fix: scaffold safe init settings

Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
bellman
2026-06-04 15:34:15 +09:00
parent d8535bf938
commit 7dd17c6344
5 changed files with 173 additions and 21 deletions

View File

@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
const STARTER_CLAW_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
const STARTER_SETTINGS_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
pub(crate) enum InitStatus {
Created,
Updated,
Partial,
Deferred,
Skipped,
}
@@ -24,6 +33,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial (created missing sub-files)",
Self::Deferred => "deferred (created on first session save)",
Self::Skipped => "skipped (already exists)",
}
}
@@ -36,6 +47,8 @@ impl InitStatus {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Partial => "partial",
Self::Deferred => "deferred",
Self::Skipped => "skipped",
}
}
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
let mut artifacts = Vec::new();
let claw_dir = cwd.join(".claw");
let claw_dir_status = ensure_dir(&claw_dir)?;
let settings_json = claw_dir.join("settings.json");
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
let claw_dir_status =
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
InitStatus::Partial
} else {
claw_dir_status
};
artifacts.push(InitArtifact {
name: ".claw/",
status: ensure_dir(&claw_dir)?,
status: claw_dir_status,
});
artifacts.push(InitArtifact {
name: ".claw/settings.json",
status: settings_status,
});
artifacts.push(InitArtifact {
name: ".claw/sessions/",
status: if claw_dir.join("sessions").is_dir() {
InitStatus::Skipped
} else {
InitStatus::Deferred
},
});
let claw_json = cwd.join(".claw.json");
@@ -414,11 +448,26 @@ mod tests {
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"dontAsk\"\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert_eq!(
fs::read_to_string(root.join(".claw").join("settings.json"))
.expect("read project settings"),
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
assert!(
!root.join(".claw").join("sessions").exists(),
"sessions directory should be deferred until first session save"
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
@@ -436,14 +485,24 @@ mod tests {
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
let first = initialize_repo(&root).expect("first init should succeed");
assert!(first
.render()
.contains("CLAUDE.md skipped (already exists)"));
assert_eq!(
first.artifacts_with_status(InitStatus::Partial),
vec![".claw/".to_string()],
"existing .claw/ should report partial when init creates missing settings.json"
);
assert!(root.join(".claw").join("settings.json").is_file());
let second = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render();
assert!(second_rendered.contains(".claw/"));
assert!(second_rendered.contains(".claw/settings.json"));
assert!(second_rendered.contains(".claw/sessions/"));
assert!(second_rendered.contains(".claw.json"));
assert!(second_rendered.contains("skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
@@ -474,16 +533,22 @@ mod tests {
created_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
"fresh init should place created artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
assert_eq!(
fresh.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"fresh init should report session storage as deferred"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
@@ -491,27 +556,38 @@ mod tests {
skipped_names,
vec![
".claw/".to_string(),
".claw/settings.json".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
"idempotent init should place existing artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
assert_eq!(
second.artifacts_with_status(InitStatus::Deferred),
vec![".claw/sessions/".to_string()],
"idempotent init should keep session storage deferred until first save"
);
// 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);
assert_eq!(entries.len(), 6);
for entry in &entries {
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
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)'"
);
if name == ".claw/sessions/" {
assert_eq!(status, "deferred");
} else {
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");

View File

@@ -9011,8 +9011,8 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
.to_string(),
LocalHelpTopic::Init => "Init
Usage claw init [--output-format <format>]
Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project
Output list of created vs. skipped files (idempotent: safe to re-run)
Purpose create .claw/settings.json, .claw.json, .gitignore, and CLAUDE.md in the current project
Output per-artifact created/updated/partial/deferred/skipped status (idempotent: safe to re-run)
Formats text (default), json
Related claw status · claw doctor"
.to_string(),
@@ -9774,10 +9774,12 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
// Derive top-level status: "ok" when all artifacts succeeded (created or
// skipped = idempotent); no failure path exists today so always "ok".
let status = "ok";
// #783: already_initialized lets orchestrators detect the idempotent case
// without checking created.len() == 0; hint gives a stable next-action pointer.
// #783/#436: already_initialized lets orchestrators detect the idempotent
// case without checking every status bucket; deferred session storage does
// not make the workspace uninitialized because it is created on first save.
let already_initialized = report.artifacts_with_status(InitStatus::Created).is_empty()
&& report.artifacts_with_status(InitStatus::Updated).is_empty();
&& report.artifacts_with_status(InitStatus::Updated).is_empty()
&& report.artifacts_with_status(InitStatus::Partial).is_empty();
let hint = if already_initialized {
"Workspace already initialised. Run `claw doctor` to verify health, or edit CLAUDE.md to customise guidance."
} else {
@@ -9792,6 +9794,8 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"partial": report.artifacts_with_status(InitStatus::Partial),
"deferred": report.artifacts_with_status(InitStatus::Deferred),
"artifacts": report.artifact_json_entries(),
"hint": hint,
"next_step": crate::init::InitReport::NEXT_STEP,

View File

@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
use serde_json::Value;
use serde_json::{json, Value};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -3928,6 +3928,41 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
hint.contains("CLAUDE.md") || hint.contains("doctor"),
"fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}"
);
assert_eq!(
parsed["created"],
json!([
".claw/",
".claw/settings.json",
".claw.json",
".gitignore",
"CLAUDE.md"
]),
"fresh init should materialize .claw/settings.json and safe .claw.json"
);
assert_eq!(
parsed["deferred"],
json!([".claw/sessions/"]),
"session storage should be reported as deferred until first save"
);
assert_eq!(parsed["partial"], json!([]));
let claw_json = fs::read_to_string(root.join(".claw.json")).expect("read .claw.json");
assert!(
claw_json.contains("\"defaultMode\": \"acceptEdits\""),
"init must not scaffold dontAsk in .claw.json: {claw_json}"
);
assert!(
!claw_json.contains("dontAsk"),
"init must not scaffold unsafe dontAsk permission mode: {claw_json}"
);
let settings_json = root.join(".claw").join("settings.json");
assert!(
settings_json.is_file(),
"init should template .claw/settings.json"
);
assert!(
!root.join(".claw").join("sessions").exists(),
"sessions directory should remain deferred until first save"
);
// Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
@@ -3954,6 +3989,43 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
hint2.contains("already") || hint2.contains("doctor"),
"re-init hint should acknowledge workspace exists, got: {hint2:?}"
);
let existing_claw_root = unique_temp_dir("init-existing-claw-436");
fs::create_dir_all(existing_claw_root.join(".claw")).expect("existing .claw dir");
let partial_output = run_claw(
&existing_claw_root,
&["--output-format", "json", "init"],
&[],
);
assert!(
partial_output.status.success(),
"init with existing .claw should succeed"
);
let partial_stdout = String::from_utf8_lossy(&partial_output.stdout);
let partial: serde_json::Value =
serde_json::from_str(partial_stdout.trim()).expect("partial init should emit valid JSON");
assert_eq!(
partial["partial"],
json!([".claw/"]),
"existing .claw with newly-created settings should report partial .claw/"
);
assert_eq!(
partial["created"],
json!([
".claw/settings.json",
".claw.json",
".gitignore",
"CLAUDE.md"
]),
"init should still create missing sub-files when .claw already exists"
);
assert!(
existing_claw_root
.join(".claw")
.join("settings.json")
.is_file(),
"existing .claw must receive missing settings template"
);
}
#[test]