1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use anyhow::{anyhow, Context, Result};
5use clap::{ArgAction, Parser};
6
7use crate::{
8 builtins, default_db_path, format_help, load_spec, match_commands, resolve_git_branch,
9 resolve_preferred_spec, run_matched, RootSpec, RunContext,
10};
11
12#[derive(Parser, Debug)]
13#[command(name = "jan")]
14#[command(
15 about = "YAML-driven command tree loaded from the preferred directory (`jan use`)",
16 version,
17 disable_help_flag = true
18)]
19pub struct JanCli {
20 #[arg(long, short = 'h', action = ArgAction::SetTrue, global = true)]
22 pub help: bool,
23
24 #[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
26 pub db: Option<PathBuf>,
27
28 #[arg(long, global = true)]
30 pub branch: Option<String>,
31
32 #[arg(long, global = true)]
34 pub no_log: bool,
35
36 #[arg(long, global = true, default_value = ".")]
38 pub cwd: PathBuf,
39
40 #[arg(short, long, global = true)]
42 pub verbose: bool,
43
44 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
46 pub command: Vec<OsString>,
47}
48
49fn framework_builtins_help() -> String {
50 "\
51Built-in commands (part of the jan binary):
52 use — set, show, or clear the preferred jan directory
53 lab — isolated jan + cron environment for trying a command tree
54 computer — register, show, or clear the host computer id for `computer:` filtering
55 bundle — pack the preferred YAML tree into a ZIP
56 alias — emit shell aliases for executable leaves
57 config — emit / link / unlink / apply / deps host configuration from the preferred tree
58 list — list script leaves in the preferred tree
59 search — find scripts by name/about/category
60 show — print details for a script leaf
61 validate — structural checks for the preferred tree
62 audit — query the SQLite invocation log
63 cron — start/stop daemon; --list reads the schedule cache (100ms ticks)
64 systems — list/show/status for named agent systems (`system:` YAML)
65 runtime — warm language interpreter pool (python/node/shell/kotlin)
66 packages — inspect / prune cached package environments (uv, pnpm, gradle)
67 test — run Given/When/Then shell tests for a command path (and nested)
68 ps — show jan processes and collective CPU usage
69
70Global options:
71 -h, --help Show this help (lists preferred-tree commands when a dir is set)
72 -V, --version Print version
73 -v, --verbose Explain how the preferred directory was resolved
74 --cwd <DIR> Working directory for subprocesses (default: .)
75 --db <FILE> SQLite audit log path
76 --branch <B> Override git branch recorded in the audit log
77 --no-log Do not write to the audit log
78
79Configure a command tree with `jan use <DIR>`, then re-run `jan --help`.
80To try agents without touching your daily setup: `jan lab <DIR> --help`.
81"
82 .to_string()
83}
84
85fn print_root_help(spec: Option<&RootSpec>) {
86 match spec {
87 Some(spec) => {
88 print!("{}", format_help(spec, &[], None));
89 }
90 None => {
91 println!("jan — YAML-driven command tree\n");
92 println!("No preferred jan directory is configured yet.\n");
93 println!(" jan use <DIR> Save a directory containing scripts.spec.yaml");
94 println!(" jan use --show Show the saved preference\n");
95 }
96 }
97 print!("{}", framework_builtins_help());
98}
99
100pub fn run_jan() -> Result<i32> {
101 let cli = JanCli::parse();
102 let cwd = cli.cwd.canonicalize().unwrap_or_else(|_| cli.cwd.clone());
103
104 if let Some(first) = cli.command.first() {
106 let key = first.to_string_lossy();
107 if builtins::is_pre_spec_builtin(key.as_ref()) {
108 let tail: Vec<_> = cli.command[1..].to_vec();
109 return match key.as_ref() {
110 "use" => builtins::run_use(&tail),
111 "lab" => crate::lab::run_lab(&tail),
112 "computer" => builtins::run_computer(&tail),
113 "ps" => crate::ps::run_ps(&tail),
114 "runtime" => crate::runtime_daemon::dispatch_runtime(&tail),
115 _ => Ok(0),
116 };
117 }
118 }
119
120 let loaded = match resolve_preferred_spec() {
121 Ok(pair) => Some(pair),
122 Err(e) => {
123 if cli.help || cli.command.is_empty() {
124 print_root_help(None);
125 if cli.verbose {
126 eprintln!("jan: no preferred spec ({e:#})");
127 }
128 return Ok(0);
129 }
130 return Err(e);
131 }
132 };
133
134 let (spec_path, spec_identity) = loaded.expect("Ok branch always sets Some");
135 let spec = load_spec(&spec_path).with_context(|| format!("load {}", spec_path.display()))?;
136
137 if cli.verbose {
138 eprintln!("jan: cwd={}", cwd.display());
139 eprintln!(
140 "jan: preferred directory: {} / {}",
141 spec_identity.spec_dir, spec_identity.root_yaml
142 );
143 }
144
145 let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
146 let is_test = cli
147 .command
148 .first()
149 .is_some_and(|s| s.to_string_lossy() == "test");
150 let no_log = cli.no_log || is_test || std::env::var_os("JAN_NO_LOG").is_some();
151 let audit_db = cli.db.as_ref().cloned().unwrap_or_else(default_db_path);
152 let db_path = if no_log { None } else { Some(audit_db.clone()) };
153
154 let ctx = RunContext {
155 cwd: &cwd,
156 db_path: db_path.as_deref(),
157 branch,
158 no_log,
159 spec_root: &spec_identity,
160 };
161
162 if cli.command.is_empty() {
163 print_root_help(Some(&spec));
164 return Ok(0);
165 }
166
167 if let Some(first) = cli.command.first() {
168 let key = first.to_string_lossy();
169 if builtins::is_builtin_reserved(key.as_ref()) {
170 let tail: Vec<_> = cli.command[1..].to_vec();
171 return match key.as_ref() {
172 "bundle" => builtins::bundle_spec_zip(&spec_identity, &tail, cli.verbose),
173 "alias" => builtins::emit_shell_aliases(&spec, &tail),
174 "config" => {
175 let root = PathBuf::from(&spec_identity.spec_dir);
176 crate::hostconfig::dispatch_config(&spec, &root, &tail)
177 }
178 "use" => builtins::run_use(&tail),
179 "computer" => builtins::run_computer(&tail),
180 "list" | "search" | "show" | "validate" => {
181 let root = PathBuf::from(&spec_identity.spec_dir);
182 crate::inspect::dispatch_inspect(key.as_ref(), &tail, &spec, None, Some(&root))
183 }
184 "audit" => crate::inspect::dispatch_inspect(
185 key.as_ref(),
186 &tail,
187 &spec,
188 Some(&audit_db),
189 None,
190 ),
191 "cron" => crate::inspect::run_cron(&spec, &tail, &ctx),
192 "systems" => crate::systems::dispatch_systems(&tail, &spec),
193 "runtime" => crate::runtime_daemon::dispatch_runtime(&tail),
194 "packages" => {
195 let root = PathBuf::from(&spec_identity.spec_dir);
196 crate::packages::dispatch_packages(&tail, &spec, &root)
197 }
198 "test" => crate::cmdtest::dispatch_test(&tail, &spec, &ctx),
199 "ps" => crate::ps::run_ps(&tail),
200 _ => Ok(0),
201 };
202 }
203 }
204
205 let m = match_commands(&spec, &cli.command);
206
207 if cli.help || m.wants_help {
208 let help = format_help(&spec, &m.chain, m.node);
209 print!("{help}");
210 return Ok(0);
211 }
212
213 let passthrough_leaf = m
216 .node
217 .and_then(|n| n.exec.as_ref())
218 .is_some_and(|e| e.passthrough);
219 if !passthrough_leaf && m.trailing.iter().any(|a| a == "--help" || a == "-h") {
220 return Err(anyhow!(
221 "place --help immediately after the subcommand prefix you want help for"
222 ));
223 }
224
225 let node = match m.node {
226 Some(n) => n,
227 None => {
228 let key = cli.command[0].to_string_lossy();
229 return Err(anyhow!("unknown top-level command `{key}`"));
230 }
231 };
232
233 if !m.trailing.is_empty() && !node.is_leaf_exec() {
234 let t = m.trailing[0].to_string_lossy();
235 return Err(anyhow!("unknown subcommand `{t}`"));
236 }
237
238 if node.is_leaf_exec() {
239 return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
240 }
241
242 let help = format_help(&spec, &m.chain, Some(node));
243 print!("{help}");
244 Ok(0)
245}