merge bd48194a-c0bd-44c7-b13c-43cf486195d9
Message
merge bd48194a-c0bd-44c7-b13c-43cf486195d9
Diff
crates/cli/src/cmd/mod.rs+3 −1
--- 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
--- 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
--- 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
--- 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
--- 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
--- 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 {}