Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 225 additions & 2 deletions crates/vfs_cli_app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ pub enum Command {
#[command(subcommand)]
command: DatabaseCommand,
},
#[command(about = "Read official docs sources as citation-ready LLM context")]
Docs {
#[command(subcommand)]
command: DocsCommand,
},
#[command(about = "Show the current authenticated canister identity")]
Identity {
#[command(subcommand)]
Expand Down Expand Up @@ -337,6 +342,74 @@ pub enum Command {
},
}

#[derive(Subcommand, Debug, Clone)]
pub enum DocsCommand {
#[command(about = "List and search docs sources under /Wiki/sources")]
Source {
#[command(subcommand)]
command: DocsSourceCommand,
},
#[command(about = "Build citation-ready docs context packs")]
Context {
#[command(subcommand)]
command: DocsContextCommand,
},
#[command(about = "Extract citation records from a docs evidence pack")]
Cite {
#[arg(long)]
input: PathBuf,
#[arg(long)]
json: bool,
},
}

#[derive(Subcommand, Debug, Clone)]
pub enum DocsSourceCommand {
#[command(about = "List registered docs sources")]
List {
#[arg(long)]
json: bool,
},
#[command(about = "Resolve a search query to likely docs sources")]
Resolve {
query: String,
#[arg(long, default_value_t = 10)]
top_k: u32,
#[arg(long)]
json: bool,
},
#[command(about = "Search one docs source and return chunk evidence")]
Query {
query: String,
#[arg(long)]
source_id: String,
#[arg(long)]
version: Option<String>,
#[arg(long, default_value_t = 10)]
top_k: u32,
#[arg(long, default_value_t = 4_000)]
max_tokens: u32,
#[arg(long)]
json: bool,
},
}

#[derive(Subcommand, Debug, Clone)]
pub enum DocsContextCommand {
#[command(about = "Pack docs evidence across resolved sources within a token budget")]
Pack {
query: String,
#[arg(long, default_value_t = 3)]
top_sources: u32,
#[arg(long, default_value_t = 6)]
top_k_per_source: u32,
#[arg(long, default_value_t = 8_000)]
max_tokens: u32,
#[arg(long)]
json: bool,
},
}

