From 8e0082f6cdcc153a33d01c427f1122b63b766c0b Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 26 Feb 2026 15:17:40 +0000 Subject: [PATCH] story-209: accept optional positional path argument on startup Add CLI path argument support: `story-kit-server /path/to/project` opens the given project directly (scaffolding .story_kit/ if needed) instead of relying on auto-detection. Resolves conflict with story-208's port parameter. Squash merge of feature/story-209 Co-Authored-By: Claude Sonnet 4.6 --- server/src/io/fs.rs | 97 +++++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 104 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 187 insertions(+), 14 deletions(-) diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index 57eab0e..9d2ec2d 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -383,6 +383,19 @@ To support both Remote and Local models, the system implements a `ModelProvider` const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n"; +/// Resolve a path argument supplied on the CLI against the given working +/// directory. Relative paths (including `.`) are joined with `cwd` and +/// then canonicalized when possible. Absolute paths are returned +/// canonicalized when possible, unchanged otherwise. +pub fn resolve_cli_path(cwd: &Path, path_arg: &str) -> PathBuf { + let p = PathBuf::from(path_arg); + let joined = if p.is_absolute() { p } else { cwd.join(p) }; + // Canonicalize resolves `.`, `..` and symlinks. We fall back to the + // joined (non-canonical) path when the target does not yet exist so + // that callers can still create it later. + std::fs::canonicalize(&joined).unwrap_or(joined) +} + /// Walk from `start` up through parent directories, returning the first /// directory that contains a `.story_kit/` subdirectory, or `None`. pub fn find_story_kit_root(start: &Path) -> Option { @@ -493,6 +506,8 @@ async fn ensure_project_root_with_story_kit(path: PathBuf) -> Result<(), String> if !path.exists() { fs::create_dir_all(&path) .map_err(|e| format!("Failed to create project directory: {}", e))?; + } + if !path.join(".story_kit").is_dir() { scaffold_story_kit(&path)?; } Ok(()) @@ -1058,4 +1073,86 @@ mod tests { assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); } + + // --- open_project scaffolding --- + + #[tokio::test] + async fn open_project_scaffolds_when_story_kit_missing() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await + .unwrap(); + + // .story_kit/ should have been created automatically + assert!(project_dir.join(".story_kit").is_dir()); + } + + #[tokio::test] + async fn open_project_does_not_overwrite_existing_story_kit() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + let sk_dir = project_dir.join(".story_kit"); + fs::create_dir_all(&sk_dir).unwrap(); + let readme = sk_dir.join("README.md"); + fs::write(&readme, "custom content").unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await + .unwrap(); + + // Existing .story_kit/ content should not be overwritten + assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); + } + + // --- resolve_cli_path --- + + #[test] + fn resolve_cli_path_absolute_returned_unchanged_when_nonexistent() { + let cwd = PathBuf::from("/some/cwd"); + let result = resolve_cli_path(&cwd, "/nonexistent/absolute/path"); + assert_eq!(result, PathBuf::from("/nonexistent/absolute/path")); + } + + #[test] + fn resolve_cli_path_dot_resolves_to_cwd() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let result = resolve_cli_path(&cwd, "."); + // Canonicalize should resolve "." in an existing dir to the canonical cwd + assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); + } + + #[test] + fn resolve_cli_path_relative_resolves_against_cwd() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let subdir = cwd.join("sub"); + fs::create_dir_all(&subdir).unwrap(); + let result = resolve_cli_path(&cwd, "sub"); + assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); + } + + #[test] + fn resolve_cli_path_nonexistent_relative_falls_back_to_joined() { + let tmp = tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let result = resolve_cli_path(&cwd, "newproject"); + // Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject + assert_eq!(result, cwd.join("newproject")); + } } diff --git a/server/src/main.rs b/server/src/main.rs index c542fbe..cad0225 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -29,6 +29,13 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::broadcast; +/// Resolve the optional positional path argument (everything after the binary +/// name) into an absolute `PathBuf`. Returns `None` when no argument was +/// supplied so that the caller can fall back to the auto-detect behaviour. +fn parse_project_path_arg(args: &[String], cwd: &std::path::Path) -> Option { + args.first().map(|s| io::fs::resolve_cli_path(cwd, s)) +} + #[tokio::main] async fn main() -> Result<(), std::io::Error> { let app_state = Arc::new(SessionState::default()); @@ -39,26 +46,56 @@ async fn main() -> Result<(), std::io::Error> { let port = resolve_port(); - // Auto-detect a .story_kit/ project in cwd or parent directories. - if let Some(project_root) = find_story_kit_root(&cwd) { - io::fs::open_project( - project_root.to_string_lossy().to_string(), + // Collect CLI args, skipping the binary name (argv[0]). + let cli_args: Vec = std::env::args().skip(1).collect(); + let explicit_path = parse_project_path_arg(&cli_args, &cwd); + + if let Some(explicit_root) = explicit_path { + // An explicit path was given on the command line. + // Open it directly — scaffold .story_kit/ if it is missing — and + // exit with a clear error message if the path is invalid. + match io::fs::open_project( + explicit_root.to_string_lossy().to_string(), &app_state, store.as_ref(), port, ) .await - .unwrap_or_else(|e| { - slog!("Warning: failed to auto-open project at {project_root:?}: {e}"); - project_root.to_string_lossy().to_string() - }); - - // Validate agent config for the detected project root. - config::ProjectConfig::load(&project_root) - .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + { + Ok(_) => { + if let Some(root) = app_state.project_root.lock().unwrap().as_ref() { + config::ProjectConfig::load(root) + .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } else { - // No .story_kit/ found — fall back to cwd so existing behaviour is preserved. - *app_state.project_root.lock().unwrap() = Some(cwd.clone()); + // No path argument — auto-detect a .story_kit/ project in cwd or + // parent directories (preserves existing behaviour). + if let Some(project_root) = find_story_kit_root(&cwd) { + io::fs::open_project( + project_root.to_string_lossy().to_string(), + &app_state, + store.as_ref(), + port, + ) + .await + .unwrap_or_else(|e| { + slog!("Warning: failed to auto-open project at {project_root:?}: {e}"); + project_root.to_string_lossy().to_string() + }); + + // Validate agent config for the detected project root. + config::ProjectConfig::load(&project_root) + .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); + } else { + // No .story_kit/ found — fall back to cwd so existing behaviour is preserved. + *app_state.project_root.lock().unwrap() = Some(cwd.clone()); + } } let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default())); @@ -215,4 +252,43 @@ name = "coder" config::ProjectConfig::load(tmp.path()) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } + + // ── parse_project_path_arg ──────────────────────────────────────────── + + #[test] + fn parse_project_path_arg_none_when_no_args() { + let cwd = PathBuf::from("/home/user/project"); + let result = parse_project_path_arg(&[], &cwd); + assert!(result.is_none()); + } + + #[test] + fn parse_project_path_arg_returns_path_for_absolute_arg() { + let cwd = PathBuf::from("/home/user/project"); + let args = vec!["/some/absolute/path".to_string()]; + let result = parse_project_path_arg(&args, &cwd).unwrap(); + // Absolute path returned as-is (canonicalize may fail, fallback used) + assert!(result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")); + } + + #[test] + fn parse_project_path_arg_resolves_dot_to_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let args = vec![".".to_string()]; + let result = parse_project_path_arg(&args, &cwd).unwrap(); + // "." relative to an existing cwd should canonicalize to the cwd itself + assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); + } + + #[test] + fn parse_project_path_arg_resolves_relative_path() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().to_path_buf(); + let subdir = cwd.join("myproject"); + std::fs::create_dir_all(&subdir).unwrap(); + let args = vec!["myproject".to_string()]; + let result = parse_project_path_arg(&args, &cwd).unwrap(); + assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); + } }