mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-24 05:00:25 +08:00
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:
parent
7763ca3260
commit
611eed1537
@ -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();
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user