use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
use gobby_core::config::AiRouting;
use gobby_wiki::{Command, IngestFileOptions, ReadTarget, ScopeSelection, WikiError, output};
use serde_json::json;
#[derive(Debug, Parser)]
#[command(name = "gwiki", version, about = "Gobby wiki CLI")]
struct Cli {
#[command(flatten)]
scope: ScopeArgs,
#[arg(long, global = true, default_value = "json")]
format: output::Format,
#[arg(long, global = true)]
quiet: bool,
#[command(subcommand)]
command: CliCommand,
}
#[derive(Debug, Subcommand)]
enum CliCommand {
Contract,
Init,
Setup(SetupArgs),
Index,
Collect,
IngestFile {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long)]
no_ai: bool,
#[arg(long)]
translate: bool,
#[arg(long, value_name = "LANG")]
target_lang: Option<String>,
#[arg(long = "video-frame-interval", value_name = "SECONDS")]
video_frame_interval_seconds: Option<u32>,
#[arg(long, value_name = "auto|daemon|direct|off")]
transcription_routing: Option<AiRouting>,
#[arg(long, value_name = "auto|daemon|direct|off")]
vision_routing: Option<AiRouting>,
#[arg(long, value_name = "auto|daemon|direct|off")]
text_routing: Option<AiRouting>,
},
IngestUrl {
#[arg(value_name = "URL", num_args = 1..)]
urls: Vec<String>,
},
Refresh(RefreshArgs),
Sources,
RemoveSource(RemoveSourceArgs),
Search(SearchArgs),
Ask(AskArgs),
Read(ReadArgs),
Backlinks(BacklinksArgs),
LinkSuggest(LinkSuggestArgs),
Research(ResearchArgs),
Compile(CompileArgs),
Export(ExportArgs),
Audit,
Lint,
Health,
Status,
}
#[derive(Debug, Args)]
struct ScopeArgs {
#[arg(
long,
global = true,
conflicts_with = "topic",
value_name = "ROOT",
num_args = 0..=1,
default_missing_value = ".",
)]
project: Option<PathBuf>,
#[arg(long, global = true, value_name = "NAME")]
topic: Option<String>,
}
#[derive(Debug, Args)]
struct SetupArgs {
#[arg(long)]
standalone: bool,
#[arg(long = "database-url", value_name = "DSN")]
database_url: Option<String>,
#[arg(long)]
no_services: bool,
#[arg(long, value_name = "HOST")]
falkordb_host: Option<String>,
#[arg(long, value_name = "PORT")]
falkordb_port: Option<u16>,
#[arg(long, value_name = "PASSWORD")]
falkordb_password: Option<String>,
#[arg(long, value_name = "URL")]
qdrant_url: Option<String>,
#[arg(long, value_name = "PROVIDER")]
embedding_provider: Option<String>,
#[arg(long, value_name = "URL")]
embedding_api_base: Option<String>,
#[arg(long, value_name = "MODEL")]
embedding_model: Option<String>,
#[arg(long, value_name = "PREFIX")]
embedding_query_prefix: Option<String>,
#[arg(long, value_name = "DIM")]
embedding_vector_dim: Option<usize>,
#[arg(long, value_name = "KEY")]
embedding_api_key: Option<String>,
}
#[derive(Debug, Args)]
struct SearchArgs {
#[arg(value_name = "QUERY")]
query: String,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long = "no-semantic")]
no_semantic: bool,
}
#[derive(Debug, Args)]
struct AskArgs {
#[arg(value_name = "QUESTION")]
question: String,
#[arg(long)]
llm: bool,
#[arg(long, default_value = "auto", value_name = "auto|daemon|direct|off")]
ai: AiRouting,
#[arg(long = "require-ai")]
require_ai: bool,
}
#[derive(Debug, Args)]
struct RemoveSourceArgs {
#[arg(long, value_name = "SOURCE_ID")]
id: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
#[arg(long)]
keep_asset: bool,
}
#[derive(Debug, Args)]
struct RefreshArgs {
#[arg(long = "id", value_name = "SOURCE_ID")]
id: Vec<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
#[command(group(
ArgGroup::new("target")
.required(true)
.args(["path", "title"])
))]
struct ReadArgs {
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long, value_name = "TITLE")]
title: Option<String>,
}
#[derive(Debug, Args)]
struct BacklinksArgs {
#[arg(value_name = "PAGE")]
page: String,
}
#[derive(Debug, Args)]
struct LinkSuggestArgs {
#[arg(long, default_value = "10")]
limit: usize,
}
#[derive(Debug, Args)]
struct ResearchArgs {
#[arg(value_name = "QUESTION")]
question: Option<String>,
#[arg(long = "source-constraint", value_name = "TEXT")]
source_constraints: Vec<String>,
#[arg(long)]
audit: bool,
#[arg(long = "max-steps", default_value_t = 12, value_name = "N")]
max_steps: usize,
#[arg(long = "max-tokens", default_value_t = 24_000, value_name = "N")]
max_tokens: usize,
#[arg(long = "max-sources", default_value_t = 8, value_name = "N")]
max_sources: usize,
#[arg(long, default_value = "auto", value_name = "auto|daemon|direct|off")]
ai: AiRouting,
#[arg(long = "require-ai")]
require_ai: bool,
}
#[derive(Debug, Args)]
struct CompileArgs {
#[arg(value_name = "TOPIC")]
topic: Option<String>,
#[arg(long = "outline", value_name = "HEADING")]
outline: Vec<String>,
#[arg(long, value_enum, default_value = "topic")]
kind: CompileKind,
#[arg(long, value_name = "PAGE")]
target: Option<PathBuf>,
#[arg(long = "write-intent")]
write_intent: bool,
}
#[derive(Debug, Args)]
struct ExportArgs {
#[command(subcommand)]
command: ExportSubcommand,
}
#[derive(Debug, Subcommand)]
enum ExportSubcommand {
WorkflowAssets {
#[arg(long, default_value = "workflow-assets.md", value_name = "FILE")]
output: String,
},
Report {
#[arg(long, value_name = "FILE")]
output: String,
#[arg(long = "from", value_name = "PATH")]
source: PathBuf,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CompileKind {
Source,
Concept,
Topic,
}
fn main() -> ExitCode {
let Cli {
scope,
format,
quiet,
command,
} = Cli::parse_from(normalize_project_flag_args(std::env::args_os()));
if matches!(&command, CliCommand::Contract) {
let mut stdout = std::io::stdout().lock();
let result = match format {
output::Format::Json => {
output::print_json(&mut stdout, &gobby_wiki::contract::contract())
}
output::Format::Text => output::print_text(&mut stdout, "gwiki CLI contract v1"),
};
if let Err(error) = result {
eprintln!("gwiki: {error}");
return ExitCode::from(1);
}
return ExitCode::SUCCESS;
}
let command = match command_from_cli(command, scope.into()) {
Ok(command) => command,
Err(error) => {
print_error(format, &error);
return exit_code_for_error(&error);
}
};
match gobby_wiki::run(command) {
Ok(outcome) => {
if !quiet {
for message in &outcome.status_messages {
output::print_status(message);
}
}
let stdout = std::io::stdout().lock();
if let Err(error) = output::print_result(stdout, format, &outcome.result) {
eprintln!("gwiki: {error}");
return ExitCode::from(1);
}
ExitCode::from(outcome.exit_code)
}
Err(error) => {
print_error(format, &error);
exit_code_for_error(&error)
}
}
}
fn normalize_project_flag_args<I, S>(args: I) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
let mut normalized = Vec::with_capacity(args.len() + 1);
for (index, arg) in args.iter().enumerate() {
normalized.push(arg.clone());
if arg == OsStr::new("--project")
&& args
.get(index + 1)
.and_then(|next| next.to_str())
.is_some_and(is_cli_subcommand)
{
normalized.push(OsString::from("."));
}
}
normalized
}
fn is_cli_subcommand(value: &str) -> bool {
matches!(
value,
"init"
| "contract"
| "setup"
| "index"
| "collect"
| "ingest-file"
| "ingest-url"
| "refresh"
| "sources"
| "remove-source"
| "search"
| "ask"
| "read"
| "backlinks"
| "link-suggest"
| "research"
| "compile"
| "export"
| "audit"
| "lint"
| "health"
| "status"
)
}
fn print_error(format: output::Format, error: &WikiError) {
match format {
output::Format::Json => {
let payload = json!({
"code": error.code(),
"message": error.to_string(),
});
let mut stderr = std::io::stderr().lock();
if output::print_json(&mut stderr, &payload).is_err() {
eprintln!("gwiki: {error}");
}
}
output::Format::Text => eprintln!("gwiki: {error}"),
}
}
fn command_from_cli(command: CliCommand, scope: ScopeSelection) -> Result<Command, WikiError> {
match command {
CliCommand::Contract => unreachable!("contract command is handled before runtime dispatch"),
CliCommand::Init => Ok(Command::Init { scope }),
CliCommand::Setup(args) => Ok(Command::Setup {
scope,
options: args.into(),
}),
CliCommand::Index => Ok(Command::Index { scope }),
CliCommand::Collect => Ok(Command::Collect { scope }),
CliCommand::IngestFile {
path,
no_ai,
translate,
target_lang,
video_frame_interval_seconds,
transcription_routing,
vision_routing,
text_routing,
} => Ok(Command::IngestFile {
path,
scope,
options: IngestFileOptions {
no_ai,
translate,
target_lang,
video_frame_interval_seconds,
transcription_routing,
vision_routing,
text_routing,
},
}),
CliCommand::IngestUrl { urls } => Ok(Command::IngestUrl { urls, scope }),
CliCommand::Refresh(args) => Ok(Command::Refresh {
scope,
source_ids: args.id,
dry_run: args.dry_run,
}),
CliCommand::Sources => Ok(Command::Sources { scope }),
CliCommand::RemoveSource(args) => {
if args.dry_run && args.yes {
return Err(WikiError::InvalidInput {
field: "remove-source",
message: "pass only one of --dry-run or --yes".to_string(),
});
}
if !args.dry_run && !args.yes {
return Err(WikiError::InvalidInput {
field: "remove-source",
message: "destructive source removal requires --yes; use --dry-run to preview"
.to_string(),
});
}
Ok(Command::RemoveSource {
id: args.id,
scope,
dry_run: args.dry_run,
keep_asset: args.keep_asset,
})
}
CliCommand::Search(args) => Ok(Command::Search {
query: args.query,
scope,
limit: args.limit,
include_semantic: !args.no_semantic,
}),
CliCommand::Ask(args) => Ok(Command::Ask {
query: args.question,
scope,
llm: args.llm,
ai: args.ai,
require_ai: args.require_ai,
}),
CliCommand::Read(args) => {
let target = match (args.path, args.title) {
(Some(path), None) => ReadTarget::Path(path),
(None, Some(title)) => ReadTarget::Title(title),
_ => {
return Err(WikiError::InvalidInput {
field: "read",
message: "pass exactly one of --path or --title".to_string(),
});
}
};
Ok(Command::Read { target, scope })
}
CliCommand::Backlinks(args) => Ok(Command::Backlinks {
page: args.page,
scope,
}),
CliCommand::LinkSuggest(args) => Ok(Command::LinkSuggest {
scope,
limit: args.limit,
}),
CliCommand::Research(args) => {
let question = match (args.audit, args.question) {
(_, Some(question)) => question,
(true, None) => "Audit wiki scope".to_string(),
(false, None) => {
return Err(WikiError::InvalidInput {
field: "research",
message: "QUESTION is required unless --audit is set".to_string(),
});
}
};
let research_scope = gobby_wiki::resolve_research_scope(&scope)?;
Ok(Command::Research(gobby_wiki::research::ResearchOptions {
question,
scope: research_scope,
source_constraints: args.source_constraints,
audit: args.audit,
max_steps: args.max_steps,
max_tokens: args.max_tokens,
max_sources: args.max_sources,
ai: args.ai,
require_ai: args.require_ai,
accepted_notes: Vec::new(),
}))
}
CliCommand::Compile(args) => Ok(Command::Compile {
topic: args.topic,
outline: args.outline,
target_kind: args.kind.into(),
target_page: args.target,
write_intent: args.write_intent,
scope,
}),
CliCommand::Export(args) => Ok(Command::Export {
scope,
command: args.into(),
}),
CliCommand::Audit => Ok(Command::Audit { scope }),
CliCommand::Lint => Ok(Command::Lint { scope }),
CliCommand::Health => Ok(Command::Health { scope }),
CliCommand::Status => Ok(Command::Status { scope }),
}
}
impl From<CompileKind> for gobby_wiki::synthesis::ArticleKind {
fn from(kind: CompileKind) -> Self {
match kind {
CompileKind::Source => Self::Source,
CompileKind::Concept => Self::Concept,
CompileKind::Topic => Self::Topic,
}
}
}
impl From<ExportArgs> for gobby_wiki::exports::ExportCommand {
fn from(args: ExportArgs) -> Self {
match args.command {
ExportSubcommand::WorkflowAssets { output } => {
Self::WorkflowAssets { filename: output }
}
ExportSubcommand::Report { output, source } => Self::ReportFile {
filename: output,
source_path: source,
},
}
}
}
impl From<ScopeArgs> for ScopeSelection {
fn from(scope: ScopeArgs) -> Self {
if let Some(topic) = scope.topic {
Self::topic(topic)
} else if let Some(project_root) = scope.project {
Self::project(project_root)
} else {
Self::detect()
}
}
}
fn exit_code_for_error(error: &WikiError) -> ExitCode {
match error {
WikiError::NotImplemented { .. }
| WikiError::InvalidInput { .. }
| WikiError::Index { .. }
| WikiError::Search { .. }
| WikiError::InvalidScope { .. }
| WikiError::NotFound { .. } => ExitCode::from(2),
WikiError::Config { .. }
| WikiError::Io { .. }
| WikiError::Json { .. }
| WikiError::Yaml { .. }
| WikiError::Registry { .. }
| WikiError::Daemon { .. }
| WikiError::Setup { .. } => ExitCode::from(1),
}
}
impl From<SetupArgs> for gobby_wiki::SetupOptions {
fn from(args: SetupArgs) -> Self {
Self {
standalone: args.standalone,
database_url: args.database_url,
no_services: args.no_services,
falkordb_host: args.falkordb_host,
falkordb_port: args.falkordb_port,
falkordb_password: args.falkordb_password,
qdrant_url: args.qdrant_url,
embedding_provider: args.embedding_provider,
embedding_api_base: args.embedding_api_base,
embedding_model: args.embedding_model,
embedding_query_prefix: args.embedding_query_prefix,
embedding_vector_dim: args.embedding_vector_dim,
embedding_api_key: args.embedding_api_key,
}
}
}
#[cfg(test)]
mod tests {
use gobby_core::ai_context::AiContext;
use gobby_core::config::{AiRouting, EnvOnlySource};
use super::*;
fn cli_subcommands() -> &'static [&'static str] {
&[
"init",
"contract",
"setup",
"index",
"collect",
"ingest-file",
"ingest-url",
"refresh",
"sources",
"remove-source",
"search",
"ask",
"read",
"backlinks",
"link-suggest",
"research",
"compile",
"export",
"audit",
"lint",
"health",
"status",
]
}
#[test]
fn project_flag_normalization_handles_every_subcommand() {
for subcommand in cli_subcommands() {
let normalized = normalize_project_flag_args(["gwiki", "--project", subcommand]);
assert_eq!(
normalized,
vec![
OsString::from("gwiki"),
OsString::from("--project"),
OsString::from("."),
OsString::from(subcommand),
],
"bare --project should receive cwd before {subcommand}"
);
}
}
#[test]
fn attached_project_flag_preserves_every_subcommand() {
for subcommand in cli_subcommands() {
let normalized =
normalize_project_flag_args(["gwiki", "--project=/tmp/wiki-project", subcommand]);
assert_eq!(
normalized,
vec![
OsString::from("gwiki"),
OsString::from("--project=/tmp/wiki-project"),
OsString::from(subcommand),
],
"attached --project value should stay attached before {subcommand}"
);
}
}
#[test]
fn ingest_file_cli_flags_map_to_command_options() {
let command = command_from_cli(
CliCommand::IngestFile {
path: PathBuf::from("media/interview.mp3"),
no_ai: false,
translate: true,
target_lang: Some("es".to_string()),
video_frame_interval_seconds: Some(0),
transcription_routing: Some(AiRouting::Direct),
vision_routing: Some(AiRouting::Off),
text_routing: Some(AiRouting::Daemon),
},
ScopeSelection::detect(),
)
.expect("map ingest-file command");
let Command::IngestFile { options, .. } = command else {
panic!("expected ingest-file command");
};
assert!(options.translate);
assert_eq!(options.target_lang.as_deref(), Some("es"));
assert_eq!(options.video_frame_interval_seconds, Some(0));
let mut source = EnvOnlySource;
let mut context = AiContext::resolve(None, &mut source);
let original_transcribe_route = context.bindings.audio_transcribe.routing;
options.apply_to_ai_context(&mut context);
assert_eq!(
context.bindings.audio_transcribe.routing,
original_transcribe_route
);
assert_eq!(context.bindings.audio_translate.routing, AiRouting::Direct);
assert_eq!(context.bindings.vision_extract.routing, AiRouting::Off);
assert_eq!(context.bindings.text_generate.routing, AiRouting::Daemon);
assert_eq!(
context.bindings.audio_translate.target_lang.as_deref(),
Some("es")
);
}
#[test]
fn ask_cli_flags_map_to_command_options() {
let command = command_from_cli(
CliCommand::Ask(AskArgs {
question: "How do hooks work?".to_string(),
llm: true,
ai: AiRouting::Direct,
require_ai: true,
}),
ScopeSelection::topic("docs"),
)
.expect("map ask command");
let Command::Ask {
query,
scope,
llm,
ai,
require_ai,
} = command
else {
panic!("expected ask command");
};
assert_eq!(query, "How do hooks work?");
assert_eq!(scope, ScopeSelection::topic("docs"));
assert!(llm);
assert_eq!(ai, AiRouting::Direct);
assert!(require_ai);
}
#[test]
fn ingest_url_cli_accepts_multiple_urls() {
let cli = Cli::try_parse_from([
"gwiki",
"ingest-url",
"--topic",
"rust",
"https://example.test/one",
"https://example.test/two",
])
.expect("parse ingest-url command");
assert_eq!(cli.scope.topic.as_deref(), Some("rust"));
let CliCommand::IngestUrl { urls } = cli.command else {
panic!("expected parsed ingest-url command");
};
assert_eq!(
urls,
vec![
"https://example.test/one".to_string(),
"https://example.test/two".to_string()
]
);
let command = command_from_cli(
CliCommand::IngestUrl {
urls: vec![
"https://example.test/one".to_string(),
"https://example.test/two".to_string(),
],
},
ScopeSelection::topic("rust"),
)
.expect("map ingest-url command");
let Command::IngestUrl { urls, scope } = command else {
panic!("expected ingest-url command");
};
assert_eq!(
urls,
vec![
"https://example.test/one".to_string(),
"https://example.test/two".to_string()
]
);
assert_eq!(scope.topic_name(), Some("rust"));
}
#[test]
fn refresh_cli_flags_map_to_command_options() {
let cli = Cli::try_parse_from([
"gwiki",
"--format",
"json",
"refresh",
"--id",
"src1",
"--id",
"src2",
"--dry-run",
"--topic",
"docs",
])
.expect("parse refresh command");
assert_eq!(cli.scope.topic.as_deref(), Some("docs"));
let CliCommand::Refresh(args) = cli.command else {
panic!("expected parsed refresh command");
};
assert_eq!(args.id, vec!["src1".to_string(), "src2".to_string()]);
assert!(args.dry_run);
let command = command_from_cli(
CliCommand::Refresh(RefreshArgs {
id: vec!["src1".to_string(), "src2".to_string()],
dry_run: true,
}),
ScopeSelection::topic("docs"),
)
.expect("map refresh command");
let Command::Refresh {
scope,
source_ids,
dry_run,
} = command
else {
panic!("expected refresh command");
};
assert_eq!(scope.topic_name(), Some("docs"));
assert_eq!(source_ids, vec!["src1".to_string(), "src2".to_string()]);
assert!(dry_run);
assert!(
Cli::try_parse_from(["gwiki", "refresh", "--scope", "project"]).is_err(),
"refresh must use existing --project/--topic globals, not --scope"
);
let bare_project =
Cli::try_parse_from(["gwiki", "refresh", "--project"]).expect("parse bare project");
assert_eq!(bare_project.scope.project, Some(PathBuf::from(".")));
let rooted_project = Cli::try_parse_from(["gwiki", "refresh", "--project", "/repo"])
.expect("parse explicit project root");
assert_eq!(rooted_project.scope.project, Some(PathBuf::from("/repo")));
}
#[test]
fn setup_cli_flags_map_to_command_options() {
let command = command_from_cli(
CliCommand::Setup(SetupArgs {
standalone: true,
database_url: Some("postgresql://localhost/gwiki".to_string()),
no_services: true,
falkordb_host: Some("127.0.0.2".to_string()),
falkordb_port: Some(26379),
falkordb_password: Some("secret".to_string()),
qdrant_url: Some("http://localhost:7333".to_string()),
embedding_provider: Some("openai-compatible".to_string()),
embedding_api_base: Some("http://localhost:1234/v1".to_string()),
embedding_model: Some("embed-small".to_string()),
embedding_query_prefix: Some("query: ".to_string()),
embedding_vector_dim: Some(1024),
embedding_api_key: Some("api-key".to_string()),
}),
ScopeSelection::detect(),
)
.expect("map setup command");
let Command::Setup { options, .. } = command else {
panic!("expected setup command");
};
assert!(options.standalone);
assert_eq!(
options.database_url.as_deref(),
Some("postgresql://localhost/gwiki")
);
assert!(options.no_services);
assert_eq!(options.falkordb_host.as_deref(), Some("127.0.0.2"));
assert_eq!(options.falkordb_port, Some(26379));
assert_eq!(options.qdrant_url.as_deref(), Some("http://localhost:7333"));
assert_eq!(options.embedding_vector_dim, Some(1024));
}
}