From e294f580cda55cf806627c61dae3c192e41e764d Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Sun, 4 Oct 2026 15:52:44 -0700 Subject: [PATCH] silverwood: define Jsonnet CLI contract and check clap conformance --- experimental/silverwood/CLI_SPEC.jsonnet | 212 ++++++++++++++ experimental/silverwood/DESIGN.md | 9 + .../silverwood-cli/src/contract_test.rs | 260 ++++++++++++++++++ .../crates/silverwood-cli/src/main.rs | 57 +++- .../crates/silverwood-cli/tests/cli.rs | 37 ++- experimental/silverwood/flake.nix | 8 +- 6 files changed, 571 insertions(+), 12 deletions(-) create mode 100644 experimental/silverwood/CLI_SPEC.jsonnet create mode 100644 experimental/silverwood/crates/silverwood-cli/src/contract_test.rs diff --git a/experimental/silverwood/CLI_SPEC.jsonnet b/experimental/silverwood/CLI_SPEC.jsonnet new file mode 100644 index 0000000..cea7533 --- /dev/null +++ b/experimental/silverwood/CLI_SPEC.jsonnet @@ -0,0 +1,212 @@ +// Human-edited algebraic contract for the public `silverwood` CLI. +// Each command's options and positionals form a product; its subcommands form a sum. +// An option applies at its declaring command and every descendant. +// generatedDescriptionHint is an initial agent-generated description; edit intentHint +// to explain the interface decision to future implementers. +// `validation` identifies intended behavior, but the initial conformance check does +// not inspect imperative Rust validators. + +local domain(semanticName=null, validation=null, extensionalDef=null) = + (if semanticName == null then {} else { semanticName: semanticName }) + + (if validation == null then {} else { validation: validation }) + + (if extensionalDef == null then {} else { extensionalDef: extensionalDef }); + +local command(name, description, options=[], positionals=[], subcommands=[], intent=null, hidden=false) = { + name: name, + intentHint: if intent == null then description else intent, + generatedDescriptionHint: description, + options: options, + positionals: positionals, + subcommands: subcommands, + hidden: hidden, +}; + +local option(longName, description, valueDomain=null, required=false, intent=null) = { + longName: longName, + arity: if valueDomain == null then 0 else 1, + intentHint: if intent == null then description else intent, + generatedDescriptionHint: description, + required: required, +} + (if valueDomain == null then {} else { domain: valueDomain }); + +local positional(description, valueDomain, intent=null) = { + domain: valueDomain, + required: true, + intentHint: if intent == null then description else intent, + generatedDescriptionHint: description, +}; + +local id = function(what='workstream') + positional('The ' + what + ' id.', domain('ID', 'silverwoodFn:workstreamId')); +local sessionId = positional('The session id.', domain('SESSION_ID')); +local name = positional('The new display name.', domain('NAME')); +local workingDir = option( + 'override-working-directory', + 'Working directory to run in (default: this process\'s own cwd).', + domain('PATH', 'rustType:std::path::Path') +); +local runDirenv = option( + 'run-direnv-exec', + 'Whether to run claude under direnv exec; supply true or false.', + domain('RUN_DIRENV_EXEC', extensionalDef=['true', 'false']), + required=true +); +local firstRun = command( + 'first-run', 'First launch of this Claude session id.', + positionals=[sessionId], options=[workingDir] +); +local resume = command( + 'resume', 'Resume an existing Claude conversation.', + positionals=[sessionId], options=[workingDir] +); + +command( + 'silverwood', + 'Frontend-agnostic backend for code and agent workstreams.', + intent='Expose the same persistent workstream and session state to humans and shell-driven frontends.', + options=[ + option('forest', 'Path to the forest; overrides the environment and home default.', + domain('DIR', 'rustType:std::path::Path')), + option('json', 'Emit machine-readable JSON instead of human-readable text.'), + ], + subcommands=[ + command('info', 'Open the forest and print its identity.'), + command( + 'new', 'Create a workstream.', + intent='Make the user choose a workstream kind and supply a human name.', + options=[option('name', 'Human-friendly workstream name.', domain('NAME'))], + subcommands=[ + command( + 'basic', 'Create a basic workstream using a checkout mode.', + intent='Make checkout timing and mode explicit before creating a managed code checkout.', + options=[option( + 'checkout-extent', 'Provision now or defer checkout until later.', + domain('CHECKOUT_EXTENT', extensionalDef=['full', 'skip']), required=true + )], + subcommands=[ + command('jj-colocated', 'Clone an HTTPS Git endpoint with a colocated jj repository.', + positionals=[positional('HTTPS Git endpoint to clone.', + domain('SOURCE_HTTPS_URL', 'silverwoodFn:httpsGitUrl'))]), + command('jj-colocated-direnv-unsafe', + 'Clone with jj, then preapprove the checkout environment with direnv.', + positionals=[positional('HTTPS Git endpoint to clone.', + domain('SOURCE_HTTPS_URL', 'silverwoodFn:httpsGitUrl'))]), + command('apfs-cow', 'Copy a local directory using APFS copy-on-write.', + positionals=[positional('Absolute source directory to copy.', + domain('ABSOLUTE_PATH', 'silverwoodFn:absolutePath'))]), + command('apfs-cow-direnv-unsafe', + 'Copy a local directory using APFS, then preapprove its environment.', + positionals=[positional('Absolute source directory to copy.', + domain('ABSOLUTE_PATH', 'silverwoodFn:absolutePath'))]), + ] + ), + command('local-unmanaged-existing-path', 'Adopt an existing directory without managing it.', + positionals=[positional('Absolute path of the existing directory.', + domain('ABSOLUTE_PATH', 'silverwoodFn:absolutePath'))]), + command('local-tmp', 'Create a fresh ephemeral directory under /tmp.'), + command('local-blank', 'Create a fresh empty directory inside the forest.'), + ] + ), + command('ls', 'List workstreams.', + options=[option('all', 'Include archived and deleted workstreams.')]), + command( + 'workstream', 'Operate on one workstream by id.', + intent='Group actions that affect a specific workstream under its stable id.', + positionals=[id()], + subcommands=[ + command('show', 'Show the workstream.'), + command('archive', 'Archive the workstream.'), + command('remove', 'Delete a managed workstream and its directory when safe.', + options=[option('force', 'Override the managed directory safety check.')]), + command('rename', 'Rename the workstream.', positionals=[name]), + command('basic', 'Operations specific to a basic workstream.', + subcommands=[command('checkout', 'Provision a deferred basic checkout.')]), + ] + ), + command( + 'kv', 'Read and write frontend-owned namespaced key-value state.', + intent='Allow frontends to persist their own per-workstream state without changing core schema.', + subcommands=[ + command('set', 'Set a JSON string value.', positionals=[ + id(), + positional('Namespace to write.', domain('NAMESPACE')), + positional('Key within the namespace.', domain('KEY')), + positional('Opaque JSON string value.', domain('VALUE')), + ]), + command('get', 'Get one value.', positionals=[ + id(), + positional('Namespace containing the key.', domain('NAMESPACE')), + positional('Key within the namespace.', domain('KEY')), + ]), + command('ls', 'List every entry in a namespace.', positionals=[ + id(), positional('Namespace to list.', domain('NAMESPACE')), + ]), + command('unset', 'Remove one value.', positionals=[ + id(), + positional('Namespace containing the key.', domain('NAMESPACE')), + positional('Key to remove.', domain('KEY')), + ]), + ] + ), + command( + 'session', 'Manage sessions attached to workstreams.', + intent='Treat agent and shell sessions as durable records associated with a workstream.', + subcommands=[ + command( + 'create', 'Create a session of a chosen kind.', + subcommands=[ + command('claude-code', 'Record a Claude Code session.', + options=[option('name', 'Display name; defaults to the session id.', domain('NAME'))], + positionals=[id(), sessionId]), + command('codex', 'Start Codex and register its generated session id.', + options=[option('name', 'Display name; defaults to the generated id.', domain('NAME'))], + positionals=[id()]), + command('plain-shell', 'Record a plain login shell session.', + options=[option('name', 'Display name; defaults to the session id.', domain('NAME'))], + positionals=[id(), sessionId]), + command('claude-code-noninteractiveshell', + 'Record a Claude Code session run without an interactive shell.', + options=[option('name', 'Display name; defaults to the session id.', domain('NAME')), + runDirenv], positionals=[id(), sessionId]), + command('disk-space', 'Record a disk-space monitor session.', + options=[option('name', 'Display name; defaults to the session id.', domain('NAME'))], + positionals=[id(), sessionId]), + ] + ), + command('ls', 'List sessions on a workstream.', positionals=[id()]), + command('rename', 'Rename one session.', positionals=[id(), sessionId, name]), + command('rm', 'Remove one session.', positionals=[id(), sessionId]), + command('lock', 'Acquire a session advisory lock.', + options=[option('holder', 'Opaque token identifying the lock holder.', domain('HOLDER'), required=true), + option('force', 'Steal an existing lock.')], + positionals=[id(), sessionId]), + command('unlock', 'Release a session advisory lock.', + options=[option('holder', 'Only release a lock held by this token.', domain('HOLDER')), + option('force', 'Release regardless of current holder.')], + positionals=[id(), sessionId]), + command('doctor', 'Report the health of a recorded session.', + positionals=[id(), sessionId]), + ] + ), + command('upgrade-forest', 'Upgrade every document to the latest schema version.', + options=[option('dry-run', 'Report what would change without writing.')]), + command('new-schema', 'Describe the new subcommand tree for frontends.'), + command('session-schema', 'Describe session creation kinds for frontends.'), + command( + 'spawn', 'Run a session, or inspect its shell plan with --json.', + intent='Launch a recorded or direct session in the intended working directory; --json reveals the plan.', + subcommands=[ + command('from-id', 'Run a recorded session in its workstream checkout.', + options=[option('workstream', 'Workstream containing the session.', domain('WORKSTREAM'), required=true)], + positionals=[sessionId]), + command('plain-shell', 'Run a plain interactive login shell.', options=[workingDir]), + command('disk-space', 'Run a disk-space monitor.', options=[workingDir]), + command('claude-code', 'Run a Claude Code session.', subcommands=[firstRun, resume]), + command('claude-code-noninteractiveshell', + 'Run Claude Code without an interactive shell.', + options=[runDirenv], subcommands=[firstRun, resume]), + ] + ), + command('__register-codex-session', 'Internal Codex SessionStart callback.', hidden=true), + ] +) diff --git a/experimental/silverwood/DESIGN.md b/experimental/silverwood/DESIGN.md index 8319a00..7fb3139 100644 --- a/experimental/silverwood/DESIGN.md +++ b/experimental/silverwood/DESIGN.md @@ -261,6 +261,15 @@ rejected on user/API input. emits machine-readable `--json`. Any frontend — Rust (link core), or any language (shell out to the CLI) — drives the same backend. +The human-edited CLI syntax contract is [`CLI_SPEC.jsonnet`](./CLI_SPEC.jsonnet). +Its nested `subcommands` are alternatives; each node's options and positionals +are used together. Options are accepted at the node where declared and below it. +The CLI crate compares this contract with clap in its test suite. `intentHint` +explains the intended interface to implementers, while `generatedDescriptionHint` +holds initial agent-generated wording; neither prose field is compared with +clap. Validator URIs express intended value checks but are not yet statically +matched to the Rust handlers. + Provisional core surface (illustrative, not final): ```rust diff --git a/experimental/silverwood/crates/silverwood-cli/src/contract_test.rs b/experimental/silverwood/crates/silverwood-cli/src/contract_test.rs new file mode 100644 index 0000000..be893da --- /dev/null +++ b/experimental/silverwood/crates/silverwood-cli/src/contract_test.rs @@ -0,0 +1,260 @@ +//! Compare the human-edited Jsonnet tree against the independently declared clap tree. +//! This checks syntax and finite domains. Validator URI wiring and prose are intentionally +//! outside this initial structural check. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Command; + +use clap::{Arg, ArgAction, CommandFactory}; +use serde::Deserialize; + +use super::Cli; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CommandSpec { + name: String, + intent_hint: String, + generated_description_hint: String, + #[serde(default)] + options: Vec, + #[serde(default)] + positionals: Vec, + #[serde(default)] + subcommands: Vec, + #[serde(default)] + hidden: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OptionSpec { + long_name: String, + arity: usize, + #[serde(default)] + domain: Option, + #[serde(default)] + required: bool, + intent_hint: String, + generated_description_hint: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PositionalSpec { + domain: DomainSpec, + required: bool, + intent_hint: String, + generated_description_hint: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DomainSpec { + #[serde(default)] + semantic_name: Option, + #[serde(default)] + validation: Option, + #[serde(default)] + extensional_def: Option>, +} + +#[test] +fn cli_conforms_to_jsonnet_contract() { + let spec_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../CLI_SPEC.jsonnet"); + let jsonnet: OsString = std::env::var_os("JSONNET_BIN").unwrap_or_else(|| "jsonnet".into()); + let output = Command::new(&jsonnet) + .arg(&spec_path) + .output() + .unwrap_or_else(|e| panic!("cannot run {}: {e}", jsonnet.to_string_lossy())); + assert!( + output.status.success(), + "Jsonnet evaluation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let spec: CommandSpec = serde_json::from_slice(&output.stdout) + .unwrap_or_else(|e| panic!("invalid CLI_SPEC.jsonnet result: {e}")); + compare_command(&Cli::command(), &spec, "silverwood"); +} + +fn compare_command(actual: &clap::Command, spec: &CommandSpec, path: &str) { + assert_eq!(actual.get_name(), spec.name, "command name at {path}"); + assert_eq!(actual.is_hide_set(), spec.hidden, "visibility at {path}"); + assert_eq!( + actual.get_all_aliases().count(), + 0, + "unmodeled alias at {path}" + ); + check_hints(&spec.intent_hint, &spec.generated_description_hint, path); + assert_eq!( + actual.is_subcommand_required_set(), + !spec.subcommands.is_empty(), + "subcommand requirement at {path}" + ); + + let options: Vec<_> = actual + .get_arguments() + .filter(|arg| arg.get_long().is_some()) + .collect(); + assert_eq!( + options.len(), + spec.options.len(), + "option count at {path}; actual: {:?}", + options.iter().map(|a| a.get_long()).collect::>() + ); + for (actual, expected) in options.into_iter().zip(&spec.options) { + let label = format!("{path} --{}", expected.long_name); + assert_eq!( + actual.get_long(), + Some(expected.long_name.as_str()), + "{label}" + ); + assert!( + actual.get_short().is_none(), + "unmodeled short alias: {label}" + ); + assert!( + actual.get_all_aliases().is_none(), + "unmodeled alias: {label}" + ); + assert!(!actual.is_hide_set(), "unmodeled hidden option: {label}"); + assert_eq!(arity(actual), expected.arity, "arity at {label}"); + // clap disallows a required global argument. Those options are checked + // after parsing, and their missing-value behavior is covered by CLI tests. + // A new case must be explicitly reviewed and added here with a runtime test. + if expected.required && !spec.subcommands.is_empty() { + assert!( + matches!( + (path, expected.long_name.as_str()), + ("silverwood new basic", "checkout-extent") + | ( + "silverwood spawn claude-code-noninteractiveshell", + "run-direnv-exec" + ) + ), + "new inherited required option needs a runtime check: {label}" + ); + } + let clap_required = expected.required && spec.subcommands.is_empty(); + assert_eq!( + actual.is_required_set(), + clap_required, + "required at {label}" + ); + if !spec.subcommands.is_empty() { + assert!(actual.is_global_set(), "{label} must apply to descendants"); + } + check_hints( + &expected.intent_hint, + &expected.generated_description_hint, + &label, + ); + match (&expected.domain, expected.arity) { + (None, 0) => {} + (Some(domain), 1) => compare_domain(actual, domain, &label), + _ => panic!("a one-value option needs a domain and a flag has none: {label}"), + } + } + + let positionals: Vec<_> = actual + .get_arguments() + .filter(|arg| arg.get_long().is_none()) + .collect(); + assert_eq!( + positionals.len(), + spec.positionals.len(), + "positionals at {path}" + ); + for (index, (actual, expected)) in positionals.into_iter().zip(&spec.positionals).enumerate() { + let label = format!("{path} positional {}", index + 1); + assert!(actual.get_short().is_none(), "unmodeled short arg: {label}"); + assert_eq!(arity(actual), 1, "arity at {label}"); + assert_eq!( + actual.is_required_set(), + expected.required, + "required at {label}" + ); + check_hints( + &expected.intent_hint, + &expected.generated_description_hint, + &label, + ); + compare_domain(actual, &expected.domain, &label); + } + + let children: Vec<_> = actual.get_subcommands().collect(); + assert_eq!( + children.len(), + spec.subcommands.len(), + "subcommand count at {path}; actual: {:?}", + children.iter().map(|c| c.get_name()).collect::>() + ); + for (child, expected) in children.into_iter().zip(&spec.subcommands) { + compare_command(child, expected, &format!("{path} {}", expected.name)); + } +} + +fn arity(arg: &Arg) -> usize { + let from_action = match arg.get_action() { + ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count => 0, + ArgAction::Set => 1, + action => panic!("unsupported clap action for {}: {action:?}", arg.get_id()), + }; + if let Some(range) = arg.get_num_args() { + assert_eq!( + range.min_values(), + from_action, + "minimum arity for {}", + arg.get_id() + ); + assert_eq!( + range.max_values(), + from_action, + "maximum arity for {}", + arg.get_id() + ); + } + from_action +} + +fn compare_domain(arg: &Arg, domain: &DomainSpec, label: &str) { + if let Some(name) = &domain.semantic_name { + assert!(!name.is_empty(), "empty semanticName at {label}"); + let actual_name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.to_string()) + .unwrap_or_else(|| arg.get_id().as_str().to_ascii_uppercase()); + assert_eq!(actual_name, name.as_str(), "semanticName at {label}"); + } + if let Some(uri) = &domain.validation { + assert!( + (uri.starts_with("rustType:") || uri.starts_with("silverwoodFn:")) + && uri + .split_once(':') + .is_some_and(|(_, suffix)| !suffix.is_empty()), + "unsupported validation URI at {label}: {uri}" + ); + } + let actual_values: Vec<_> = arg + .get_possible_values() + .into_iter() + .filter(|v| !v.is_hide_set()) + .map(|v| v.get_name().to_string()) + .collect(); + let expected_values = domain.extensional_def.as_deref().unwrap_or(&[]); + assert_eq!( + actual_values.as_slice(), + expected_values, + "extensionalDef at {label}" + ); +} + +fn check_hints(intent: &str, generated: &str, label: &str) { + assert!(!intent.trim().is_empty(), "empty intentHint at {label}"); + assert!( + !generated.trim().is_empty(), + "empty generatedDescriptionHint at {label}" + ); +} diff --git a/experimental/silverwood/crates/silverwood-cli/src/main.rs b/experimental/silverwood/crates/silverwood-cli/src/main.rs index 2073d21..23b0e02 100644 --- a/experimental/silverwood/crates/silverwood-cli/src/main.rs +++ b/experimental/silverwood/crates/silverwood-cli/src/main.rs @@ -4,6 +4,10 @@ //! emit machine-readable JSON with `--json`, so any frontend can drive the same //! backend by shelling out. The one frontend policy that lives here is the //! default forest location, `$HOME/.silverwood`. +//! +//! The human-edited CLI contract is `experimental/silverwood/CLI_SPEC.jsonnet`. +//! Keep the clap documentation and implementation aligned with its `intentHint` +//! prose when changing a command, option, or positional. use std::collections::BTreeMap; use std::io::Read; @@ -25,6 +29,8 @@ const CODEX_REGISTER_NAME: &str = "SILVERWOOD_CODEX_REGISTER_NAME"; /// Frontend-agnostic backend for the code you work on and the agent sessions /// attached to it. +/// Intent: expose the same persistent workstream and session state to humans and +/// shell-driven frontends. #[derive(Parser)] #[command(name = "silverwood", version, about, long_about = None)] struct Cli { @@ -47,6 +53,7 @@ enum Command { /// Create a workstream. Whether its checkout is provisioned now or deferred is /// chosen per kind (basic: `--checkout-extent full|skip`). + /// Intent: make the user choose a workstream kind and supply a human name. New { /// Human-friendly name (accepted at any `new` subcommand level). #[arg(long, global = true)] @@ -65,6 +72,7 @@ enum Command { /// Operate on one workstream by id: the kind-agnostic verbs (show, archive, remove, /// rename) plus the per-kind `` namespace (e.g. `basic`), which is validated /// against the workstream's actual kind. + /// Intent: group actions that affect a specific workstream under its stable id. Workstream { /// The workstream id (from `silverwood ls`). id: String, @@ -73,10 +81,14 @@ enum Command { }, /// Namespaced key-value state (frontend-owned). + /// Intent: allow frontends to persist their own per-workstream state without + /// changing core schema. #[command(subcommand)] Kv(KvCommand), /// Agent sessions (a kind-aware wrapper over the reserved session kv). + /// Intent: treat agent and shell sessions as durable records associated with a + /// workstream. #[command(subcommand)] Session(SessionCommand), @@ -102,6 +114,8 @@ enum Command { /// looks up a durable session and runs it the way it records itself; the per-kind /// subcommands run a kind directly in the current directory (or /// `--override-working-directory`). The environment is a scrubbed login env either way. + /// Intent: launch a recorded or direct session in the intended working directory; + /// `--json` reveals the plan. Spawn { #[command(subcommand)] what: SpawnCommand, @@ -147,8 +161,8 @@ enum SpawnCommand { /// control over loading the checkout's `.envrc`. ClaudeCodeNoninteractiveshell { /// Whether to wrap claude in `direnv exec ` — required, `true` or `false`. - #[arg(long, action = clap::ArgAction::Set, required = true)] - run_direnv_exec: bool, + #[arg(long, global = true, action = clap::ArgAction::Set)] + run_direnv_exec: Option, #[command(subcommand)] run: ClaudeRunArg, }, @@ -378,11 +392,13 @@ enum SessionCreate { #[derive(Subcommand)] enum NewVariant { /// A basic workstream, materialized by a checkout mode. + /// Intent: make checkout timing and mode explicit before creating a managed + /// code checkout. Basic { /// Whether to provision the checkout now (`full`) or register the workstream and /// defer provisioning to `silverwood workstream basic checkout` (`skip`). - #[arg(long, value_enum)] - checkout_extent: CheckoutExtentArg, + #[arg(long, global = true, value_enum)] + checkout_extent: Option, #[command(subcommand)] mode: NewModeArg, }, @@ -685,6 +701,7 @@ fn main() -> ExitCode { type CliResult = Result<(), Box>; fn run(cli: Cli) -> CliResult { + validate_required_options(&cli)?; let root = match cli.forest { Some(path) => path, None => resolve_forest_dir()?, @@ -759,6 +776,29 @@ fn run(cli: Cli) -> CliResult { } } +/// clap cannot mark an inherited option as required. Check those options before +/// opening the forest so an invalid invocation has no persistent side effects. +fn validate_required_options(cli: &Cli) -> CliResult { + match &cli.command { + Command::New { + variant: + NewVariant::Basic { + checkout_extent: None, + .. + }, + .. + } => Err("`--checkout-extent ` is required".into()), + Command::Spawn { + what: + SpawnCommand::ClaudeCodeNoninteractiveshell { + run_direnv_exec: None, + .. + }, + } => Err("`--run-direnv-exec ` is required".into()), + _ => Ok(()), + } +} + fn run_new(forest: &Forest, json: bool, name: Option, variant: NewVariant) -> CliResult { let name = name.ok_or("`--name ` is required")?; let kind = match variant { @@ -767,7 +807,9 @@ fn run_new(forest: &Forest, json: bool, name: Option, variant: NewVarian checkout_extent, } => NewKind::Basic { mode: mode.into_new_mode()?, - checkout_extent: checkout_extent.into(), + checkout_extent: checkout_extent + .ok_or("`--checkout-extent ` is required")? + .into(), }, NewVariant::LocalUnmanagedExistingPath { path } => NewKind::LocalUnmanagedExistingPath { path: AbsolutePath::parse(&path)?, @@ -1047,6 +1089,8 @@ fn run_spawn(forest: &Forest, json: bool, what: SpawnCommand) -> CliResult { run, } => { let (session_id, run, wd) = run.parts(); + let run_direnv_exec = + run_direnv_exec.ok_or("`--run-direnv-exec ` is required")?; claude_code_noninteractiveshell_plan( &wd.resolve()?, &session_id, @@ -1348,3 +1392,6 @@ fn resolve_codex_home() -> Result> { std::env::var_os("HOME").ok_or("cannot locate Codex home: set CODEX_HOME or HOME")?; Ok(PathBuf::from(home).join(".codex")) } + +#[cfg(test)] +mod contract_test; diff --git a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs index 6cffa16..1c74db5 100644 --- a/experimental/silverwood/crates/silverwood-cli/tests/cli.rs +++ b/experimental/silverwood/crates/silverwood-cli/tests/cli.rs @@ -105,9 +105,21 @@ fn new_subcommand_structure_mirrors_the_data_model() { fails(&dir, &["new"]); fails(&dir, &["new", "basic"]); - // `basic` demands its `--checkout-extent` (full|skip); omitting it is a clap error. - let stderr = fails(&dir, &["new", "basic", "jj-colocated", "--name", "x"]); + // `basic` demands its inherited `--checkout-extent` (full|skip). clap cannot + // mark a global option required, so the handler reports its absence. + let stderr = fails( + &dir, + &[ + "new", + "basic", + "jj-colocated", + "https://github.com/a/b.git", + "--name", + "x", + ], + ); assert!(stderr.contains("checkout-extent"), "got: {stderr}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); // A mode leaf demands its seed: the `` positional. let stderr = fails( @@ -216,10 +228,10 @@ fn new_skip_registers_without_provisioning() { "--json", "new", "basic", - "--checkout-extent", - "skip", "jj-colocated", "https://github.com/octocat/Hello-World.git", + "--checkout-extent", + "skip", "--name", "deferred", ], @@ -472,6 +484,19 @@ fn spawn_from_id_rejects_bad_and_absent_ids() { fn spawn_direct_variants_reflect_the_kind() { let dir = forest(); + let stderr = fails( + &dir, + &[ + "--json", + "spawn", + "claude-code-noninteractiveshell", + "first-run", + "sess-1", + ], + ); + assert!(stderr.contains("run-direnv-exec"), "got: {stderr}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + // plain-shell: an interactive login shell (` -l`). let plan = json( &dir, @@ -519,10 +544,10 @@ fn spawn_direct_variants_reflect_the_kind() { "--json", "spawn", "claude-code-noninteractiveshell", - "--run-direnv-exec", - "false", "resume", "sess-1", + "--run-direnv-exec", + "false", "--override-working-directory", "/tmp/x", ], diff --git a/experimental/silverwood/flake.nix b/experimental/silverwood/flake.nix index 3149028..63b8e2b 100644 --- a/experimental/silverwood/flake.nix +++ b/experimental/silverwood/flake.nix @@ -38,7 +38,10 @@ src = ./.; name = "source"; filter = - path: type: (lib.hasInfix "/corpus/" path) || (craneLib.filterCargoSources path type); + path: type: + (lib.hasInfix "/corpus/" path) + || (lib.hasSuffix "/CLI_SPEC.jsonnet" path) + || (craneLib.filterCargoSources path type); }; commonArgs = { @@ -60,6 +63,7 @@ commonArgs // { inherit cargoArtifacts; + nativeBuildInputs = [ pkgs.go-jsonnet ]; meta.mainProgram = "silverwood"; } ); @@ -116,6 +120,7 @@ commonArgs // { inherit cargoArtifacts; + nativeBuildInputs = [ pkgs.go-jsonnet ]; partitions = 1; partitionType = "count"; cargoNextestPartitionsExtraArgs = "--no-tests=pass"; @@ -141,6 +146,7 @@ pkgs.git pkgs.direnv pkgs.taplo + pkgs.go-jsonnet ]; }; } -- 2.54.0