#[derive(Subcommand, Debug, Clone)]
pub enum SkillCommand {
#[command(about = "Store or update a Skill Registry package from a local directory")]
Expand Down Expand Up @@ -666,6 +739,7 @@ impl Command {
command,
SkillCommand::Find { .. } | SkillCommand::Inspect { .. }
),
Self::Docs { .. } => false,
Self::Hermes { command } => matches!(
command,
HermesCommand::Setup { .. }
Expand Down Expand Up @@ -710,6 +784,7 @@ impl Command {
command,
SkillCommand::Find { .. } | SkillCommand::Inspect { .. }
),
Self::Docs { command } => !matches!(command, DocsCommand::Cite { .. }),
Self::ReadNode { .. }
| Self::ListNodes { .. }
| Self::ListChildren { .. }
Expand Down Expand Up @@ -970,8 +1045,9 @@ impl Command {
#[cfg(test)]
mod tests {
use super::{
ClaudeCommand, Cli, CodexCommand, Command, DatabaseCommand, HermesCommand, IdentityModeArg,
NodeKindArg, SkillCommand, SkillImportCommand, SkillRunOutcomeArg, SkillStatusArg,
ClaudeCommand, Cli, CodexCommand, Command, DatabaseCommand, DocsCommand,
DocsContextCommand, DocsSourceCommand, HermesCommand, IdentityModeArg, NodeKindArg,
SkillCommand, SkillImportCommand, SkillRunOutcomeArg, SkillStatusArg,
};
use clap::{CommandFactory, Parser};
use vfs_cli::cli::VfsCommand;
Expand All @@ -990,6 +1066,7 @@ mod tests {
let help = command.render_long_help().to_string();

assert!(help.contains("Manage database creation"));
assert!(help.contains("Read official docs sources"));
assert!(help.contains("Manage Skill Registry packages"));
assert!(help.contains("Read one node by path"));
assert!(help.contains("Search node content"));
Expand Down Expand Up @@ -1209,6 +1286,152 @@ mod tests {
let list = Cli::parse_from(["kinic-vfs-cli", "database", "list"]);
assert!(!list.command.requires_identity());
assert!(list.command.prefers_identity_in_auto());

let docs = Cli::parse_from(["kinic-vfs-cli", "docs", "source", "list", "--json"]);
assert!(!docs.command.requires_identity());
assert!(docs.command.probes_anonymous_database_read());

let docs_cite = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"cite",
"--input",
"pack.json",
"--json",
]);
assert!(!docs_cite.command.requires_identity());
assert!(!docs_cite.command.probes_anonymous_database_read());
}

#[test]
fn main_cli_parses_docs_commands() {
let list = Cli::parse_from(["kinic-vfs-cli", "docs", "source", "list", "--json"]);
let Command::Docs {
command:
DocsCommand::Source {
command: DocsSourceCommand::List { json },
},
} = list.command
else {
panic!("expected docs source list command");
};
assert!(json);

let resolve = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"source",
"resolve",
"next middleware",
"--top-k",
"12",
"--json",
]);
let Command::Docs {
command:
DocsCommand::Source {
command: DocsSourceCommand::Resolve { query, top_k, json },
},
} = resolve.command
else {
panic!("expected docs source resolve command");
};
assert_eq!(query, "next middleware");
assert_eq!(top_k, 12);
assert!(json);

let query = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"source",
"query",
"--source-id",
"/vercel/next.js",
"next middleware",
"--version",
"16",
"--top-k",
"8",
"--max-tokens",
"3000",
"--json",
]);
let Command::Docs {
command:
DocsCommand::Source {
command:
DocsSourceCommand::Query {
query,
source_id,
version,
top_k,
max_tokens,
json,
},
},
} = query.command
else {
panic!("expected docs source query command");
};
assert_eq!(query, "next middleware");
assert_eq!(source_id, "/vercel/next.js");
assert_eq!(version.as_deref(), Some("16"));
assert_eq!(top_k, 8);
assert_eq!(max_tokens, 3000);
assert!(json);

let pack = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"context",
"pack",
"next middleware",
"--top-sources",
"2",
"--top-k-per-source",
"5",
"--max-tokens",
"6000",
"--json",
]);
let Command::Docs {
command:
DocsCommand::Context {
command:
DocsContextCommand::Pack {
query,
top_sources,
top_k_per_source,
max_tokens,
json,
},
},
} = pack.command
else {
panic!("expected docs context pack command");
};
assert_eq!(query, "next middleware");
assert_eq!(top_sources, 2);
assert_eq!(top_k_per_source, 5);
assert_eq!(max_tokens, 6000);
assert!(json);

let cite = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"cite",
"--input",
"pack.json",
"--json",
]);
let Command::Docs {
command: DocsCommand::Cite { input, json },
} = cite.command
else {
panic!("expected docs cite command");
};
assert_eq!(input.to_string_lossy(), "pack.json");
assert!(json);
}

#[test]
Expand Down
88 changes: 87 additions & 1 deletion crates/vfs_cli_app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// What: Command handlers for FS-first remote reads and writes.
// Why: The CLI should keep canister operations explicit and path-oriented.
use crate::claude::run_claude_command;
use crate::cli::{Cli, Command, IdentityCommand};
use crate::cli::{Cli, Command, DocsCommand, IdentityCommand};
use crate::codex::run_codex_command;
use crate::conversation_wiki::generate_conversation_wiki;
use crate::docs_context::{run_docs_cite, run_docs_command};
use crate::github_ingest::run_github_command;
use crate::hermes::run_hermes_command;
use crate::maintenance::{rebuild_index, rebuild_scope_index};
Expand All @@ -15,6 +16,19 @@ use vfs_cli::commands::{database_id_or_env, run_vfs_command};
use vfs_cli::connection::ResolvedConnection;
use vfs_client::VfsApi;

