merge bd48194a-c0bd-44c7-b13c-43cf486195d9

22f2e58gl0w

all commits

Message

merge bd48194a-c0bd-44c7-b13c-43cf486195d9

Diff

crates/cli/src/cmd/mod.rs+3 −1
diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rsindex 4ee4e6c..21def66 100644--- a/crates/cli/src/cmd/mod.rs+++ b/crates/cli/src/cmd/mod.rs@@ -19,11 +19,13 @@ use crate::args::Global;1919 use crate::CliError;2020 2121 /// Root help: <=12 lines, <=300 bytes. Whole surface from here.22+/// Command map only; contract lives in `f0rge --skill`.2223 pub const ROOT_HELP: &str = "f0rge: local forge\n\2324 usage: f0rge [--json|--jq E|--skill] <cmd> [args]\n\2425 issue pr task label milestone ci wiki\n\2526 release merge init skill\n\26-'cmd -h' for help\n";27+'cmd -h' for help\n\28+contract: f0rge --skill\n";2729 2830 /// Dispatch over subcommand names (mirror of folder tree).2931 pub fn dispatch(
crates/cli/src/cmd/skill.rs+6 −4
diff --git a/crates/cli/src/cmd/skill.rs b/crates/cli/src/cmd/skill.rsindex 2bda4a0..2974e70 100644--- a/crates/cli/src/cmd/skill.rs+++ b/crates/cli/src/cmd/skill.rs@@ -7,21 +7,23 @@ pub fn help() -> &'static str {77 }88 99 /// Contract text: <=30 lines, <=900 bytes. Schema v1 decisions only.10-pub const SKILL_TEXT: &str = "f0rge v1: local gh-like forge\n\10+/// SKILL_TEXT is the single source of truth; skill/skill.md body must equal it.11+pub const SKILL_TEXT: &str = "f0rge v1: CONTRACT (not help)\n\1112 id: uuid4 lowercase dashes kept; never changes\n\1213 number: rank in (created,uuid) order; derived never stored\n\1314 files: one file per entity; command tree mirrors folder tree\n\15+tree: issue<->issues/ pr<->prs/ task<->tasks/ ci<->ci/ wiki<->wiki/\n\1416 frontmatter: strict --- fences; unknown field warning; missing required error\n\1517 ci verdict: step exit code; pass|fail|running|cancelled\n\1618 never delete: close/archive only; archive moves folder\n\1719 wiki: pages are files under wiki/; edit in place or via stdin\n\18-usage: f0rge [--json] <cmd> [args]; --jq uses system jq\n\20+usage: f0rge [--json] <cmd> [args]; --jq uses system jq; never parse prose\n\1921 json: compact fixed order; empty is [] or {}\n";2022 2123 pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {2224     for a in args {2325         if a == "-h" || a == "--help" {24-            print!("{}", help());26+            crate::output::out(help());2527             return Ok(());2628         }2729         if a.starts_with('-') {@@ -31,6 +33,6 @@ pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {3133     if !args.is_empty() {3234         return Err(CliError::Usage("skill takes no args".to_string()));3335     }34-    print!("{SKILL_TEXT}");36+    crate::output::out(SKILL_TEXT);3537     Ok(())3638 }
crates/cli/src/main.rs+2 −2
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rsindex 3a4461d..a2b2b46 100644--- a/crates/cli/src/main.rs+++ b/crates/cli/src/main.rs@@ -67,8 +67,8 @@ fn run(argv: &[String]) -> Result<(), CliError> {6767         return Ok(());6868     }6969     let Some(sub) = g.sub.clone() else {70-        // no args, or only globals (-h/--help): root help to stdout.71-        print!("{}", crate::cmd::ROOT_HELP);70+        // no args, or only globals (-h/--help): root help to stdout (EPIPE-safe).71+        output::out(crate::cmd::ROOT_HELP);7272         return Ok(());7373     };7474     // resolve forge root: cwd (init handles its own dir).
crates/cli/src/output.rs+24 −6
diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rsindex 0de679e..7ded550 100644--- a/crates/cli/src/output.rs+++ b/crates/cli/src/output.rs@@ -8,6 +8,24 @@ use serde::Serialize;88 use std::io::IsTerminal;99 use std::process::{Command, Stdio};1010 11+/// Safe stdout write: BrokenPipe is clean end-of-output (exit 0, no panic/trace).12+/// All root-path prints must go through here. Errors stay on stderr.13+pub fn out(s: &str) {14+    use std::io::Write;15+    let r = (|| -> std::io::Result<()> {16+        let mut o = std::io::stdout().lock();17+        o.write_all(s.as_bytes())?;18+        o.flush()?;19+        Ok(())20+    })();21+    if let Err(e) = r {22+        if e.kind() == std::io::ErrorKind::BrokenPipe {23+            std::process::exit(0);24+        }25+        // Other stdout errors: no panic, no trace.26+    }27+}28+1129 /// Compact one-line JSON.1230 pub fn json_line<T: Serialize>(v: &T) -> String {1331     serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string())@@ -17,13 +35,13 @@ pub fn json_line<T: Serialize>(v: &T) -> String {1735 pub fn emit_str(json: &str, jq: Option<&str>) {1836     match jq {1937         Some(expr) => match run_jq(json, expr) {20-            Ok(out) => print!("{out}"),38+            Ok(s) => out(&s),2139             Err(note) => {2240                 eprintln!("{note}");23-                println!("{json}");41+                out(&format!("{json}\n"));2442             }2543         },26-        None => println!("{json}"),44+        None => out(&format!("{json}\n")),2745     }2846 }2947 @@ -41,7 +59,7 @@ pub fn emit_bulk(lines: &str, jq: Option<&str>) {4159                 emit_str(line, jq);4260             }4361         }44-        None => print!("{lines}"),62+        None => out(lines),4563     }4664 }4765 @@ -70,9 +88,9 @@ pub fn emit_text(text: &str, jq: Option<&str>) {7088     if jq.is_some() && (t.starts_with('{') || t.starts_with('[')) {7189         emit_str(t, jq);7290     } else {73-        print!("{text}");91+        out(text);7492         if !text.ends_with('\n') {75-            println!();93+            out("\n");7694         }7795     }7896 }
crates/cli/tests/cli.rs+21 −0
diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rsindex 9533094..8dcaa4c 100644--- a/crates/cli/tests/cli.rs+++ b/crates/cli/tests/cli.rs@@ -59,6 +59,27 @@ fn skill_within_budget() {5959     }6060 }6161 62+#[test]63+fn skill_single_source_body_equals_skill_text() {64+    let (out, _, code) = run(&["--skill"]);65+    assert_eq!(code, 0);66+    let md = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../skill/skill.md"))67+        .expect("read skill/skill.md");68+    let mut parts = md.splitn(3, "---\n");69+    let _ = parts.next().expect("leading fence");70+    let fm = parts.next().expect("frontmatter");71+    let body = parts.next().expect("body");72+    assert!(fm.contains("name:"), "frontmatter name missing");73+    assert!(fm.contains("description:"), "frontmatter description missing");74+    assert_eq!(body, out, "skill.md body != --skill output");75+    let (root, _, _) = run(&[]);76+    let root_first = root.lines().next().unwrap_or("");77+    let skill_first = out.lines().next().unwrap_or("");78+    assert!(skill_first.contains("CONTRACT"), "contract must self-identify: {skill_first:?}");79+    assert_ne!(root_first, skill_first, "openings confusable");80+    assert!(root.contains("contract: f0rge --skill"), "root must point at contract");81+}82+6283 #[test]6384 fn init_creates_skeleton_idempotent() {6485     let dir = std::env::temp_dir().join(format!("f0rge-cli-init-{}-{}", std::process::id(), line!()));
skill/skill.md+11 −15
diff --git a/skill/skill.md b/skill/skill.mdindex 23247e7..237df15 100644--- a/skill/skill.md+++ b/skill/skill.md@@ -2,18 +2,14 @@22 name: f0rge33 description: local gh-like forge under .forge/44 ---5-# f0rge6-f0rge is gh, local:7-same commands, flags, --json fields;8-reads/writes .forge/ files, not api.github.com.9-tree mirrors folders:10-  issue <-> issues/  pr <-> prs/11-  task  <-> tasks/   ci <-> ci/12-  wiki  <-> wiki/    release <-> releases/13-wiki pages are .forge/wiki/ files:14-edit in place (or wr1te) or `wiki put <path> -`.15-discover: run `f0rge`; `cmd -h` for verbs.16-never parse prose; always use --json.17-`f0rge --skill` prints the contract.18-empty is [] or {}; errors: 1 line + code.19-close/archive only; never delete.5+f0rge v1: CONTRACT (not help)6+id: uuid4 lowercase dashes kept; never changes7+number: rank in (created,uuid) order; derived never stored8+files: one file per entity; command tree mirrors folder tree9+tree: issue<->issues/ pr<->prs/ task<->tasks/ ci<->ci/ wiki<->wiki/10+frontmatter: strict --- fences; unknown field warning; missing required error11+ci verdict: step exit code; pass|fail|running|cancelled12+never delete: close/archive only; archive moves folder13+wiki: pages are files under wiki/; edit in place or via stdin14+usage: f0rge [--json] <cmd> [args]; --jq uses system jq; never parse prose15+json: compact fixed order; empty is [] or {}