merge a0545d59-caba-4be0-896b-81cf9b28715a

6430f20gl0w

all commits

Message

merge a0545d59-caba-4be0-896b-81cf9b28715a

Diff

crates/cli/src/cmd/ci.rs+75 −12
diff --git a/crates/cli/src/cmd/ci.rs b/crates/cli/src/cmd/ci.rsindex 1e2076e..03cb31d 100644--- a/crates/cli/src/cmd/ci.rs+++ b/crates/cli/src/cmd/ci.rs@@ -8,7 +8,37 @@ use std::process::Command;88 use std::time::Instant;99 1010 pub fn help() -> &'static str {11-    "f0rge ci: script runs\nusage: f0rge ci <verb> [--json] [flags]\nverbs: run list view log rerun cancel\ndocs: spec/schema.md §Ci\ncontract: f0rge --skill\n"11+    "f0rge ci: script runs\nusage: f0rge ci <verb> [--json] [flags]\nverbs: run list view log rerun cancel\nrun: [--docker] [pr]; log: <run> [step]\ndocs: spec/schema.md §Ci\ncontract: f0rge --skill\n"12+}13+14+// Per-verb helps next to parser arms (#36). Each <=250B/8ln.15+const RUN_H: &str = "f0rge ci run: scripts\nusage: f0rge ci run [--docker] [--continue-on-error] [pr]\ncontract: f0rge --skill\n";16+const LIST_H: &str = "f0rge ci list: runs\nusage: f0rge ci list\ncontract: f0rge --skill\n";17+const VIEW_H: &str = "f0rge ci view: summary\nusage: f0rge ci view <run>\ncontract: f0rge --skill\n";18+const LOG_H: &str = "f0rge ci log: output\nusage: f0rge ci log <run> [step]\ncontract: f0rge --skill\n";19+const RERUN_H: &str = "f0rge ci rerun: retry\nusage: f0rge ci rerun [run]\ncontract: f0rge --skill\n";20+const CANCEL_H: &str = "f0rge ci cancel: stop\nusage: f0rge ci cancel\ncontract: f0rge --skill\n";21+22+/// Verb-depth help (#36).23+pub fn verb_help(verb: &str) -> Option<&'static str> {24+    Some(match verb {25+        "run" => RUN_H,26+        "list" => LIST_H,27+        "view" => VIEW_H,28+        "log" => LOG_H,29+        "rerun" => RERUN_H,30+        "cancel" => CANCEL_H,31+        _ => return None,32+    })33+}34+35+fn with_try(e: CliError, verb: &str) -> CliError {36+    match e {37+        CliError::Usage(m) if !m.contains("(try:") => {38+            CliError::Usage(format!("{m} (try: f0rge ci {verb} --help)"))39+        }40+        other => other,41+    }1242 }1343 1444 const VERBS: &[&str] = &["run", "list", "view", "log", "rerun", "cancel"];@@ -465,22 +495,29 @@ fn cancel(fg: &Forge) -> Result<(), CliError> {465495 466496 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {467497     if args_in.is_empty() {468-        return Err(usage("f0rge ci <run|list|view|log|rerun|cancel>"));498+        return Err(usage("f0rge ci <run|list|view|log|rerun|cancel> (try: f0rge ci -h)"));469499     }470500     let verb = clean(&args_in[0]);471501     if verb.is_empty() || verb.starts_with('-') || !VERBS.contains(&verb.as_str()) {472-        return Err(usage("f0rge ci <run|list|view|log|rerun|cancel>"));502+        let shown = if verb.is_empty() { "<none>".to_string() } else { verb };503+        return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge ci -h)")));473504     }474-    let fs = args::parse_flags(&args_in[1..], &spec())?;475-    match verb.as_str() {505+    let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;506+    if fs.is("help") {507+        if let Some(h) = verb_help(&verb) {508+            output::out(h);509+            return Ok(());510+        }511+    }512+    let r: Result<(), CliError> = match verb.as_str() {476513         "run" => {477514             if fs.positional.len() > 1 {478-                return Err(usage("f0rge ci run [--docker] [pr]"));515+                return Err(with_try(usage("f0rge ci run [--docker] [pr]"), &verb));479516             }480517             let pr = fs.positional.first().map(|s| clean(s)).filter(|s| !s.is_empty());481518             if let Some(p) = &pr {482519                 if !f0rge_core::is_uuid4_lower(p) {483-                    return Err(usage("f0rge ci run [pr]"));520+                    return Err(with_try(usage("f0rge ci run [pr]"), &verb));484521                 }485522             }486523             do_run(&fs, fg, pr)@@ -488,28 +525,54 @@ pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {488525         "list" => list_runs(&fs, fg),489526         "view" => {490527             if fs.positional.len() != 1 {491-                return Err(usage("f0rge ci view <run>"));528+                return Err(with_try(usage("f0rge ci view <run>"), &verb));492529             }493530             view(&fs, fg)494531         }495532         "log" => {496533             if fs.positional.is_empty() || fs.positional.len() > 2 {497-                return Err(usage("f0rge ci log <run> [step]"));534+                return Err(with_try(usage("f0rge ci log <run> [step]"), &verb));498535             }499536             log(&fs, fg)500537         }501538         "rerun" => {502539             if fs.positional.len() > 1 {503-                return Err(usage("f0rge ci rerun [run]"));540+                return Err(with_try(usage("f0rge ci rerun [run]"), &verb));504541             }505542             rerun(&fs, fg)506543         }507544         "cancel" => {508545             if !fs.positional.is_empty() {509-                return Err(usage("f0rge ci cancel"));546+                return Err(with_try(usage("f0rge ci cancel"), &verb));510547             }511548             cancel(fg)512549         }513-        _ => Err(usage("f0rge ci <run|list|view|log|rerun|cancel>")),550+        _ => Err(usage("f0rge ci <run|list|view|log|rerun|cancel> (try: f0rge ci -h)")),551+    };552+    r.map_err(|e| with_try(e, &verb))553+}554+555+#[cfg(test)]556+mod verb_help_tests {557+    use super::*;558+    fn budget(s: &str) {559+        assert!(s.lines().count() <= 8, "lines:\n{s}");560+        assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());561+    }562+    #[test]563+    fn per_verb_budgets_and_needles() {564+        for (v, needle) in [565+            ("run", "--docker"),566+            ("list", "ci list"),567+            ("view", "<run>"),568+            ("log", "[step]"),569+            ("rerun", "[run]"),570+            ("cancel", "ci cancel"),571+        ] {572+            let h = verb_help(v).expect(v);573+            budget(h);574+            assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");575+        }576+        assert!(verb_help("frobnicate").is_none());514577     }515578 }
crates/cli/src/cmd/issue.rs+87 −13
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex 3d0345c..fe0de19 100644--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -13,6 +13,43 @@ pub fn help() -> &'static str {1313     "f0rge issue: issues\nusage: f0rge issue <verb> [--json] [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --state --search\ndocs: spec/schema.md §Issue\ncontract: f0rge --skill\n"1414 }1515 16+// Per-verb helps: small consts next to parser arms (#36). Each <=250B/8ln.17+const CREATE_H: &str = "f0rge issue create: new issue\nusage: f0rge issue create --title S [--body S --label L --assignee A --milestone M]\ncontract: f0rge --skill\n";18+const LIST_H: &str = "f0rge issue list: filter\nusage: f0rge issue list [--label L] [--assignee A] [--state S] [--search Q]\ncontract: f0rge --skill\n";19+const VIEW_H: &str = "f0rge issue view: show\nusage: f0rge issue view <id>\ncontract: f0rge --skill\n";20+const COMMENT_H: &str = "f0rge issue comment: add\nusage: f0rge issue comment <id> --body B [--author A]\ncontract: f0rge --skill\n";21+const CLOSE_H: &str = "f0rge issue close: shut\nusage: f0rge issue close <id>\ncontract: f0rge --skill\n";22+const REOPEN_H: &str = "f0rge issue reopen: open\nusage: f0rge issue reopen <id>\ncontract: f0rge --skill\n";23+const ASSIGN_H: &str = "f0rge issue assign: owner\nusage: f0rge issue assign <id> --assignee A [--remove]\ncontract: f0rge --skill\n";24+const LABEL_H: &str = "f0rge issue label: tag\nusage: f0rge issue label <id> --label L [--remove]\ncontract: f0rge --skill\n";25+const EDIT_H: &str = "f0rge issue edit: change\nusage: f0rge issue edit <id> [--title T] [--body B]\ncontract: f0rge --skill\n";26+27+/// Verb-depth help for future dispatch (#36). None when unknown.28+pub fn verb_help(verb: &str) -> Option<&'static str> {29+    Some(match verb {30+        "create" => CREATE_H,31+        "list" => LIST_H,32+        "view" => VIEW_H,33+        "comment" => COMMENT_H,34+        "close" => CLOSE_H,35+        "reopen" => REOPEN_H,36+        "assign" => ASSIGN_H,37+        "label" => LABEL_H,38+        "edit" => EDIT_H,39+        _ => return None,40+    })41+}42+43+/// Append parse hint (#37), one line, when missing.44+fn with_try(e: CliError, verb: &str) -> CliError {45+    match e {46+        CliError::Usage(m) if !m.contains("(try:") => {47+            CliError::Usage(format!("{m} (try: f0rge issue {verb} --help)"))48+        }49+        other => other,50+    }51+}52+1653 const VERBS: &[&str] =1754     &["create", "list", "view", "comment", "close", "reopen", "assign", "label", "edit"];1855 @@ -533,23 +570,60 @@ fn edit(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {533570 534571 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {535572     if args_in.is_empty() {536-        return Err(usage("f0rge issue <verb> [flags]"));573+        return Err(usage("f0rge issue <verb> [flags] (try: f0rge issue -h)"));537574     }538575     let verb = clean(&args_in[0]);539576     if verb.is_empty() || verb.starts_with('-') || !VERBS.contains(&verb.as_str()) {540-        return Err(usage("f0rge issue <verb> [flags]"));577+        let shown = if verb.is_empty() { "<none>".to_string() } else { verb };578+        return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge issue -h)")));579+    }580+    let fs = args::parse_flags(&args_in[1..], &spec())581+        .map_err(|e| with_try(e, &verb))?;582+    if fs.is("help") {583+        if let Some(h) = verb_help(&verb) {584+            output::out(h);585+            return Ok(());586+        }541587     }542-    let fs = args::parse_flags(&args_in[1..], &spec())?;543588     match verb.as_str() {544-        "create" => create(&fs, fg),545-        "list" => list(&fs, fg),546-        "view" => view(&fs, fg),547-        "comment" => comment(&fs, fg),548-        "close" => set_state(&fs, fg, "close", "closed"),549-        "reopen" => set_state(&fs, fg, "reopen", "open"),550-        "assign" => assign(&fs, fg),551-        "label" => label(&fs, fg),552-        "edit" => edit(&fs, fg),553-        _ => Err(usage("f0rge issue <verb> [flags]")),589+        "create" => create(&fs, fg).map_err(|e| with_try(e, &verb)),590+        "list" => list(&fs, fg).map_err(|e| with_try(e, &verb)),591+        "view" => view(&fs, fg).map_err(|e| with_try(e, &verb)),592+        "comment" => comment(&fs, fg).map_err(|e| with_try(e, &verb)),593+        "close" => set_state(&fs, fg, "close", "closed").map_err(|e| with_try(e, &verb)),594+        "reopen" => set_state(&fs, fg, "reopen", "open").map_err(|e| with_try(e, &verb)),595+        "assign" => assign(&fs, fg).map_err(|e| with_try(e, &verb)),596+        "label" => label(&fs, fg).map_err(|e| with_try(e, &verb)),597+        "edit" => edit(&fs, fg).map_err(|e| with_try(e, &verb)),598+        _ => Err(usage("f0rge issue <verb> [flags] (try: f0rge issue -h)")),599+    }600+}601+602+#[cfg(test)]603+mod verb_help_tests {604+    use super::*;605+    fn budget(s: &str) {606+        assert!(s.lines().count() <= 8, "lines:\n{s}");607+        assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());608+    }609+    #[test]610+    fn per_verb_budgets_and_needles() {611+        for (v, needle) in [612+            ("create", "--title"),613+            ("list", "--label"),614+            ("view", "<id>"),615+            ("comment", "--body"),616+            ("close", "close <id>"),617+            ("reopen", "reopen <id>"),618+            ("assign", "--assignee"),619+            ("label", "--label"),620+            ("edit", "--title"),621+        ] {622+            let h = verb_help(v).expect(v);623+            budget(h);624+            assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");625+            assert!(h.ends_with("contract: f0rge --skill\n"), "{v} contract:\n{h}");626+        }627+        assert!(verb_help("frobnicate").is_none());554628     }555629 }
crates/cli/src/cmd/merge.rs+17 −2
diff --git a/crates/cli/src/cmd/merge.rs b/crates/cli/src/cmd/merge.rsindex e92aef6..f66e9a2 100644--- a/crates/cli/src/cmd/merge.rs+++ b/crates/cli/src/cmd/merge.rs@@ -14,6 +14,21 @@ pub fn help() -> &'static str {1414     "f0rge merge: imports\nusage: f0rge merge <folder|zip|branch-name> [--json]\ngate: scratch worktree; never touches main/branches; conflicts stop; you integrate (ff) + delete\ndocs: spec/schema.md §Merge-record\ncontract: f0rge --skill\n"1515 }1616 17+/// No sub-verbs: single positional form (#36). Stub for API uniformity.18+#[allow(dead_code)]19+pub fn verb_help(_verb: &str) -> Option<&'static str> {20+    None21+}22+23+fn with_try(e: CliError) -> CliError {24+    match e {25+        CliError::Usage(m) if !m.contains("(try:") => {26+            CliError::Usage(format!("{m} (try: f0rge merge -h)"))27+        }28+        other => other,29+    }30+}31+1732 static CTR: AtomicU64 = AtomicU64::new(0);1833 1934 const VALS: &[&str] = &["jq", "base"];@@ -668,11 +683,11 @@ fn commit_applied(wt: &Path, id: &str) -> Result<String, CliError> {668683 669684 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {670685     let spec = spec_from_lists(VALS, BOOLS);671-    let fs = parse_flags(args, &spec)?;686+    let fs = parse_flags(args, &spec).map_err(with_try)?;672687     let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));673688     let base_flag = fs.get("base").map(|s| s.to_string());674689     if fs.positional.len() != 1 {675-        return Err(CliError::Usage("f0rge merge <folder|zip|branch-name>".to_string()));690+        return Err(with_try(CliError::Usage("f0rge merge <folder|zip|branch-name>".to_string())));676691     }677692     let src = fs.positional[0].clone();678693     let repo = repo_dir(fg)?;
crates/cli/src/cmd/mod.rs+24 −1
diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rsindex a3eb9ff..a663adf 100644--- a/crates/cli/src/cmd/mod.rs+++ b/crates/cli/src/cmd/mod.rs@@ -35,8 +35,13 @@ pub fn dispatch(3535     fg: &f0rge_core::Forge,3636     _g: &Global,3737 ) -> Result<(), CliError> {38-    // -h anywhere in sub args prints that subcommand's help.38+    // -h anywhere in sub args prints help: per-verb const when the39+    // first bare token names a verb (#36), else the domain block.3940     if args.iter().any(|a| a == "-h" || a == "--help") {41+        if let Some(h) = verb_help_for(sub, args) {42+            crate::output::out(h);43+            return Ok(());44+        }4045         crate::output::out(help_for(sub).unwrap_or(ROOT_HELP));4146         return Ok(());4247     }@@ -57,6 +62,24 @@ pub fn dispatch(5762     }5863 }5964 65+/// Two-arg help (#36): first bare token names the verb. None when the66+/// subcommand has no verbs (merge/skill) or the verb is unknown, so the67+/// domain block prints. Only fix-help verbs route here; other commands68+/// keep domain help until their owners add verb_help().69+fn verb_help_for(sub: &str, args: &[String]) -> Option<&'static str> {70+    let verb = args.first().map(|s| s.as_str()).filter(|s| !s.starts_with('-'))?;71+    Some(match sub {72+        "issue" => issue::verb_help(verb)?,73+        "pr" => pr::verb_help(verb)?,74+        "ci" => ci::verb_help(verb)?,75+        "wiki" => wiki::verb_help(verb)?,76+        "release" => release::verb_help(verb)?,77+        "merge" => merge::verb_help(verb)?,78+        "skill" => skill::verb_help(verb)?,79+        _ => return None,80+    })81+}82+6083 fn help_for(sub: &str) -> Option<&'static str> {6184     Some(match sub {6285         "issue" => issue::help(),
crates/cli/src/cmd/pr.rs+121 −9
diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rsindex 12a7e44..e9f06a8 100644--- a/crates/cli/src/cmd/pr.rs+++ b/crates/cli/src/cmd/pr.rs@@ -12,7 +12,66 @@ use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};1313 1414 pub fn help() -> &'static str {15-    "f0rge pr: pull requests\nusage: f0rge pr <create|diff|review|comment|merge|close|list> [--json]\nverbs: create diff review comment merge close list\ndocs: spec/schema.md §Pr\ncontract: f0rge --skill\n"15+    "f0rge pr: pull requests\nusage: f0rge pr <create|diff|review|comment|merge|close|list> [--json]\nverbs: create diff review comment merge close list\ncreate: --base B --head H --issue UUID\ndocs: spec/schema.md §Pr\ncontract: f0rge --skill\n"16+}17+18+// Per-verb helps next to parser arms (#36). Each <=250B/8ln.19+const CREATE_H: &str = "f0rge pr create: open\nusage: f0rge pr create --base B --head H [--issue UUID] [--title T]\ncontract: f0rge --skill\n";20+const DIFF_H: &str = "f0rge pr diff: patch\nusage: f0rge pr diff <id|number>\ncontract: f0rge --skill\n";21+const REVIEW_H: &str = "f0rge pr review: judge\nusage: f0rge pr review <id|number> --body T [--state S]\ncontract: f0rge --skill\n";22+const COMMENT_H: &str = "f0rge pr comment: note\nusage: f0rge pr comment <id|number> --body T\ncontract: f0rge --skill\n";23+const MERGE_H: &str = "f0rge pr merge: land\nusage: f0rge pr merge <id|number>\ncontract: f0rge --skill\n";24+const CLOSE_H: &str = "f0rge pr close: shut\nusage: f0rge pr close <id|number>\ncontract: f0rge --skill\n";25+const LIST_H: &str = "f0rge pr list: all\nusage: f0rge pr list [--state S]\ncontract: f0rge --skill\n";26+27+/// Verb-depth help (#36). None when unknown.28+pub fn verb_help(verb: &str) -> Option<&'static str> {29+    Some(match verb {30+        "create" => CREATE_H,31+        "diff" => DIFF_H,32+        "review" => REVIEW_H,33+        "comment" => COMMENT_H,34+        "merge" => MERGE_H,35+        "close" => CLOSE_H,36+        "list" => LIST_H,37+        _ => return None,38+    })39+}40+41+const KNOWN_VERBS: &[&str] = &["create", "diff", "review", "comment", "merge", "close", "list"];42+43+/// First non-flag token as verb guess for pre-verb parse errors (#37).44+fn guess_verb(raw: &[String]) -> Option<String> {45+    for a in raw {46+        if a == "--" {47+            continue;48+        }49+        if a.starts_with('-') {50+            continue;51+        }52+        // skip values glued to flags is impossible pre-parse; take first bare token53+        return Some(a.clone());54+    }55+    None56+}57+58+/// Append parse hint (#37), one line, when missing.59+fn with_try_verb(e: CliError, verb: &str) -> CliError {60+    match e {61+        CliError::Usage(m) if !m.contains("(try:") => {62+            CliError::Usage(format!("{m} (try: f0rge pr {verb} --help)"))63+        }64+        other => other,65+    }66+}67+68+fn with_try_domain(e: CliError) -> CliError {69+    match e {70+        CliError::Usage(m) if !m.contains("(try:") => {71+            CliError::Usage(format!("{m} (try: f0rge pr -h)"))72+        }73+        other => other,74+    }1675 }1776 1877 static TMP_CTR: AtomicU64 = AtomicU64::new(0);@@ -22,25 +81,51 @@ const BOOLS: &[&str] = &["json", "draft"];2281 2382 pub fn run(args: &[String], fg: &Forge) -> Result<(), CliError> {2483     let spec = spec_from_lists(VALS, BOOLS);25-    let fs = parse_flags(args, &spec)?;84+    let fs = parse_flags(args, &spec).map_err(|e| {85+        match guess_verb(args) {86+            Some(v) if KNOWN_VERBS.contains(&v.as_str()) => with_try_verb(e, &v),87+            _ => with_try_domain(e),88+        }89+    })?;90+    if fs.is("help") {91+        match guess_verb(args) {92+            Some(v) => {93+                if let Some(h) = verb_help(&v) {94+                    output::out(h);95+                    return Ok(());96+                }97+            }98+            None => {}99+        }100+    }26101     let (json, jq) = (fs.is("json"), fs.get("jq").map(|s| s.to_string()));27102     let p = &fs.positional;28103     if p.is_empty() {29104         return Err(CliError::Usage(30-            "pr <create|diff|review|comment|merge|close|list>".to_string(),105+            "pr <create|diff|review|comment|merge|close|list> (try: f0rge pr -h)".to_string(),31106         ));32107     }33-    match p[0].as_str() {108+    let r: Result<(), CliError> = match p[0].as_str() {34109         "create" => create(&fs, args, fg, json, jq.as_deref()),35110         "diff" => diff(p, fg),36111         "review" | "comment" => review(&fs, p, fg, json, jq.as_deref()),37112         "merge" => merge(p, fg, json, jq.as_deref()),38113         "close" => close(p, fg, json, jq.as_deref()),39114         "list" => list(&fs, fg, json, jq.as_deref()),40-        _ => Err(CliError::Usage(41-            "pr <create|diff|review|comment|merge|close|list>".to_string(),42-        )),43-    }115+        other => {116+            return Err(CliError::Usage(format!(117+                "unknown verb: {other} (try: f0rge pr -h)"118+            )))119+        }120+    };121+    r.map_err(|e| {122+        let v = p[0].clone();123+        if KNOWN_VERBS.contains(&v.as_str()) {124+            with_try_verb(e, &v)125+        } else {126+            with_try_domain(e)127+        }128+    })44129 }45130 46131 fn one_line(s: &str) -> String {@@ -176,7 +261,7 @@ fn create(176261     json: bool,177262     jq: Option<&str>,178263 ) -> Result<(), CliError> {179-    let usage = "pr create --base <branch> --head <branch|file>";264+    let usage = "f0rge pr create --base B --head H";180265     let base = fs.get("base").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;181266     let head = fs.get("head").filter(|s| !s.is_empty()).ok_or_else(|| CliError::Usage(usage.to_string()))?;182267     let repo = repo_dir(fg)?;@@ -548,3 +633,30 @@ fn list(fs: &FlagSet, fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), Cl548633     }549634     Ok(())550635 }636+637+#[cfg(test)]638+mod verb_help_tests {639+    use super::*;640+    fn budget(s: &str) {641+        assert!(s.lines().count() <= 8, "lines:\n{s}");642+        assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());643+    }644+    #[test]645+    fn per_verb_budgets_and_needles() {646+        for (v, needle) in [647+            ("create", "--base"),648+            ("create", "--head"),649+            ("diff", "diff <id|number>"),650+            ("review", "--body"),651+            ("comment", "--body"),652+            ("merge", "merge <id|number>"),653+            ("close", "close <id|number>"),654+            ("list", "--state"),655+        ] {656+            let h = verb_help(v).expect(v);657+            budget(h);658+            assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");659+        }660+        assert!(verb_help("frobnicate").is_none());661+    }662+}
crates/cli/src/cmd/release.rs+64 −7
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex d2372d2..e073629 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -23,6 +23,32 @@ fn usage(msg: &str) -> CliError {2323     CliError::Usage(msg.to_string())2424 }2525 26+// Per-verb helps next to parser arms (#36). Each <=250B/8ln.27+const CREATE_H: &str = "f0rge release create: notes\nusage: f0rge release create <ver> --notes <file|-> [--title S]\ncontract: f0rge --skill\n";28+const LIST_H: &str = "f0rge release list: all\nusage: f0rge release list\ncontract: f0rge --skill\n";29+const VIEW_H: &str = "f0rge release view: notes\nusage: f0rge release view <ver>\ncontract: f0rge --skill\n";30+const INSTALL_H: &str = "f0rge release install: setup\nusage: f0rge release install [ver|main] [--dry-run]\ncontract: f0rge --skill\n";31+32+/// Verb-depth help (#36). None when unknown.33+pub fn verb_help(verb: &str) -> Option<&'static str> {34+    Some(match verb {35+        "create" => CREATE_H,36+        "list" => LIST_H,37+        "view" => VIEW_H,38+        "install" => INSTALL_H,39+        _ => return None,40+    })41+}42+43+fn with_try(e: CliError, verb: &str) -> CliError {44+    match e {45+        CliError::Usage(m) if !m.contains("(try:") => {46+            CliError::Usage(format!("{m} (try: f0rge release {verb} --help)"))47+        }48+        other => other,49+    }50+}51+2652 fn clean(s: &str) -> String {2753     s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()2854 }@@ -606,28 +632,59 @@ fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), Cl606632 607633 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {608634     if args_in.is_empty() {609-        return Err(usage("f0rge release <create|list|view|install> [ver|main]"));635+        return Err(usage("f0rge release <create|list|view|install> [ver|main] (try: f0rge release -h)"));610636     }611637     let verb = clean(&args_in[0]);612638     if verb.is_empty() || verb.starts_with('-') || !["create", "list", "view", "install"].contains(&verb.as_str()) {613-        return Err(usage("f0rge release <create|list|view|install> [ver|main]"));639+        let shown = if verb.is_empty() { "<none>".to_string() } else { verb };640+        return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge release -h)")));614641     }615-    let fs = args::parse_flags(&args_in[1..], &spec())?;616-    match verb.as_str() {642+    let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;643+    if fs.is("help") {644+        if let Some(h) = verb_help(&verb) {645+            output::out(h);646+            return Ok(());647+        }648+    }649+    let r: Result<(), CliError> = match verb.as_str() {617650         "create" => create(&fs, fg),618651         "list" => list(&fs, fg),619652         "install" => {620653             if fs.positional.len() > 1 {621-                return Err(usage("f0rge release install [ver|main] [--dry-run]"));654+                return Err(with_try(usage("f0rge release install [ver|main] [--dry-run]"), &verb));622655             }623656             install(&fs, fg, fs.positional.first().cloned())624657         }625658         "view" => {626659             if fs.positional.len() != 1 {627-                return Err(usage("f0rge release view <ver>"));660+                return Err(with_try(usage("f0rge release view <ver>"), &verb));628661             }629662             view(&fs, fg, &fs.positional[0].clone())630663         }631-        _ => Err(usage("f0rge release <create|list|view> [ver]")),664+        _ => Err(usage("f0rge release <create|list|view> [ver] (try: f0rge release -h)")),665+    };666+    r.map_err(|e| with_try(e, &verb))667+}668+669+#[cfg(test)]670+mod verb_help_tests {671+    use super::*;672+    fn budget(s: &str) {673+        assert!(s.lines().count() <= 8, "lines:\n{s}");674+        assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());675+    }676+    #[test]677+    fn per_verb_budgets_and_needles() {678+        for (v, needle) in [679+            ("create", "--notes"),680+            ("list", "release list"),681+            ("view", "<ver>"),682+            ("install", "--dry-run"),683+        ] {684+            let h = verb_help(v).expect(v);685+            budget(h);686+            assert!(h.contains(needle), "{v} missing `{needle}`:\n{h}");687+        }688+        assert!(verb_help("frobnicate").is_none());632689     }633690 }
crates/cli/src/cmd/skill.rs+8 −2
diff --git a/crates/cli/src/cmd/skill.rs b/crates/cli/src/cmd/skill.rsindex dad2e9b..e1f4dfb 100644--- a/crates/cli/src/cmd/skill.rs+++ b/crates/cli/src/cmd/skill.rs@@ -24,6 +24,12 @@ usage: f0rge <cmd> [--json] [args]; init makes .forge/ idempotent; --jq via jq;2424 json: compact fixed order; empty []/{}\n\2525 install: f0rge release install [ver|main]\n";2626 27+/// No sub-verbs (#36). Stub for API uniformity.28+#[allow(dead_code)]29+pub fn verb_help(_verb: &str) -> Option<&'static str> {30+    None31+}32+2733 pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {2834     for a in args {2935         if a == "-h" || a == "--help" {@@ -31,11 +37,11 @@ pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {3137             return Ok(());3238         }3339         if a.starts_with('-') {34-            return Err(CliError::Usage(format!("unknown flag: {a}")));40+            return Err(CliError::Usage(format!("unknown flag: {a} (try: f0rge skill -h)")));3541         }3642     }3743     if !args.is_empty() {38-        return Err(CliError::Usage("skill takes no args".to_string()));44+        return Err(CliError::Usage("skill takes no args (try: f0rge skill -h)".to_string()));3945     }4046     crate::output::out(SKILL_TEXT);4147     Ok(())
crates/cli/src/cmd/wiki.rs+67 −11
diff --git a/crates/cli/src/cmd/wiki.rs b/crates/cli/src/cmd/wiki.rsindex 61d4d57..81befab 100644--- a/crates/cli/src/cmd/wiki.rs+++ b/crates/cli/src/cmd/wiki.rs@@ -8,7 +8,31 @@ use serde::Serialize;88 use std::path::{Component, Path};99 1010 pub fn help() -> &'static str {11-    "f0rge wiki: memory\nusage: f0rge wiki <verb> [--json] [flags]\nverbs: get put list\nindex derived: tree + first H1\ndocs: spec/schema.md §Wiki\ncontract: f0rge --skill\n"11+    "f0rge wiki: memory\nusage: f0rge wiki <verb> [--json] [flags]\nverbs: get put list\nput: <path> [stdin|-]; get: <path>\nindex: tree + first H1\ndocs: spec/schema.md §Wiki\ncontract: f0rge --skill\n"12+}13+14+// Per-verb helps next to parser arms (#36, #39). Each <=250B/8ln.15+const GET_H: &str = "f0rge wiki get: read\nusage: f0rge wiki get <path>\ncontract: f0rge --skill\n";16+const PUT_H: &str = "f0rge wiki put: write\nusage: f0rge wiki put <path> [-]\nstdin when omitted; - explicit\ncontract: f0rge --skill\n";17+const LIST_H: &str = "f0rge wiki list: index\nusage: f0rge wiki list\ncontract: f0rge --skill\n";18+19+/// Verb-depth help (#36).20+pub fn verb_help(verb: &str) -> Option<&'static str> {21+    Some(match verb {22+        "get" => GET_H,23+        "put" => PUT_H,24+        "list" => LIST_H,25+        _ => return None,26+    })27+}28+29+fn with_try(e: CliError, verb: &str) -> CliError {30+    match e {31+        CliError::Usage(m) if !m.contains("(try:") => {32+            CliError::Usage(format!("{m} (try: f0rge wiki {verb} --help)"))33+        }34+        other => other,35+    }1236 }1337 1438 fn spec() -> Vec<args::FlagSpec> {@@ -234,28 +258,60 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {234258 235259 pub fn run(args_in: &[String], fg: &Forge) -> Result<(), CliError> {236260     if args_in.is_empty() {237-        return Err(usage("f0rge wiki <get|put|list> <path>"));261+        return Err(usage("f0rge wiki <get|put|list> <path> (try: f0rge wiki -h)"));238262     }239263     let verb = clean(&args_in[0]);240264     if verb.is_empty() || verb.starts_with('-') || !["get", "put", "list"].contains(&verb.as_str()) {241-        return Err(usage("f0rge wiki <get|put|list> <path>"));265+        let shown = if verb.is_empty() { "<none>".to_string() } else { verb };266+        return Err(CliError::Usage(format!("unknown verb: {shown} (try: f0rge wiki -h)")));242267     }243-    let fs = args::parse_flags(&args_in[1..], &spec())?;244-    match verb.as_str() {268+    let fs = args::parse_flags(&args_in[1..], &spec()).map_err(|e| with_try(e, &verb))?;269+    if fs.is("help") {270+        if let Some(h) = verb_help(&verb) {271+            output::out(h);272+            return Ok(());273+        }274+    }275+    let r: Result<(), CliError> = match verb.as_str() {245276         "get" => {246277             if fs.positional.len() != 1 {247-                return Err(usage("f0rge wiki get <path>"));278+                return Err(with_try(usage("f0rge wiki get <path>"), &verb));248279             }249280             get(&fs, fg, &fs.positional[0].clone())250281         }251282         "put" => {252-            if fs.positional.len() != 2 {253-                return Err(usage("f0rge wiki put <path> -"));283+            // #39: bare stdin accepted; `-` stays explicit.284+            if fs.positional.len() == 1 {285+                let p = fs.positional[0].clone();286+                put(&fs, fg, &p, "-")287+            } else if fs.positional.len() == 2 {288+                let (p, s) = (fs.positional[0].clone(), fs.positional[1].clone());289+                put(&fs, fg, &p, &s)290+            } else {291+                return Err(with_try(usage("f0rge wiki put <path> [-]"), &verb));254292             }255-            let (p, s) = (fs.positional[0].clone(), fs.positional[1].clone());256-            put(&fs, fg, &p, &s)257293         }258294         "list" => list(&fs, fg),259-        _ => Err(usage("f0rge wiki <get|put|list> <path>")),295+        _ => Err(usage("f0rge wiki <get|put|list> <path> (try: f0rge wiki -h)")),296+    };297+    r.map_err(|e| with_try(e, &verb))298+}299+300+#[cfg(test)]301+mod verb_help_tests {302+    use super::*;303+    fn budget(s: &str) {304+        assert!(s.lines().count() <= 8, "lines:\n{s}");305+        assert!(s.len() <= 250, "bytes {}:\n{s}", s.len());306+    }307+    #[test]308+    fn per_verb_budgets_and_needles() {309+        let h = verb_help("put").expect("put");310+        budget(h);311+        assert!(h.contains("<path>"), "put path:\n{h}");312+        assert!(h.contains("stdin"), "put stdin:\n{h}");313+        budget(verb_help("get").expect("get"));314+        budget(verb_help("list").expect("list"));315+        assert!(verb_help("frobnicate").is_none());260316     }261317 }