Skip to main content

agentstatedeveloper_cli/commands/
help.rs

1//! `asd help [topic]` — on-demand instruction disclosure.
2//!
3//! Returns compiled-in feature docs (synopsis, syntax, params, examples,
4//! gotchas) for one asd feature, or the full catalog. Docs come from
5//! [`agentstatedeveloper_core::help`], version-pinned to the running binary, so
6//! the CLI and the `asd-mcp` `help` tool return byte-identical payloads.
7//!
8//! `--publish` writes this binary's manifest into the shared cross-tool index
9//! (`$AGENTSTATE_HELP_INDEX`, default `$HOME/.config/agentstate/
10//! help-index.json`) so a unified `help` can discover asd's features alongside
11//! ctx's. The index is tool-keyed; publishing asd never clobbers ctx's slice.
12
13use std::path::PathBuf;
14
15use anyhow::{Result, anyhow};
16use clap::Args;
17use serde_json::{Value, json};
18
19use agentstatedeveloper_core::help;
20
21use crate::config::Config;
22
23#[derive(Debug, Args)]
24pub struct HelpArgs {
25    /// Feature name (e.g. "impact") or a phrase (e.g. "blast radius").
26    pub topic: Option<String>,
27
28    /// Machine-readable JSON output.
29    #[arg(long)]
30    pub agent: bool,
31
32    /// Print this binary's manifest (feature -> synopsis -> owner -> version).
33    #[arg(long)]
34    pub manifest: bool,
35
36    /// Publish this binary's manifest into the shared cross-tool help index,
37    /// merging alongside any other tool's entry. Prints the path written.
38    #[arg(long)]
39    pub publish: bool,
40
41    /// Resolve locally only — do not proxy an unknown topic to the other tool.
42    /// Used internally to break the proxy chain (single-hop guard).
43    #[arg(long, hide = true)]
44    pub no_proxy: bool,
45}
46
47pub fn run(_cfg: &Config, args: HelpArgs) -> Result<()> {
48    if args.publish {
49        let path = publish_help_index()?;
50        println!("Published asd help manifest to {}", path.display());
51        return Ok(());
52    }
53
54    let value = if args.manifest {
55        help::manifest()
56    } else {
57        help::resolve(args.topic.as_deref(), !args.no_proxy)
58    };
59
60    if args.agent || args.manifest {
61        println!("{}", serde_json::to_string_pretty(&value)?);
62        return Ok(());
63    }
64    render_help(&value);
65    Ok(())
66}
67
68/// Resolve the shared help index path: `$AGENTSTATE_HELP_INDEX`, else
69/// `$HOME/.config/agentstate/help-index.json` — same literal `$HOME/.config`
70/// convention as asd's `~/.config/asd/repos.toml` registry.
71fn help_index_path() -> Option<PathBuf> {
72    if let Some(p) = std::env::var_os("AGENTSTATE_HELP_INDEX") {
73        return Some(PathBuf::from(p));
74    }
75    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config/agentstate/help-index.json"))
76}
77
78/// Merge this binary's manifest into the shared index, preserving other tools'
79/// slices. Returns the path written. Exposed so `asd mcp install` can refresh
80/// the index as a best-effort setup step.
81pub(crate) fn publish_help_index() -> Result<PathBuf> {
82    let manifest = help::manifest();
83    let tool = manifest
84        .get("tool")
85        .and_then(|t| t.as_str())
86        .ok_or_else(|| anyhow!("manifest missing `tool`"))?
87        .to_string();
88    let path = help_index_path().ok_or_else(|| anyhow!("cannot resolve HOME"))?;
89
90    // Read + merge; tolerate a missing or malformed file by starting fresh so a
91    // corrupt index never blocks publishing.
92    let mut index = std::fs::read_to_string(&path)
93        .ok()
94        .and_then(|s| serde_json::from_str::<Value>(&s).ok())
95        .filter(|v| v.is_object())
96        .unwrap_or_else(|| json!({ "schema": 1, "tools": {} }));
97    if !index.get("tools").map(|t| t.is_object()).unwrap_or(false) {
98        index["tools"] = json!({});
99    }
100    index["schema"] = json!(1);
101    index["tools"][&tool] = json!({
102        "version": manifest.get("version").cloned().unwrap_or(Value::Null),
103        "features": manifest.get("features").cloned().unwrap_or_else(|| json!([])),
104    });
105
106    if let Some(dir) = path.parent() {
107        std::fs::create_dir_all(dir)?;
108    }
109    std::fs::write(
110        &path,
111        format!("{}\n", serde_json::to_string_pretty(&index)?),
112    )
113    .map_err(|e| anyhow!("writing {}: {e}", path.display()))?;
114    Ok(path)
115}
116
117/// Render a help response as readable text (catalog / one feature /
118/// disambiguation / not-found).
119fn render_help(v: &Value) {
120    // Catalog.
121    if let Some(groups) = v.get("groups").and_then(|g| g.as_object()) {
122        println!(
123            "asd help — {} features (v{}). Call `asd help <feature>` for details.\n",
124            v.get("feature_count").and_then(|c| c.as_u64()).unwrap_or(0),
125            v.get("version").and_then(|s| s.as_str()).unwrap_or("?"),
126        );
127        for (group, feats) in groups {
128            println!("{}:", group);
129            if let Some(arr) = feats.as_array() {
130                for f in arr {
131                    println!(
132                        "  {:<16} {}",
133                        f.get("feature").and_then(|s| s.as_str()).unwrap_or(""),
134                        f.get("synopsis").and_then(|s| s.as_str()).unwrap_or(""),
135                    );
136                }
137            }
138            println!();
139        }
140        return;
141    }
142
143    // Disambiguation.
144    if let Some(matches) = v.get("matches").and_then(|m| m.as_array()) {
145        println!(
146            "No exact match for '{}'. Did you mean:",
147            v.get("query").and_then(|s| s.as_str()).unwrap_or(""),
148        );
149        for m in matches {
150            println!(
151                "  {:<16} {}",
152                m.get("feature").and_then(|s| s.as_str()).unwrap_or(""),
153                m.get("synopsis").and_then(|s| s.as_str()).unwrap_or(""),
154            );
155        }
156        return;
157    }
158
159    // Not found.
160    if let Some(nf) = v.get("not_found").and_then(|s| s.as_str()) {
161        println!("No asd feature matched '{}'.", nf);
162        if let Some(hint) = v.get("hint").and_then(|s| s.as_str()) {
163            println!("{}", hint);
164        }
165        return;
166    }
167
168    // Single feature.
169    let get = |k: &str| v.get(k).and_then(|s| s.as_str()).unwrap_or("");
170    if let Some(from) = v.get("proxied_from").and_then(|s| s.as_str()) {
171        println!("(via {from})");
172    }
173    println!("{} — {}", get("feature"), get("synopsis"));
174    println!("  syntax: {}", get("syntax"));
175    if let Some(params) = v.get("params").and_then(|p| p.as_array())
176        && !params.is_empty()
177    {
178        println!("  params:");
179        for p in params {
180            let req = if p.get("required").and_then(|b| b.as_bool()).unwrap_or(false) {
181                "(required)"
182            } else {
183                "(optional)"
184            };
185            println!(
186                "    {:<14} {} {}",
187                p.get("name").and_then(|s| s.as_str()).unwrap_or(""),
188                req,
189                p.get("desc").and_then(|s| s.as_str()).unwrap_or(""),
190            );
191        }
192    }
193    let list = |label: &str, key: &str| {
194        if let Some(arr) = v.get(key).and_then(|a| a.as_array())
195            && !arr.is_empty()
196        {
197            println!("  {}:", label);
198            for item in arr {
199                if let Some(s) = item.as_str() {
200                    println!("    - {}", s);
201                }
202            }
203        }
204    };
205    list("examples", "examples");
206    list("gotchas", "gotchas");
207    list("related", "related");
208    println!(
209        "  (~{} tokens, v{})",
210        v.get("help_tokens").and_then(|t| t.as_u64()).unwrap_or(0),
211        get("version"),
212    );
213}