merge a0545d59-caba-4be0-896b-81cf9b28715a
Message
merge a0545d59-caba-4be0-896b-81cf9b28715a
Diff
crates/cli/src/cmd/ci.rs+75 −12
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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 }