From 352fb5f089e74bf47b60c6221594b9c22defe251 Mon Sep 17 00:00:00 2001 From: main Date: Thu, 19 Mar 2026 17:41:40 -0400 Subject: Prepare fidget spinner for public sharing --- crates/fidget-spinner-cli/src/main.rs | 273 +++++++++++++++++++++++++++++++++- 1 file changed, 266 insertions(+), 7 deletions(-) (limited to 'crates/fidget-spinner-cli/src/main.rs') diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs index 9b2b8ae..fe4cb5f 100644 --- a/crates/fidget-spinner-cli/src/main.rs +++ b/crates/fidget-spinner-cli/src/main.rs @@ -1,8 +1,10 @@ mod bundled_skill; mod mcp; +mod ui; use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use camino::{Utf8Path, Utf8PathBuf}; @@ -10,7 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use fidget_spinner_core::{ AnnotationVisibility, CodeSnapshotRef, CommandRecipe, ExecutionBackend, FrontierContract, FrontierNote, FrontierVerdict, GitCommitHash, MetricObservation, MetricSpec, MetricUnit, - NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, + NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, TagName, }; use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment, @@ -21,7 +23,11 @@ use serde_json::{Map, Value, json}; use uuid::Uuid; #[derive(Parser)] -#[command(author, version, about = "Fidget Spinner local project CLI")] +#[command( + author, + version, + about = "Fidget Spinner CLI, MCP server, and local navigator" +)] struct Cli { #[command(subcommand)] command: Command, @@ -29,29 +35,48 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Initialize a project-local `.fidget_spinner/` store. Init(InitArgs), + /// Read the local project payload schema. Schema { #[command(subcommand)] command: SchemaCommand, }, + /// Create and inspect frontiers. Frontier { #[command(subcommand)] command: FrontierCommand, }, + /// Create, inspect, and mutate DAG nodes. Node { #[command(subcommand)] command: NodeCommand, }, + /// Record terse off-path notes. Note(NoteCommand), + /// Manage the repo-local tag registry. + Tag { + #[command(subcommand)] + command: TagCommand, + }, + /// Record off-path research and enabling work. Research(ResearchCommand), + /// Close a core-path experiment atomically. Experiment { #[command(subcommand)] command: ExperimentCommand, }, + /// Serve the hardened stdio MCP endpoint. Mcp { #[command(subcommand)] command: McpCommand, }, + /// Serve the minimal local web navigator. + Ui { + #[command(subcommand)] + command: UiCommand, + }, + /// Inspect or install bundled Codex skills. Skill { #[command(subcommand)] command: SkillCommand, @@ -60,22 +85,28 @@ enum Command { #[derive(Args)] struct InitArgs { + /// Project root to initialize. #[arg(long, default_value = ".")] project: PathBuf, + /// Human-facing project name. Defaults to the directory name. #[arg(long)] name: Option, + /// Payload schema namespace written into `.fidget_spinner/schema.json`. #[arg(long, default_value = "local.project")] namespace: String, } #[derive(Subcommand)] enum SchemaCommand { + /// Show the current project schema as JSON. Show(ProjectArg), } #[derive(Subcommand)] enum FrontierCommand { + /// Create a frontier and root contract node. Init(FrontierInitArgs), + /// Show one frontier projection or list frontiers when omitted. Status(FrontierStatusArgs), } @@ -115,10 +146,15 @@ struct FrontierStatusArgs { #[derive(Subcommand)] enum NodeCommand { + /// Create a generic DAG node. Add(NodeAddArgs), + /// List recent nodes. List(NodeListArgs), + /// Show one node in full. Show(NodeShowArgs), + /// Attach an annotation to a node. Annotate(NodeAnnotateArgs), + /// Archive a node without deleting it. Archive(NodeArchiveArgs), } @@ -138,6 +174,8 @@ struct NodeAddArgs { payload_json: Option, #[arg(long = "payload-file")] payload_file: Option, + #[command(flatten)] + tag_selection: ExplicitTagSelectionArgs, #[arg(long = "field")] fields: Vec, #[arg(long = "annotation")] @@ -154,12 +192,22 @@ struct NodeListArgs { frontier: Option, #[arg(long, value_enum)] class: Option, + #[arg(long = "tag")] + tags: Vec, #[arg(long)] include_archived: bool, #[arg(long, default_value_t = 20)] limit: u32, } +#[derive(Args, Default)] +struct ExplicitTagSelectionArgs { + #[arg(long = "tag")] + tags: Vec, + #[arg(long, conflicts_with = "tags")] + no_tags: bool, +} + #[derive(Args)] struct NodeShowArgs { #[command(flatten)] @@ -198,9 +246,18 @@ struct NoteCommand { #[derive(Subcommand)] enum NoteSubcommand { + /// Record a quick off-path note. Quick(QuickNoteArgs), } +#[derive(Subcommand)] +enum TagCommand { + /// Register a new repo-local tag. + Add(TagAddArgs), + /// List registered repo-local tags. + List(ProjectArg), +} + #[derive(Args)] struct ResearchCommand { #[command(subcommand)] @@ -209,6 +266,7 @@ struct ResearchCommand { #[derive(Subcommand)] enum ResearchSubcommand { + /// Record off-path research or enabling work. Add(QuickResearchArgs), } @@ -222,10 +280,22 @@ struct QuickNoteArgs { title: String, #[arg(long)] body: String, + #[command(flatten)] + tag_selection: ExplicitTagSelectionArgs, #[arg(long = "parent")] parents: Vec, } +#[derive(Args)] +struct TagAddArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + name: String, + #[arg(long)] + description: String, +} + #[derive(Args)] struct QuickResearchArgs { #[command(flatten)] @@ -244,16 +314,24 @@ struct QuickResearchArgs { #[derive(Subcommand)] enum ExperimentCommand { + /// Close a core-path experiment with checkpoint, run, note, and verdict. Close(ExperimentCloseArgs), } #[derive(Subcommand)] enum McpCommand { + /// Serve the public stdio MCP host. If `--project` is omitted, the host starts unbound. Serve(McpServeArgs), #[command(hide = true)] Worker(McpWorkerArgs), } +#[derive(Subcommand)] +enum UiCommand { + /// Serve the local read-only navigator. + Serve(UiServeArgs), +} + #[derive(Args)] struct ExperimentCloseArgs { #[command(flatten)] @@ -304,33 +382,41 @@ struct ExperimentCloseArgs { #[derive(Subcommand)] enum SkillCommand { + /// List bundled skills. List, + /// Install bundled skills into a Codex skill directory. Install(SkillInstallArgs), + /// Print one bundled skill body. Show(SkillShowArgs), } #[derive(Args)] struct SkillInstallArgs { + /// Bundled skill name. Defaults to all bundled skills. #[arg(long)] name: Option, + /// Destination root. Defaults to `~/.codex/skills`. #[arg(long)] destination: Option, } #[derive(Args)] struct SkillShowArgs { + /// Bundled skill name. Defaults to `fidget-spinner`. #[arg(long)] name: Option, } #[derive(Args)] struct ProjectArg { + /// Project root or any nested path inside a project containing `.fidget_spinner/`. #[arg(long, default_value = ".")] project: PathBuf, } #[derive(Args)] struct McpServeArgs { + /// Optional initial project binding. When omitted, the MCP starts unbound. #[arg(long)] project: Option, } @@ -341,6 +427,18 @@ struct McpWorkerArgs { project: PathBuf, } +#[derive(Args)] +struct UiServeArgs { + #[command(flatten)] + project: ProjectArg, + /// Bind address for the local navigator. + #[arg(long, default_value = "127.0.0.1:8913")] + bind: SocketAddr, + /// Maximum rows rendered in list views. + #[arg(long, default_value_t = 200)] + limit: u32, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CliNodeClass { Contract, @@ -416,6 +514,10 @@ fn run() -> Result<(), StoreError> { Command::Note(command) => match command.command { NoteSubcommand::Quick(args) => run_quick_note(args), }, + Command::Tag { command } => match command { + TagCommand::Add(args) => run_tag_add(args), + TagCommand::List(project) => run_tag_list(project), + }, Command::Research(command) => match command.command { ResearchSubcommand::Add(args) => run_quick_research(args), }, @@ -426,6 +528,9 @@ fn run() -> Result<(), StoreError> { McpCommand::Serve(args) => mcp::serve(args.project), McpCommand::Worker(args) => mcp::serve_worker(args.project), }, + Command::Ui { command } => match command { + UiCommand::Serve(args) => run_ui_serve(args), + }, Command::Skill { command } => match command { SkillCommand::List => print_json(&bundled_skill::bundled_skill_summaries()), SkillCommand::Install(args) => run_skill_install(args), @@ -439,16 +544,17 @@ fn run() -> Result<(), StoreError> { fn run_init(args: InitArgs) -> Result<(), StoreError> { let project_root = utf8_path(args.project); - let display_name = NonEmptyText::new(args.name.unwrap_or_else(|| { - project_root - .file_name() - .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned) - }))?; + let display_name = args + .name + .map(NonEmptyText::new) + .transpose()? + .unwrap_or(default_display_name_for_root(&project_root)?); let namespace = NonEmptyText::new(args.namespace)?; let store = ProjectStore::init(&project_root, display_name, namespace)?; println!("initialized {}", store.state_root()); println!("project: {}", store.config().display_name); println!("schema: {}", store.state_root().join("schema.json")); + maybe_print_gitignore_hint(&project_root)?; Ok(()) } @@ -498,6 +604,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> { .as_deref() .map(parse_frontier_id) .transpose()?; + let tags = optional_cli_tags(args.tag_selection, args.class == CliNodeClass::Note)?; let payload = load_payload( store.schema().schema_ref(), args.payload_json, @@ -514,6 +621,7 @@ fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> { frontier_id, title: NonEmptyText::new(args.title)?, summary: args.summary.map(NonEmptyText::new).transpose()?, + tags, payload, annotations, attachments: lineage_attachments(args.parents)?, @@ -530,6 +638,7 @@ fn run_node_list(args: NodeListArgs) -> Result<(), StoreError> { .map(parse_frontier_id) .transpose()?, class: args.class.map(Into::into), + tags: parse_tag_set(args.tags)?, include_archived: args.include_archived, limit: args.limit, })?; @@ -585,6 +694,7 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { .transpose()?, title: NonEmptyText::new(args.title)?, summary: None, + tags: Some(explicit_cli_tags(args.tag_selection)?), payload, annotations: Vec::new(), attachments: lineage_attachments(args.parents)?, @@ -592,6 +702,20 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { print_json(&node) } +fn run_tag_add(args: TagAddArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let tag = store.add_tag( + TagName::new(args.name)?, + NonEmptyText::new(args.description)?, + )?; + print_json(&tag) +} + +fn run_tag_list(args: ProjectArg) -> Result<(), StoreError> { + let store = open_store(&args.project)?; + print_json(&store.list_tags()?) +} + fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; let payload = NodePayload::with_schema( @@ -607,6 +731,7 @@ fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { .transpose()?, title: NonEmptyText::new(args.title)?, summary: args.summary.map(NonEmptyText::new).transpose()?, + tags: None, payload, annotations: Vec::new(), attachments: lineage_attachments(args.parents)?, @@ -684,6 +809,10 @@ fn run_skill_install(args: SkillInstallArgs) -> Result<(), StoreError> { Ok(()) } +fn run_ui_serve(args: UiServeArgs) -> Result<(), StoreError> { + ui::serve(utf8_path(args.project.project), args.bind, args.limit) +} + fn resolve_bundled_skill( requested_name: Option<&str>, ) -> Result { @@ -712,10 +841,88 @@ fn open_store(path: &Path) -> Result { ProjectStore::open(utf8_path(path.to_path_buf())) } +fn open_or_init_store_for_binding(path: &Path) -> Result { + let requested_root = utf8_path(path.to_path_buf()); + match ProjectStore::open(requested_root.clone()) { + Ok(store) => Ok(store), + Err(StoreError::MissingProjectStore(_)) => { + let project_root = binding_bootstrap_root(&requested_root)?; + if !is_empty_directory(&project_root)? { + return Err(StoreError::MissingProjectStore(requested_root)); + } + ProjectStore::init( + &project_root, + default_display_name_for_root(&project_root)?, + default_namespace_for_root(&project_root)?, + ) + } + Err(error) => Err(error), + } +} + fn utf8_path(path: impl Into) -> Utf8PathBuf { Utf8PathBuf::from(path.into().to_string_lossy().into_owned()) } +fn binding_bootstrap_root(path: &Utf8Path) -> Result { + match fs::metadata(path.as_std_path()) { + Ok(metadata) if metadata.is_file() => Ok(path + .parent() + .map_or_else(|| path.to_path_buf(), Utf8Path::to_path_buf)), + Ok(_) => Ok(path.to_path_buf()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(error) => Err(StoreError::from(error)), + } +} + +fn is_empty_directory(path: &Utf8Path) -> Result { + match fs::metadata(path.as_std_path()) { + Ok(metadata) if metadata.is_dir() => { + let mut entries = fs::read_dir(path.as_std_path())?; + Ok(entries.next().transpose()?.is_none()) + } + Ok(_) => Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(StoreError::from(error)), + } +} + +fn default_display_name_for_root(project_root: &Utf8Path) -> Result { + NonEmptyText::new( + project_root + .file_name() + .map_or_else(|| "fidget-spinner-project".to_owned(), ToOwned::to_owned), + ) + .map_err(StoreError::from) +} + +fn default_namespace_for_root(project_root: &Utf8Path) -> Result { + let slug = slugify_namespace_component(project_root.file_name().unwrap_or("project")); + NonEmptyText::new(format!("local.{slug}")).map_err(StoreError::from) +} + +fn slugify_namespace_component(raw: &str) -> String { + let mut slug = String::new(); + let mut previous_was_separator = false; + for character in raw.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + slug.push(character); + previous_was_separator = false; + continue; + } + if !previous_was_separator { + slug.push('_'); + previous_was_separator = true; + } + } + let slug = slug.trim_matches('_').to_owned(); + if slug.is_empty() { + "project".to_owned() + } else { + slug + } +} + fn to_text_vec(values: Vec) -> Result, StoreError> { values .into_iter() @@ -728,6 +935,35 @@ fn to_text_set(values: Vec) -> Result, StoreError to_text_vec(values).map(BTreeSet::from_iter) } +fn parse_tag_set(values: Vec) -> Result, StoreError> { + values + .into_iter() + .map(TagName::new) + .collect::, _>>() + .map_err(StoreError::from) +} + +fn explicit_cli_tags(selection: ExplicitTagSelectionArgs) -> Result, StoreError> { + optional_cli_tags(selection, true)?.ok_or(StoreError::NoteTagsRequired) +} + +fn optional_cli_tags( + selection: ExplicitTagSelectionArgs, + required: bool, +) -> Result>, StoreError> { + if selection.no_tags { + return Ok(Some(BTreeSet::new())); + } + if selection.tags.is_empty() { + return if required { + Err(StoreError::NoteTagsRequired) + } else { + Ok(None) + }; + } + Ok(Some(parse_tag_set(selection.tags)?)) +} + fn parse_env(values: Vec) -> BTreeMap { values .into_iter() @@ -825,6 +1061,29 @@ fn run_git(project_root: &Utf8Path, args: &[&str]) -> Result, Sto Ok(Some(text)) } +fn maybe_print_gitignore_hint(project_root: &Utf8Path) -> Result<(), StoreError> { + if run_git(project_root, &["rev-parse", "--show-toplevel"])?.is_none() { + return Ok(()); + } + + let status = std::process::Command::new("git") + .arg("-C") + .arg(project_root.as_str()) + .args(["check-ignore", "-q", ".fidget_spinner"]) + .status()?; + + match status.code() { + Some(0) => Ok(()), + Some(1) => { + println!( + "note: add `.fidget_spinner/` to `.gitignore` or `.git/info/exclude` if you do not want local state in `git status`" + ); + Ok(()) + } + _ => Ok(()), + } +} + fn parse_metric_observation(raw: String) -> Result { let parts = raw.split(':').collect::>(); if parts.len() != 4 { -- cgit v1.2.3