pub fn run_local_docs_command(command: &Command) -> Result<bool> {
let Command::Docs { command } = command else {
return Ok(false);
};
match command {
DocsCommand::Cite { input, json } => {
run_docs_cite(input, *json)?;
Ok(true)
}
_ => Ok(false),
}
}

pub async fn run_command(
client: &impl VfsApi,
cli: Cli,
Expand Down Expand Up @@ -49,6 +63,10 @@ pub async fn run_command(
Command::Skill { command } => {
run_skill_command(client, require_database_id(database_id)?, command).await?;
}
Command::Docs { command } => match command {
DocsCommand::Cite { input, json } => run_docs_cite(&input, json)?,
command => run_docs_command(client, require_database_id(database_id)?, command).await?,
},
Command::Github { command } => {
run_github_command(client, require_database_id(database_id)?, command).await?;
}
Expand Down Expand Up @@ -123,3 +141,71 @@ fn require_database_id(database_id: Option<&str>) -> Result<&str> {
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("database id is required; set --database-id, VFS_DATABASE_ID, or run database link <database-id>"))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::commands_fs_tests::MockClient;
use clap::Parser;
use std::fs;
use vfs_cli::connection::ResolvedConnection;

#[test]
fn local_docs_cite_runs_without_remote_database() {
let temp_dir = tempfile::tempdir().expect("tempdir should create");
let input = temp_dir.path().join("pack.json");
fs::write(
&input,
r#"{"query":"q","max_tokens":1000,"estimated_tokens":1,"sources":[],"evidence":[{"path":"/Wiki/sources/vercel__next_js/16/a.md","score":1.0,"snippet":"s","content":"c","source_id":"/vercel/next.js","title":"Next.js","citation":"https://nextjs.org/docs","version":"16","chunk_id":"a","trust":"official"}],"citations":[],"truncated":false}"#,
)
.expect("pack should write");
let command = Command::Docs {
command: crate::cli::DocsCommand::Cite { input, json: true },
};

assert!(run_local_docs_command(&command).expect("local docs command should run"));
}

#[test]
fn local_docs_command_ignores_remote_docs_commands() {
let command = Command::Docs {
command: crate::cli::DocsCommand::Source {
command: crate::cli::DocsSourceCommand::List { json: true },
},
};

assert!(!run_local_docs_command(&command).expect("remote docs command should not run"));
}

#[tokio::test]
async fn run_command_docs_cite_skips_database_id() {
let temp_dir = tempfile::tempdir().expect("tempdir should create");
let input = temp_dir.path().join("pack.json");
fs::write(
&input,
r#"{"query":"q","max_tokens":1000,"estimated_tokens":1,"sources":[],"evidence":[{"path":"/Wiki/sources/vercel__next_js/16/a.md","score":1.0,"snippet":"s","content":"c","source_id":"/vercel/next.js","title":"Next.js","citation":"https://nextjs.org/docs","version":"16","chunk_id":"a","trust":"official"}],"citations":[],"truncated":false}"#,
)
.expect("pack should write");
let input_arg = input.to_string_lossy().into_owned();
let cli = Cli::parse_from([
"kinic-vfs-cli",
"docs",
"cite",
"--input",
&input_arg,
"--json",
]);
let connection = ResolvedConnection {
replica_host: "http://127.0.0.1:8000".to_string(),
canister_id: "aaaaa-aa".to_string(),
database_id: None,
replica_host_source: "test".to_string(),
canister_id_source: "test".to_string(),
database_id_source: None,
};

run_command(&MockClient::default(), cli, &connection)
.await
.expect("docs cite should not require database id");
}
}
Loading