Skip to main content

jan_cli/
lib.rs

1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7mod packages;
8pub mod remote;
9mod runner;
10mod spec_load;
11mod yaml_closure;
12
13pub use config::{load_user_config, UserConfig};
14pub use runner::run_jan;
15pub use spec_load::HostPlatform;
16
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use rusqlite::Connection;
24use serde::Deserialize;
25use serde::de::{self, Deserializer, Visitor};
26use std::fmt;
27
28#[derive(Debug, Deserialize)]
29pub struct RootSpec {
30    pub metadata: Option<Metadata>,
31    #[serde(default)]
32    pub commands: BTreeMap<String, CommandNode>,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct Metadata {
37    pub name: Option<String>,
38    pub description: Option<String>,
39}
40
41/// Child-process environment declaration for a command node.
42///
43/// Two YAML shapes are accepted:
44///
45/// ```yaml
46/// # Legacy / shorthand — all keys are public assignments
47/// env:
48///   FOO: bar
49///
50/// # Explicit sections
51/// env:
52///   public:
53///     FOO: bar
54///   private:
55///     - GH_TOKEN
56///   pass:
57///     GH_TOKEN: github/pat
58/// ```
59///
60/// `public` values are taken from the YAML. `private` names must already exist in
61/// jan's own environment; their values are copied into the child and never stored
62/// in the spec. `pass` maps an environment variable name to a `pass` store id;
63/// jan runs `pass <id>` and sets only the first line of stdout as that variable
64/// in the child. When any section is non-empty, the child runs with a cleared
65/// environment containing only those variables plus a small essential allowlist
66/// (PATH, HOME, …).
67#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct EnvSpec {
69    pub public: BTreeMap<String, String>,
70    pub private: Vec<String>,
71    /// Env var name → `pass` store id (e.g. `GH_TOKEN` → `github/pat`).
72    pub pass: BTreeMap<String, String>,
73}
74
75impl EnvSpec {
76    pub fn is_empty(&self) -> bool {
77        self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
78    }
79
80    /// True when the child should not inherit the full parent environment.
81    pub fn restricts_child_env(&self) -> bool {
82        !self.is_empty()
83    }
84
85    pub fn merge_from(&mut self, other: EnvSpec) {
86        for (k, v) in other.public {
87            self.public.insert(k, v);
88        }
89        for name in other.private {
90            if !self.private.iter().any(|p| p == &name) {
91                self.private.push(name);
92            }
93        }
94        for (k, v) in other.pass {
95            self.pass.insert(k, v);
96        }
97    }
98
99    /// Reject overlapping private/pass names and empty keys/ids.
100    pub fn validate(&self, path: &str) -> Result<()> {
101        for name in &self.private {
102            if name.trim().is_empty() {
103                bail!("command '{path}': env.private entry must not be empty");
104            }
105        }
106        for (env_name, pass_id) in &self.pass {
107            if env_name.trim().is_empty() {
108                bail!("command '{path}': env.pass key must not be empty");
109            }
110            if pass_id.trim().is_empty() {
111                bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
112            }
113            if self.private.iter().any(|p| p == env_name) {
114                bail!(
115                    "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
116                );
117            }
118        }
119        Ok(())
120    }
121}
122
123impl<'de> Deserialize<'de> for EnvSpec {
124    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125    where
126        D: Deserializer<'de>,
127    {
128        #[derive(Deserialize)]
129        struct Structured {
130            #[serde(default)]
131            public: BTreeMap<String, String>,
132            #[serde(default, deserialize_with = "deserialize_string_or_seq")]
133            private: Vec<String>,
134            #[serde(default)]
135            pass: BTreeMap<String, String>,
136        }
137
138        #[derive(Deserialize)]
139        #[serde(untagged)]
140        enum EnvDe {
141            Flat(BTreeMap<String, String>),
142            Sections(Structured),
143        }
144
145        Ok(match EnvDe::deserialize(deserializer)? {
146            EnvDe::Flat(public) => Self {
147                public,
148                private: Vec::new(),
149                pass: BTreeMap::new(),
150            },
151            EnvDe::Sections(s) => Self {
152                public: s.public,
153                private: s.private,
154                pass: s.pass,
155            },
156        })
157    }
158}
159
160/// Whether an include link pointed at YAML (subtree graft) or a script file (exec leaf).
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum IncludeLinkKind {
163    Yaml,
164    Script,
165}
166
167/// Retained include identity after load (not authored directly in YAML).
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct IncludeLink {
170    pub kind: IncludeLinkKind,
171    /// Relative path under the jan use root (local includes).
172    pub path: Option<String>,
173    /// Remote URL when the include was fetched over HTTPS.
174    pub url: Option<String>,
175    /// Declared SHA256 when present (required for remote; optional for local).
176    pub sha256: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Default, Clone)]
180pub struct CommandNode {
181    /// If non-empty, this command and its subtree are only offered on these
182    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
183    #[serde(default)]
184    pub os: Vec<String>,
185    #[serde(default)]
186    pub about: String,
187    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
188    pub path: Option<String>,
189    /// Other script names whose `path` directories are prepended before this one runs.
190    #[serde(default)]
191    pub dependencies: Vec<String>,
192    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
193    #[serde(default)]
194    pub requires: Vec<String>,
195    /// Public assignments and/or private names required from the host environment.
196    #[serde(default)]
197    pub env: EnvSpec,
198    /// Named CLI inputs (`--name value`) available as `${{ inputs.name }}` in env/argv.
199    #[serde(default)]
200    pub inputs: BTreeMap<String, crate::inputs::InputDef>,
201    /// Optional crontab schedule(s). When set, `jan cron` runs this script's `run`
202    /// leaf whenever the local time matches any expression.
203    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
204    pub cron: Vec<String>,
205    /// Package-manager dependencies (uv now; pnpm reserved).
206    #[serde(default)]
207    pub packages: PackagesSpec,
208    #[serde(default)]
209    pub commands: BTreeMap<String, CommandNode>,
210    pub exec: Option<ExecSpec>,
211    /// Include link this node was loaded from, if any (filled by the loader).
212    #[serde(skip)]
213    pub source: Option<IncludeLink>,
214}
215
216/// Package-manager deps for a command node (`packages:` in YAML).
217///
218/// Distinct from `dependencies:`, which names other jan scripts whose `path`
219/// directories are prepended to PATH.
220#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
221pub struct PackagesSpec {
222    #[serde(default)]
223    pub uv: Option<UvPackages>,
224    /// Reserved for a future pnpm backend. Presence is rejected at validate/run.
225    #[serde(default)]
226    pub pnpm: Option<serde_yaml::Value>,
227}
228
229impl PackagesSpec {
230    pub fn is_empty(&self) -> bool {
231        self.uv.is_none() && self.pnpm.is_none()
232    }
233
234    /// Deeper node wins per manager (no list merge).
235    pub fn merge_from(&mut self, other: PackagesSpec) {
236        if other.uv.is_some() {
237            self.uv = other.uv;
238        }
239        if other.pnpm.is_some() {
240            self.pnpm = other.pnpm;
241        }
242    }
243
244    pub fn validate(&self, path: &str) -> Result<()> {
245        if self.pnpm.is_some() {
246            bail!("command '{path}': packages.pnpm is not implemented yet");
247        }
248        if let Some(uv) = &self.uv {
249            uv.validate(path)?;
250        }
251        Ok(())
252    }
253}
254
255/// uv dependency declaration: inline list, project dir, or requirements file.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum UvPackages {
258    List(Vec<String>),
259    Project(String),
260    Requirements(String),
261}
262
263impl UvPackages {
264    pub fn validate(&self, path: &str) -> Result<()> {
265        match self {
266            Self::List(pkgs) => {
267                if pkgs.is_empty() {
268                    bail!("command '{path}': packages.uv list must not be empty");
269                }
270                for p in pkgs {
271                    if p.trim().is_empty() {
272                        bail!("command '{path}': packages.uv entry must not be empty");
273                    }
274                }
275            }
276            Self::Project(p) | Self::Requirements(p) => {
277                if p.trim().is_empty() {
278                    bail!("command '{path}': packages.uv path must not be empty");
279                }
280            }
281        }
282        Ok(())
283    }
284}
285
286impl<'de> Deserialize<'de> for UvPackages {
287    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288    where
289        D: Deserializer<'de>,
290    {
291        #[derive(Deserialize)]
292        #[serde(deny_unknown_fields)]
293        struct MapForm {
294            #[serde(default)]
295            project: Option<String>,
296            #[serde(default)]
297            requirements: Option<String>,
298        }
299
300        #[derive(Deserialize)]
301        #[serde(untagged)]
302        enum Helper {
303            List(Vec<String>),
304            Map(MapForm),
305        }
306
307        match Helper::deserialize(deserializer)? {
308            Helper::List(pkgs) => {
309                let pkgs: Vec<String> = pkgs
310                    .into_iter()
311                    .map(|s| s.trim().to_string())
312                    .filter(|s| !s.is_empty())
313                    .collect();
314                Ok(UvPackages::List(pkgs))
315            }
316            Helper::Map(m) => {
317                let project = m
318                    .project
319                    .map(|s| s.trim().to_string())
320                    .filter(|s| !s.is_empty());
321                let requirements = m
322                    .requirements
323                    .map(|s| s.trim().to_string())
324                    .filter(|s| !s.is_empty());
325                match (project, requirements) {
326                    (Some(p), None) => Ok(UvPackages::Project(p)),
327                    (None, Some(r)) => Ok(UvPackages::Requirements(r)),
328                    (None, None) => Err(de::Error::custom(
329                        "packages.uv map must set exactly one of `project` or `requirements`",
330                    )),
331                    (Some(_), Some(_)) => Err(de::Error::custom(
332                        "packages.uv map must set exactly one of `project` or `requirements`, not both",
333                    )),
334                }
335            }
336        }
337    }
338}
339
340pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
341where
342    D: Deserializer<'de>,
343{
344    struct StringOrSeq;
345
346    impl<'de> Visitor<'de> for StringOrSeq {
347        type Value = Vec<String>;
348
349        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
350            formatter.write_str("a string or a sequence of strings")
351        }
352
353        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
354        where
355            E: de::Error,
356        {
357            if value.trim().is_empty() {
358                Ok(Vec::new())
359            } else {
360                Ok(vec![value.to_string()])
361            }
362        }
363
364        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
365        where
366            E: de::Error,
367        {
368            self.visit_str(&value)
369        }
370
371        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
372        where
373            A: de::SeqAccess<'de>,
374        {
375            let mut out = Vec::new();
376            while let Some(s) = seq.next_element::<String>()? {
377                if !s.trim().is_empty() {
378                    out.push(s);
379                }
380            }
381            Ok(out)
382        }
383
384        fn visit_none<E>(self) -> Result<Self::Value, E>
385        where
386            E: de::Error,
387        {
388            Ok(Vec::new())
389        }
390
391        fn visit_unit<E>(self) -> Result<Self::Value, E>
392        where
393            E: de::Error,
394        {
395            Ok(Vec::new())
396        }
397    }
398
399    deserializer.deserialize_any(StringOrSeq)
400}
401
402/// Local include under the preferred jan directory (YAML subtree or script file).
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct LocalInclude {
405    pub path: String,
406    /// Optional integrity pin; verified when present.
407    pub sha256: Option<String>,
408    /// Interpreter prefix for script includes only (e.g. `["bash"]`).
409    pub argv: Vec<String>,
410    /// Passthrough trailing CLI args for script includes only.
411    pub passthrough: bool,
412}
413
414impl LocalInclude {
415    pub fn from_path(path: impl Into<String>) -> Self {
416        Self {
417            path: path.into(),
418            sha256: None,
419            argv: Vec::new(),
420            passthrough: false,
421        }
422    }
423
424    pub fn is_yaml(&self) -> bool {
425        let lower = self.path.to_ascii_lowercase();
426        lower.ends_with(".yaml") || lower.ends_with(".yml")
427    }
428}
429
430/// Local path or remote HTTPS include target.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub enum IncludeRef {
433    /// Relative path under the preferred jan directory (optional sha256 / script opts).
434    Local(LocalInclude),
435    /// Remote YAML fetched with SHA256 verification.
436    Remote(RemoteInclude),
437}
438
439#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
440pub struct RemoteInclude {
441    pub url: String,
442    pub sha256: String,
443    #[serde(default)]
444    pub ttl: Option<u64>,
445}
446
447impl IncludeRef {
448    pub fn is_remote(&self) -> bool {
449        matches!(self, Self::Remote(_))
450    }
451
452    pub fn local_path(&self) -> Option<&str> {
453        match self {
454            Self::Local(l) => Some(l.path.as_str()),
455            Self::Remote(_) => None,
456        }
457    }
458
459    pub fn cycle_token(&self) -> String {
460        match self {
461            Self::Local(l) => match &l.sha256 {
462                Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
463                None => l.path.clone(),
464            },
465            Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
466        }
467    }
468}
469
470impl<'de> Deserialize<'de> for IncludeRef {
471    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472    where
473        D: Deserializer<'de>,
474    {
475        #[derive(Deserialize)]
476        #[serde(deny_unknown_fields)]
477        struct LocalMap {
478            path: String,
479            #[serde(default)]
480            sha256: Option<String>,
481            #[serde(default)]
482            argv: Vec<String>,
483            #[serde(default)]
484            passthrough: bool,
485        }
486
487        #[derive(Deserialize)]
488        #[serde(untagged)]
489        enum Helper {
490            Path(String),
491            Local(LocalMap),
492            Remote(RemoteInclude),
493        }
494
495        match Helper::deserialize(deserializer)? {
496            Helper::Path(path) => {
497                let path = path.trim();
498                if path.is_empty() {
499                    return Err(de::Error::custom("include path must not be empty"));
500                }
501                Ok(IncludeRef::Local(LocalInclude::from_path(path)))
502            }
503            Helper::Local(m) => {
504                let path = m.path.trim();
505                if path.is_empty() {
506                    return Err(de::Error::custom("include.path must not be empty"));
507                }
508                let sha256 = m
509                    .sha256
510                    .map(|s| s.trim().to_string())
511                    .filter(|s| !s.is_empty());
512                Ok(IncludeRef::Local(LocalInclude {
513                    path: path.to_string(),
514                    sha256,
515                    argv: m.argv,
516                    passthrough: m.passthrough,
517                }))
518            }
519            Helper::Remote(r) => {
520                if r.url.trim().is_empty() {
521                    return Err(de::Error::custom("include.url must not be empty"));
522                }
523                if r.sha256.trim().is_empty() {
524                    return Err(de::Error::custom(
525                        "include.sha256 is required with include.url",
526                    ));
527                }
528                Ok(IncludeRef::Remote(r))
529            }
530        }
531    }
532}
533
534#[derive(Debug, Deserialize, Clone, Default)]
535pub struct ExecSpec {
536    /// Program argv. For remote `url` / local `file` leaves this is an optional
537    /// interpreter prefix (e.g. `["python3"]`); the script path is appended automatically.
538    #[serde(default)]
539    pub argv: Vec<String>,
540    /// Append extra CLI arguments after those from `argv` / the script path.
541    #[serde(default)]
542    pub passthrough: bool,
543    /// HTTPS URL of a remote script to download, verify, and run.
544    #[serde(default)]
545    pub url: Option<String>,
546    /// Local script path relative to the jan use root (optional `sha256` pin).
547    #[serde(default)]
548    pub file: Option<String>,
549    /// SHA256 of the script: required with `url`, optional with `file`.
550    #[serde(default)]
551    pub sha256: Option<String>,
552    /// Optional TTL override (seconds) for the remote script cache.
553    #[serde(default)]
554    pub ttl: Option<u64>,
555}
556
557impl ExecSpec {
558    pub fn is_remote(&self) -> bool {
559        self.url
560            .as_deref()
561            .map(|u| !u.trim().is_empty())
562            .unwrap_or(false)
563    }
564
565    pub fn is_local_file(&self) -> bool {
566        self.file
567            .as_deref()
568            .map(|u| !u.trim().is_empty())
569            .unwrap_or(false)
570    }
571
572    pub fn validate(&self, path: &str) -> Result<()> {
573        let url = self
574            .url
575            .as_deref()
576            .map(str::trim)
577            .filter(|s| !s.is_empty());
578        let file = self
579            .file
580            .as_deref()
581            .map(str::trim)
582            .filter(|s| !s.is_empty());
583        let hash = self
584            .sha256
585            .as_deref()
586            .map(str::trim)
587            .filter(|s| !s.is_empty());
588        if url.is_some() && file.is_some() {
589            bail!("command '{path}': exec cannot set both `url` and `file`");
590        }
591        match (url, file, hash) {
592            (Some(_), None, Some(_)) => Ok(()),
593            (Some(_), None, None) => {
594                bail!("command '{path}': exec.sha256 is required with exec.url")
595            }
596            (None, Some(_), _) => Ok(()),
597            (None, None, Some(_)) => {
598                bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
599            }
600            (None, None, None) => {
601                if self.argv.is_empty() {
602                    bail!(
603                        "command '{path}': exec.argv must not be empty (or set exec.url / exec.file)"
604                    );
605                }
606                Ok(())
607            }
608            (Some(_), Some(_), _) => unreachable!("checked above"),
609        }
610    }
611}
612
613impl CommandNode {
614    pub fn is_leaf_exec(&self) -> bool {
615        self.exec.is_some()
616    }
617
618    pub fn validate(&self, path: &str) -> Result<()> {
619        if self.exec.is_some() && !self.commands.is_empty() {
620            bail!("command '{path}' cannot define both `exec` and nested `commands`");
621        }
622        if let Some(ref e) = self.exec {
623            e.validate(path)?;
624        }
625        self.env.validate(path)?;
626        self.packages.validate(path)?;
627        for name in self.inputs.keys() {
628            inputs::InputDef::validate_name(name)
629                .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
630        }
631        for (name, child) in &self.commands {
632            let p = if path.is_empty() {
633                name.clone()
634            } else {
635                format!("{path} {name}")
636            };
637            child.validate(&p)?;
638        }
639        Ok(())
640    }
641}
642
643/// Deep-merge `overlay.commands` into `base`, letting included YAML fragments
644/// add or replace leaves and extend nested groups.
645pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
646    for (name, node) in overlay.commands {
647        match base.commands.get_mut(&name) {
648            Some(existing) => merge_command_node(existing, node)?,
649            None => {
650                base.commands.insert(name, node);
651            }
652        }
653    }
654    Ok(())
655}
656
657fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
658    if src.exec.is_some() && !src.commands.is_empty() {
659        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
660    }
661    if !src.os.is_empty() {
662        dst.os = src.os;
663    }
664    if !src.about.trim().is_empty() {
665        dst.about = src.about;
666    }
667    if src.path.is_some() {
668        dst.path = src.path;
669    }
670    if !src.dependencies.is_empty() {
671        dst.dependencies = src.dependencies;
672    }
673    if !src.requires.is_empty() {
674        dst.requires = src.requires;
675    }
676    if !src.cron.is_empty() {
677        dst.cron = src.cron;
678    }
679    if !src.env.is_empty() {
680        dst.env.merge_from(src.env);
681    }
682    for (k, v) in src.inputs {
683        dst.inputs.insert(k, v);
684    }
685    if let Some(exec) = src.exec {
686        dst.exec = Some(exec);
687        dst.commands.clear();
688        return Ok(());
689    }
690    if !src.commands.is_empty() {
691        dst.exec = None;
692        for (k, child) in src.commands {
693            match dst.commands.get_mut(&k) {
694                Some(existing) => merge_command_node(existing, child)?,
695                None => {
696                    dst.commands.insert(k, child);
697                }
698            }
699        }
700    }
701    Ok(())
702}
703
704/// Validate every command in the tree (after merges or programmatic edits).
705pub fn validate_spec(spec: &RootSpec) -> Result<()> {
706    for (name, node) in &spec.commands {
707        node.validate(name)?;
708    }
709    Ok(())
710}
711
712/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
713pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
714    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
715}
716
717pub fn load_spec(path: &Path) -> Result<RootSpec> {
718    spec_load::load_spec_from_path(path, HostPlatform::detect())
719}
720
721pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
722    if let Some(b) = override_branch {
723        if !b.is_empty() {
724            return b.to_string();
725        }
726    }
727    if let Ok(v) = std::env::var("JAN_BRANCH") {
728        if !v.is_empty() {
729            return v;
730        }
731    }
732    let output = Command::new("git")
733        .args(["rev-parse", "--abbrev-ref", "HEAD"])
734        .current_dir(cwd)
735        .output();
736    match output {
737        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
738        _ => "(no-git)".to_string(),
739    }
740}
741
742fn first_line(s: &str) -> String {
743    s.lines().next().unwrap_or("").trim().to_string()
744}
745
746pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
747    let mut out = String::new();
748    let bin = spec
749        .metadata
750        .as_ref()
751        .and_then(|m| m.name.as_deref())
752        .unwrap_or("jan");
753    let full_cmd = if chain.is_empty() {
754        bin.to_string()
755    } else {
756        format!("{} {}", bin, chain.join(" "))
757    };
758
759    let (about, children, exec) = match node {
760        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
761        None => ("", &spec.commands, None),
762    };
763
764    if chain.is_empty() {
765        if let Some(meta) = &spec.metadata {
766            if let Some(desc) = &meta.description {
767                out.push_str(desc.trim());
768                out.push_str("\n\n");
769            }
770        }
771    }
772
773    if !about.is_empty() {
774        out.push_str(about.trim());
775        out.push_str("\n\n");
776    }
777
778    if exec.is_some() && children.is_empty() {
779        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
780        let defs = inputs::collect_chain_inputs(chain, spec);
781        if !defs.is_empty() {
782            out.push('\n');
783            out.push_str(&inputs::format_inputs_help(&defs));
784        }
785        return out;
786    }
787
788    if !children.is_empty() {
789        out.push_str("Subcommands:\n");
790        for (name, child) in children {
791            let line = if child.about.is_empty() {
792                format!("  {name}\n")
793            } else {
794                format!("  {name} — {}\n", first_line(&child.about))
795            };
796            out.push_str(&line);
797        }
798        out.push('\n');
799        out.push_str(&format!(
800            "Use `{} --help` for more about a subcommand.\n",
801            full_cmd
802        ));
803        let defs = inputs::collect_chain_inputs(chain, spec);
804        if !defs.is_empty() {
805            out.push('\n');
806            out.push_str(&inputs::format_inputs_help(&defs));
807        }
808    } else if exec.is_none() {
809        out.push_str("(No subcommands defined.)\n");
810    }
811    if chain.is_empty() && node.is_none() {
812        out.push_str(
813            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
814        );
815    }
816    out
817}
818
819/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
820#[derive(Debug, Clone)]
821pub struct SpecRootIdentity {
822    /// Canonical directory containing top-level YAML fragments.
823    pub spec_dir: String,
824    /// Entry YAML file name relative to `spec_dir`.
825    pub root_yaml: String,
826}
827
828/// Resolve the preferred jan directory saved by `jan use`.
829pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
830    let cfg = config::load_user_config().context("load user config")?;
831    let Some(dir_s) = cfg
832        .jan_dir
833        .as_ref()
834        .map(|s| s.trim())
835        .filter(|s| !s.is_empty())
836    else {
837        bail!(
838            "no preferred jan directory configured\n\
839             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
840        );
841    };
842    let dir = PathBuf::from(dir_s);
843    if !dir.is_dir() {
844        bail!(
845            "preferred jan directory does not exist: {}\n\
846             Fix the path or run `jan use <DIR>` again (config: {})",
847            dir.display(),
848            config::config_path().display()
849        );
850    }
851    let root = cfg
852        .spec_root
853        .as_deref()
854        .map(str::trim)
855        .filter(|s| !s.is_empty())
856        .unwrap_or("scripts.spec.yaml");
857    resolve_spec_dir_entry(&dir, root)
858}
859
860/// Resolve a jan directory + entry file name into an absolute spec path and identity.
861pub fn resolve_spec_dir_entry(
862    spec_dir: &Path,
863    root_yaml: &str,
864) -> Result<(PathBuf, SpecRootIdentity)> {
865    let rel = Path::new(root_yaml);
866    if rel.is_absolute() {
867        bail!("entry YAML must be a relative file name, not an absolute path");
868    }
869    if rel
870        .components()
871        .any(|c| matches!(c, std::path::Component::ParentDir))
872    {
873        bail!("entry YAML must not contain `..`");
874    }
875    let normal_only = rel
876        .components()
877        .all(|c| matches!(c, std::path::Component::Normal(_)));
878    let n = rel
879        .components()
880        .filter(|c| matches!(c, std::path::Component::Normal(_)))
881        .count();
882    if !normal_only || n != 1 {
883        bail!("entry YAML must be a single file name inside the jan directory");
884    }
885    let dir = spec_dir
886        .canonicalize()
887        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
888    if !dir.is_dir() {
889        bail!("not a directory: {}", dir.display());
890    }
891    let spec_path = dir.join(rel);
892    if !spec_path.is_file() {
893        bail!(
894            "spec entry not found: {} (under {})\n\
895             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
896            spec_path.display(),
897            dir.display()
898        );
899    }
900    let identity = SpecRootIdentity {
901        spec_dir: dir.to_string_lossy().into_owned(),
902        root_yaml: rel
903            .file_name()
904            .expect("relative root has file_name")
905            .to_string_lossy()
906            .into_owned(),
907    };
908    Ok((spec_path, identity))
909}
910
911pub struct RunContext<'a> {
912    pub cwd: &'a Path,
913    pub db_path: Option<&'a Path>,
914    pub branch: String,
915    pub no_log: bool,
916    pub spec_root: &'a SpecRootIdentity,
917}
918
919/// True when `argv` is a POSIX-shell inline (`bash`/`zsh`/`sh`/… + `-c`/`-lc` + body)
920/// with no `$0` placeholder after the body yet.
921///
922/// For those interpreters the first word after the `-c` string becomes `$0`, not `$1`.
923/// Inlined jan scripts expect normal script semantics (`$1` / `"$@"` = user args), so
924/// passthrough must insert a `$0` before forwarding.
925fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
926    if argv.len() != 3 {
927        return false;
928    }
929    let prog = Path::new(&argv[0])
930        .file_name()
931        .and_then(|s| s.to_str())
932        .unwrap_or(argv[0].as_str());
933    let is_shell = matches!(
934        prog,
935        "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
936    );
937    is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
938}
939
940fn shell_passthrough_argv0(chain: &[String]) -> String {
941    chain
942        .iter()
943        .rev()
944        .find(|s| s.as_str() != "run")
945        .cloned()
946        .or_else(|| chain.last().cloned())
947        .unwrap_or_else(|| "jan".to_string())
948}
949
950pub fn run_matched(
951    spec: &RootSpec,
952    chain: &[String],
953    node: &CommandNode,
954    trailing: &[OsString],
955    ctx: &RunContext<'_>,
956) -> Result<i32> {
957    let exec = match &node.exec {
958        Some(e) => e,
959        None => {
960            let help = format_help(spec, chain, Some(node));
961            print!("{help}");
962            bail!("missing subcommand");
963        }
964    };
965    exec.validate(&chain.join(" "))?;
966
967    let input_defs = inputs::collect_chain_inputs(chain, spec);
968    let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
969
970    let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
971    for a in &exec.argv {
972        argv.push(inputs::interpolate(a, &input_vals)?);
973    }
974
975    if exec.is_remote() {
976        let url = exec.url.as_deref().unwrap().trim();
977        let hash = exec.sha256.as_deref().unwrap().trim();
978        let mut opts = remote::FetchOpts::new();
979        if let Some(ttl) = exec.ttl {
980            opts = opts.with_ttl(ttl);
981        }
982        let cached = remote::fetch_verified(url, hash, &opts, true)?;
983        argv.push(cached.to_string_lossy().into_owned());
984    } else if exec.is_local_file() {
985        let rel = exec.file.as_deref().unwrap().trim();
986        let use_root = Path::new(&ctx.spec_root.spec_dir);
987        let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
988        if let Some(hash) = exec.sha256.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
989            remote::verify_file_sha256(&resolved, hash)
990                .with_context(|| format!("verify exec.file `{rel}`"))?;
991        }
992        argv.push(resolved.to_string_lossy().into_owned());
993    } else if argv.is_empty() {
994        bail!("exec.argv must not be empty");
995    }
996
997    if exec.passthrough {
998        let mut rest = rest;
999        // `--` after the leaf is the usual jan separator; drop one leading `--` so
1000        // `run -- arg` and `run arg` match for both inline shells and `exec.file` /
1001        // script includes. A literal first arg of `--` needs `run -- --`.
1002        if rest.first().is_some_and(|a| a == "--") {
1003            rest = rest[1..].to_vec();
1004        }
1005        if shell_inline_c_needs_argv0(&argv) {
1006            argv.push(shell_passthrough_argv0(chain));
1007        }
1008        for a in &rest {
1009            argv.push(a.to_string_lossy().into_owned());
1010        }
1011    } else if !rest.is_empty() {
1012        let preview = rest
1013            .iter()
1014            .take(3)
1015            .map(|s| s.to_string_lossy().into_owned())
1016            .collect::<Vec<_>>()
1017            .join(" ");
1018        bail!(
1019            "unexpected trailing arguments: {preview}{}",
1020            if rest.len() > 3 { "…" } else { "" }
1021        );
1022    }
1023
1024    let cmd_path = if chain.is_empty() {
1025        "(root)".to_string()
1026    } else {
1027        chain.join(" ")
1028    };
1029
1030    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1031    deps::check_requires(&requires)?;
1032
1033    let pkgs = packages::collect_chain_packages(chain, spec);
1034    let uv_env = packages::ensure_packages(&pkgs, ctx)?;
1035
1036    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
1037    let program = packages::resolve_program_with_uv(&argv[0], uv_env.as_ref(), &path_dirs)?;
1038    let mut env_spec = deps::collect_chain_env(chain, spec);
1039    for value in env_spec.public.values_mut() {
1040        *value = inputs::interpolate(value, &input_vals)?;
1041    }
1042    deps::check_private_env(&env_spec.private)?;
1043    let mut path_override = if !path_dirs.is_empty() {
1044        Some(deps::prepend_path_env(&path_dirs)?)
1045    } else {
1046        None
1047    };
1048    if let Some(ref uv) = uv_env {
1049        path_override = Some(packages::prepend_uv_path(uv, path_override)?);
1050    }
1051
1052    let mut c = Command::new(&program);
1053    if argv.len() > 1 {
1054        c.args(&argv[1..]);
1055    }
1056    c.current_dir(ctx.cwd);
1057    deps::apply_process_env(&mut c, &env_spec, path_override)?;
1058
1059    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
1060    let code = status.code().unwrap_or(255);
1061
1062    if !ctx.no_log {
1063        if let Some(db) = ctx.db_path {
1064            log_invocation(
1065                db,
1066                &ctx.branch,
1067                ctx.cwd,
1068                &cmd_path,
1069                &argv,
1070                code,
1071                ctx.spec_root,
1072            )?;
1073        }
1074    }
1075
1076    Ok(code)
1077}
1078
1079fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
1080    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
1081    let cols: Vec<String> = stmt
1082        .query_map([], |row| row.get::<_, String>(1))?
1083        .collect::<std::result::Result<_, _>>()?;
1084    if !cols.iter().any(|c| c == "spec_root_id") {
1085        conn.execute(
1086            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
1087            [],
1088        )?;
1089    }
1090    Ok(())
1091}
1092
1093fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
1094    let ts = unix_ts();
1095    conn.execute(
1096        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
1097          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
1098        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
1099    )?;
1100    let id: i64 = conn.query_row(
1101        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
1102        [&spec.spec_dir, &spec.root_yaml],
1103        |r| r.get(0),
1104    )?;
1105    Ok(id)
1106}
1107
1108fn log_invocation(
1109    db_path: &Path,
1110    branch: &str,
1111    cwd: &Path,
1112    command_path: &str,
1113    argv: &[String],
1114    exit_code: i32,
1115    spec_root: &SpecRootIdentity,
1116) -> Result<()> {
1117    if let Some(parent) = db_path.parent() {
1118        std::fs::create_dir_all(parent).ok();
1119    }
1120    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
1121    conn.execute_batch(
1122        r"
1123        CREATE TABLE IF NOT EXISTS spec_roots (
1124            id INTEGER PRIMARY KEY AUTOINCREMENT,
1125            spec_dir TEXT NOT NULL,
1126            root_yaml TEXT NOT NULL,
1127            last_used_ts TEXT NOT NULL,
1128            UNIQUE(spec_dir, root_yaml)
1129        );
1130        CREATE TABLE IF NOT EXISTS invocations (
1131            id INTEGER PRIMARY KEY AUTOINCREMENT,
1132            ts TEXT NOT NULL,
1133            git_branch TEXT NOT NULL,
1134            cwd TEXT NOT NULL,
1135            command_path TEXT NOT NULL,
1136            argv_json TEXT NOT NULL,
1137            exit_code INTEGER NOT NULL,
1138            spec_root_id INTEGER
1139        );
1140        ",
1141    )?;
1142    ensure_invocations_spec_root_column(&conn)?;
1143    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
1144    let ts = unix_ts();
1145    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
1146    let cwd_s = cwd.to_string_lossy();
1147    conn.execute(
1148        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
1149         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1150        rusqlite::params![
1151            ts,
1152            branch,
1153            cwd_s.as_ref(),
1154            command_path,
1155            argv_json,
1156            exit_code,
1157            spec_root_id
1158        ],
1159    )?;
1160    Ok(())
1161}
1162
1163fn unix_ts() -> String {
1164    use std::time::SystemTime;
1165    SystemTime::now()
1166        .duration_since(std::time::UNIX_EPOCH)
1167        .unwrap_or_default()
1168        .as_secs()
1169        .to_string()
1170}
1171
1172#[derive(Debug)]
1173pub struct MatchOutcome<'a> {
1174    pub chain: Vec<String>,
1175    pub node: Option<&'a CommandNode>,
1176    pub trailing: Vec<OsString>,
1177    pub wants_help: bool,
1178}
1179
1180pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
1181    let mut chain = Vec::new();
1182    let mut node: Option<&'a CommandNode> = None;
1183    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
1184    let mut i = 0usize;
1185    let len = args.len();
1186    while i < len {
1187        let raw = &args[i];
1188        if raw == "--help" || raw == "-h" {
1189            return MatchOutcome {
1190                chain,
1191                node,
1192                trailing: args[i + 1..].to_vec(),
1193                wants_help: true,
1194            };
1195        }
1196        let key = raw.to_string_lossy();
1197        if let Some(next) = map.get(key.as_ref()) {
1198            chain.push(key.into_owned());
1199            node = Some(next);
1200            map = &next.commands;
1201            i += 1;
1202            continue;
1203        }
1204        break;
1205    }
1206    MatchOutcome {
1207        chain,
1208        node,
1209        trailing: args[i..].to_vec(),
1210        wants_help: false,
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use std::io::Write;
1218
1219    #[test]
1220    fn examples_default_spec_validates() {
1221        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
1222        load_spec(&path).unwrap();
1223    }
1224
1225    #[test]
1226    fn merge_specs_adds_and_replaces_leaves() {
1227        let mut base = load_spec_from_str(
1228            r"
1229commands:
1230  a:
1231    about: base
1232    commands:
1233      x:
1234        about: old
1235        exec:
1236          argv: [echo, old]
1237",
1238            None,
1239        )
1240        .unwrap();
1241        let overlay = load_spec_from_str(
1242            r"
1243commands:
1244  a:
1245    commands:
1246      x:
1247        about: new leaf
1248        exec:
1249          argv: [echo, new]
1250  b:
1251    about: added top
1252    exec:
1253      argv: [echo, b]
1254",
1255            None,
1256        )
1257        .unwrap();
1258        merge_specs_into(&mut base, overlay).unwrap();
1259        base.commands["a"].commands["x"].validate("a x").unwrap();
1260        assert_eq!(
1261            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
1262            vec!["echo", "new"]
1263        );
1264        assert_eq!(
1265            base.commands["b"].exec.as_ref().unwrap().argv,
1266            vec!["echo", "b"]
1267        );
1268    }
1269
1270    #[test]
1271    fn validate_rejects_exec_with_children() {
1272        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
1273        write!(
1274            tmp,
1275            r"
1276commands:
1277  x:
1278    exec:
1279      argv: [echo]
1280    commands:
1281      child:
1282        about: nested
1283"
1284        )
1285        .unwrap();
1286        let err = load_spec(tmp.path()).unwrap_err();
1287        assert!(err.to_string().contains("cannot define both"));
1288    }
1289
1290    #[test]
1291    fn shell_inline_c_needs_argv0_detects_bash_lc() {
1292        let argv = vec![
1293            "bash".into(),
1294            "-lc".into(),
1295            "case \"$1\" in create) ;; esac".into(),
1296        ];
1297        assert!(shell_inline_c_needs_argv0(&argv));
1298        let with_placeholder = vec![
1299            "zsh".into(),
1300            "-c".into(),
1301            "echo".into(),
1302            "issue".into(),
1303        ];
1304        assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1305        assert!(!shell_inline_c_needs_argv0(&[
1306            "echo".into(),
1307            "start".into()
1308        ]));
1309        assert!(!shell_inline_c_needs_argv0(&[
1310            "python3".into(),
1311            "-c".into(),
1312            "print(1)".into()
1313        ]));
1314    }
1315
1316    #[test]
1317    fn shell_passthrough_argv0_skips_run_leaf() {
1318        assert_eq!(
1319            shell_passthrough_argv0(&["scripts".into(), "misc".into(), "issue".into(), "run".into()]),
1320            "issue"
1321        );
1322        assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1323    }
1324}
1325
1326pub fn default_db_path() -> PathBuf {
1327    if let Ok(p) = std::env::var("JAN_DB") {
1328        return PathBuf::from(p);
1329    }
1330    dirs::data_local_dir()
1331        .unwrap_or_else(|| PathBuf::from("."))
1332        .join("jan-cli")
1333        .join("audit.db")
1334}