feat: #142 structured fields in claw init --output-format json

Previously `claw init --output-format json` emitted a valid JSON envelope but
packed the entire human-formatted output into a single `message` string. Claw
scripts had to substring-match human language to tell `created` from `skipped`.

Changes:
- Add InitStatus::json_tag() returning machine-stable "created"|"updated"|"skipped"
  (unlike label() which includes the human " (already exists)" suffix).
- Add InitReport::NEXT_STEP constant so claws can read the next-step hint
  without grepping the message string.
- Add InitReport::artifacts_with_status() to partition artifacts by state.
- Add InitReport::artifact_json_entries() for the structured artifacts[] array.
- Rewrite run_init + init_json_value to emit first-class fields alongside the
  legacy message string (kept for text consumers): project_path, created[],
  updated[], skipped[], artifacts[], next_step, message.
- Update the slash-command Init dispatch to use the same structured JSON.
- Add regression test artifacts_with_status_partitions_fresh_and_idempotent_runs
  asserting both fresh + idempotent runs produce the right partitioning and
  that the machine-stable tag is bare 'skipped' not label()'s phrasing.

Verified output:
- Fresh dir: created[] has 4 entries, skipped[] empty
- Idempotent call: created[] empty, skipped[] has 4 entries
- project_path, next_step as first-class keys
- message preserved verbatim for backward compat

Full workspace test green except pre-existing resume_latest flake (unrelated).

Closes ROADMAP #142.
This commit is contained in:
YeonGyu-Kim 2026-04-21 17:42:00 +09:00
parent 7763ca3260
commit 611eed1537
2 changed files with 120 additions and 6 deletions

View File

@ -27,6 +27,18 @@ impl InitStatus {
Self::Skipped => "skipped (already exists)",
}
}
/// Machine-stable identifier for structured output (#142).
/// Unlike `label()`, this never changes wording: claws can switch on
/// these values without brittle substring matching.
#[must_use]
pub(crate) fn json_tag(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -58,6 +70,36 @@ impl InitReport {
lines.push(" Next step Review and tailor the generated guidance".to_string());
lines.join("\n")
}
/// Summary constant that claws can embed in JSON output without having
/// to read it out of the human-formatted `message` string (#142).
pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance";
/// Artifact names that ended in the given status. Used to build the
/// structured `created[]`/`updated[]`/`skipped[]` arrays for #142.
#[must_use]
pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec<String> {
self.artifacts
.iter()
.filter(|artifact| artifact.status == status)
.map(|artifact| artifact.name.to_string())
.collect()
}
/// Structured artifact list for JSON output (#142). Each entry carries
/// `name` and machine-stable `status` tag.
#[must_use]
pub(crate) fn artifact_json_entries(&self) -> Vec<serde_json::Value> {
self.artifacts
.iter()
.map(|artifact| {
serde_json::json!({
"name": artifact.name,
"status": artifact.status.json_tag(),
})
})
.collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@ -333,7 +375,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{initialize_repo, render_init_claude_md};
use super::{initialize_repo, render_init_claude_md, InitStatus};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
@ -413,6 +455,63 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn artifacts_with_status_partitions_fresh_and_idempotent_runs() {
// #142: the structured JSON output needs to be able to partition
// artifacts into created/updated/skipped without substring matching
// the human-formatted `message` string.
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
let fresh = initialize_repo(&root).expect("fresh init should succeed");
let created_names = fresh.artifacts_with_status(InitStatus::Created);
assert_eq!(
created_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
assert_eq!(
skipped_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
// artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4);
for entry in &entries {
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
}
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
let root = temp_dir();

View File

@ -2948,11 +2948,15 @@ fn run_resume_command(
json: Some(render_memory_json()?),
}),
SlashCommand::Init => {
let message = init_claude_md()?;
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
let cwd = env::current_dir()?;
let report = crate::init::initialize_repo(&cwd)?;
let message = report.render();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&message)),
json: Some(init_json_value(&report, &message)),
})
}
SlashCommand::Diff => {
@ -5666,20 +5670,31 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
}
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let message = init_claude_md()?;
let cwd = env::current_dir()?;
let report = initialize_repo(&cwd)?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&init_json_value(&message))?
serde_json::to_string_pretty(&init_json_value(&report, &message))?
),
}
Ok(())
}
fn init_json_value(message: &str) -> serde_json::Value {
/// #142: emit first-class structured fields alongside the legacy `message`
/// string so claws can detect per-artifact state without substring matching.
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
use crate::init::InitStatus;
json!({
"kind": "init",
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"artifacts": report.artifact_json_entries(),
"next_step": crate::init::InitReport::NEXT_STEP,
"message": message,
})
}