Skip to main content

ctx/harness/
mod.rs

1//! Harness packaging for `ctx harness` (Claude Code integration).
2//!
3//! This module scaffolds hook scripts, settings, skills, and plugin
4//! manifests from templates embedded in the binary ([`templates`]), stamps
5//! every generated file with a version header and checksum ([`checksum`]),
6//! guards generated hooks against version skew ([`compat`]), and diagnoses
7//! the whole integration ([`doctor`]).
8//!
9//! # Ownership model
10//!
11//! `init` records a checksum for every file it writes -- in the file itself
12//! (comment header) where the format allows comments, and always in the
13//! `.ctx/harness.lock` manifest. On re-run, each planned file is classified:
14//!
15//! - **Missing**: written.
16//! - **Owned, unmodified** (checksum matches): regenerated in place.
17//! - **Owned, modified** (checksum mismatch): warned about and skipped;
18//!   `--force` overwrites.
19//! - **Foreign** (exists but no ctx checksum anywhere): warned about and
20//!   skipped; `--force` overwrites.
21//!
22//! Exception: `.ctx/rules.toml` encodes *user* policy and is never
23//! overwritten once it exists, not even with `--force`.
24
25pub mod checksum;
26pub mod compat;
27pub mod doctor;
28pub mod templates;
29
30use std::collections::BTreeMap;
31use std::fs;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::{CtxError, Result};
37use checksum::{content_checksum, finalize, recorded_checksum, style_for_path};
38
39/// Relative path of the harness manifest.
40pub const LOCK_PATH: &str = ".ctx/harness.lock";
41
42/// Relative path of the starter rules file.
43pub const RULES_PATH: &str = ".ctx/rules.toml";
44
45/// Directory holding the local-mode hook scripts.
46pub const LOCAL_HOOKS_DIR: &str = ".claude/hooks/ctx";
47
48/// The three hook script basenames (shared by local and plugin modes).
49pub const HOOK_NAMES: [&str; 3] = ["session-start", "post-tool-use", "stop"];
50
51/// Harness targets (mirrors the CLI enum; only Claude Code for now).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Target {
54    Claude,
55}
56
57/// Scaffolding mode.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Mode {
60    Local,
61    Plugin,
62}
63
64/// One file `init` plans to write: final content (header + checksum already
65/// applied) plus write metadata.
66#[derive(Debug, Clone)]
67pub struct GeneratedFile {
68    /// Path relative to the project root, `/`-separated.
69    pub rel_path: String,
70    /// Final on-disk content.
71    pub content: String,
72    /// chmod 0o755 on unix.
73    pub executable: bool,
74    /// Never overwrite once the file exists (user policy files).
75    pub never_overwrite: bool,
76}
77
78/// What `write_plan` did with one planned file.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FileAction {
81    /// File did not exist and was written.
82    Created,
83    /// File was owned by ctx and unmodified; regenerated in place.
84    Regenerated,
85    /// File was modified or foreign, but `--force` overwrote it.
86    Overwritten,
87    /// File carried a ctx checksum that no longer matches (user-modified);
88    /// skipped.
89    SkippedModified,
90    /// File exists but was not generated by ctx; skipped.
91    SkippedForeign,
92    /// File is never overwritten by policy (`.ctx/rules.toml`); skipped.
93    SkippedPolicy,
94}
95
96impl FileAction {
97    /// Stable identifier used in JSON output.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            FileAction::Created => "created",
101            FileAction::Regenerated => "regenerated",
102            FileAction::Overwritten => "overwritten",
103            FileAction::SkippedModified => "skipped_modified",
104            FileAction::SkippedForeign => "skipped_foreign",
105            FileAction::SkippedPolicy => "skipped_policy",
106        }
107    }
108
109    /// True when the file was written to disk.
110    pub fn wrote(self) -> bool {
111        matches!(
112            self,
113            FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
114        )
115    }
116}
117
118// ============================================================================
119// Manifest (.ctx/harness.lock)
120// ============================================================================
121
122/// One manifest entry: the checksum and generator version of a file.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct LockEntry {
125    /// `sha256:<hex>` over the file content minus its `ctx:checksum` lines.
126    pub checksum: String,
127    /// ctx version that generated the file.
128    pub ctx_version: String,
129}
130
131/// The `.ctx/harness.lock` manifest: rel_path -> entry.
132#[derive(Debug, Default, Serialize, Deserialize)]
133pub struct LockFile {
134    #[serde(default = "default_lock_version")]
135    pub version: u32,
136    #[serde(default)]
137    pub files: BTreeMap<String, LockEntry>,
138}
139
140fn default_lock_version() -> u32 {
141    1
142}
143
144/// Read and parse the manifest; `None` when missing or unparseable
145/// (a broken manifest falls back to in-file checksum verification).
146pub fn read_lock(root: &Path) -> Option<LockFile> {
147    let content = fs::read_to_string(root.join(LOCK_PATH)).ok()?;
148    toml::from_str(&content).ok()
149}
150
151fn write_lock(root: &Path, lock: &LockFile) -> Result<()> {
152    let body = toml::to_string_pretty(lock)
153        .map_err(|e| CtxError::Other(format!("failed to serialize {LOCK_PATH}: {e}")))?;
154    let content = finalize(&body, checksum::HeaderStyle::Toml, templates::CTX_VERSION);
155    let path = root.join(LOCK_PATH);
156    if let Some(parent) = path.parent() {
157        fs::create_dir_all(parent)?;
158    }
159    fs::write(path, content)?;
160    Ok(())
161}
162
163// ============================================================================
164// Planning
165// ============================================================================
166
167fn generated(rel_path: &str, template: &str, vars: &[(&str, &str)]) -> GeneratedFile {
168    let rendered = templates::render(template, vars);
169    let content = finalize(&rendered, style_for_path(rel_path), templates::CTX_VERSION);
170    GeneratedFile {
171        rel_path: rel_path.to_string(),
172        content,
173        executable: rel_path.ends_with(".sh"),
174        never_overwrite: rel_path == RULES_PATH,
175    }
176}
177
178fn hook_files(dir: &str, vars: &[(&str, &str)]) -> Vec<GeneratedFile> {
179    vec![
180        generated(
181            &format!("{dir}/session-start.sh"),
182            templates::SESSION_START_SH,
183            vars,
184        ),
185        generated(
186            &format!("{dir}/post-tool-use.sh"),
187            templates::POST_TOOL_USE_SH,
188            vars,
189        ),
190        generated(&format!("{dir}/stop.sh"), templates::STOP_SH, vars),
191    ]
192}
193
194/// Plan the files for `--mode local`: hook scripts under
195/// `.claude/hooks/ctx/` plus a starter `.ctx/rules.toml`.
196///
197/// The settings snippet and CLAUDE.md block are *printed*, not written; see
198/// [`render_settings_snippet`] and [`render_claude_md_block`].
199pub fn plan_local(root: &Path) -> Vec<GeneratedFile> {
200    let branch = templates::default_branch(root);
201    let author = templates::author_name();
202    let vars = templates::standard_vars(&branch, &author);
203
204    let mut plan = hook_files(LOCAL_HOOKS_DIR, &vars);
205    plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
206    plan
207}
208
209/// Plan the files for `--mode plugin`: a full Claude Code plugin scaffold.
210///
211/// `.mcp.json` is included only when this binary was compiled with the
212/// `mcp` feature (release binaries are not); callers should explain how to
213/// enable it otherwise.
214pub fn plan_plugin(root: &Path) -> Vec<GeneratedFile> {
215    let branch = templates::default_branch(root);
216    let author = templates::author_name();
217    let vars = templates::standard_vars(&branch, &author);
218
219    let mut plan = vec![
220        generated(".claude-plugin/plugin.json", templates::PLUGIN_JSON, &vars),
221        generated(
222            ".claude-plugin/marketplace.json",
223            templates::MARKETPLACE_JSON,
224            &vars,
225        ),
226        generated("hooks/hooks.json", templates::HOOKS_JSON, &vars),
227    ];
228    plan.extend(hook_files("hooks", &vars));
229    plan.push(generated(
230        "settings.json",
231        templates::PLUGIN_SETTINGS_JSON,
232        &vars,
233    ));
234    plan.push(generated("skills/ctx/SKILL.md", templates::SKILL_MD, &vars));
235    plan.push(generated("README.md", templates::PLUGIN_README_MD, &vars));
236    if cfg!(feature = "mcp") {
237        plan.push(generated(".mcp.json", templates::MCP_JSON, &vars));
238    }
239    plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
240    plan
241}
242
243/// Render the settings snippet printed to stdout in local mode.
244pub fn render_settings_snippet() -> String {
245    templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
246}
247
248/// Render the CLAUDE.md guidance block printed to stdout in local mode.
249pub fn render_claude_md_block(root: &Path) -> String {
250    let branch = templates::default_branch(root);
251    templates::render(
252        templates::CLAUDE_MD_BLOCK_MD,
253        &[("DEFAULT_BRANCH", branch.as_str())],
254    )
255}
256
257// ============================================================================
258// Ownership + writing
259// ============================================================================
260
261/// How an existing (or missing) on-disk file relates to ctx's generation.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263enum Ownership {
264    Missing,
265    OwnedUnmodified,
266    OwnedModified,
267    Foreign,
268}
269
270/// Classify one on-disk file. Manifest entry wins; in-file `ctx:checksum`
271/// is the fallback; anything else that exists is foreign.
272fn classify(path: &Path, lock_entry: Option<&LockEntry>) -> Ownership {
273    if !path.exists() {
274        return Ownership::Missing;
275    }
276    let Ok(bytes) = fs::read(path) else {
277        // Unreadable: treat as foreign so we never clobber it silently.
278        return Ownership::Foreign;
279    };
280    let actual = content_checksum(&bytes);
281
282    if let Some(entry) = lock_entry {
283        let expected = entry
284            .checksum
285            .strip_prefix("sha256:")
286            .unwrap_or(&entry.checksum);
287        return if actual == expected {
288            Ownership::OwnedUnmodified
289        } else {
290            Ownership::OwnedModified
291        };
292    }
293
294    if let Ok(text) = std::str::from_utf8(&bytes) {
295        if let Some(recorded) = recorded_checksum(text) {
296            return if actual == recorded {
297                Ownership::OwnedUnmodified
298            } else {
299                Ownership::OwnedModified
300            };
301        }
302    }
303    Ownership::Foreign
304}
305
306fn write_file(root: &Path, file: &GeneratedFile) -> Result<()> {
307    let path = root.join(&file.rel_path);
308    if let Some(parent) = path.parent() {
309        fs::create_dir_all(parent)?;
310    }
311    fs::write(&path, &file.content)?;
312    #[cfg(unix)]
313    if file.executable {
314        use std::os::unix::fs::PermissionsExt;
315        fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
316    }
317    Ok(())
318}
319
320/// Write a plan to disk, honoring the ownership model, and update
321/// `.ctx/harness.lock`.
322///
323/// Returns one `(rel_path, action)` per planned file (plus the lock file
324/// itself). This function does not print; callers surface warnings for
325/// skipped files.
326pub fn write_plan(
327    root: &Path,
328    plan: &[GeneratedFile],
329    force: bool,
330) -> Result<Vec<(String, FileAction)>> {
331    let mut lock = read_lock(root).unwrap_or_default();
332    lock.version = 1;
333    let mut actions = Vec::with_capacity(plan.len() + 1);
334
335    for file in plan {
336        let path = root.join(&file.rel_path);
337        let ownership = classify(&path, lock.files.get(&file.rel_path));
338
339        let action = match ownership {
340            Ownership::Missing => FileAction::Created,
341            _ if file.never_overwrite => FileAction::SkippedPolicy,
342            Ownership::OwnedUnmodified => FileAction::Regenerated,
343            Ownership::OwnedModified if force => FileAction::Overwritten,
344            Ownership::OwnedModified => FileAction::SkippedModified,
345            Ownership::Foreign if force => FileAction::Overwritten,
346            Ownership::Foreign => FileAction::SkippedForeign,
347        };
348
349        if action.wrote() {
350            write_file(root, file)?;
351            lock.files.insert(
352                file.rel_path.clone(),
353                LockEntry {
354                    checksum: format!("sha256:{}", content_checksum(file.content.as_bytes())),
355                    ctx_version: templates::CTX_VERSION.to_string(),
356                },
357            );
358        }
359        actions.push((file.rel_path.clone(), action));
360    }
361
362    let lock_existed = root.join(LOCK_PATH).exists();
363    write_lock(root, &lock)?;
364    actions.push((
365        LOCK_PATH.to_string(),
366        if lock_existed {
367            FileAction::Regenerated
368        } else {
369            FileAction::Created
370        },
371    ));
372
373    Ok(actions)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use tempfile::TempDir;
380
381    fn plan_and_write(root: &Path, force: bool) -> Vec<(String, FileAction)> {
382        let plan = plan_local(root);
383        write_plan(root, &plan, force).unwrap()
384    }
385
386    fn action_for(actions: &[(String, FileAction)], rel: &str) -> FileAction {
387        actions
388            .iter()
389            .find(|(p, _)| p == rel)
390            .unwrap_or_else(|| panic!("no action for {rel}"))
391            .1
392    }
393
394    #[test]
395    fn test_no_residual_tokens_and_json_parses_in_both_modes() {
396        let temp = TempDir::new().unwrap();
397        for plan in [plan_local(temp.path()), plan_plugin(temp.path())] {
398            for file in &plan {
399                assert!(
400                    !file.content.contains("{{"),
401                    "unrendered token in {}: {}",
402                    file.rel_path,
403                    file.content
404                );
405                if file.rel_path.ends_with(".json") {
406                    serde_json::from_str::<serde_json::Value>(&file.content)
407                        .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", file.rel_path));
408                }
409            }
410        }
411        // The printed (not written) templates render clean too.
412        assert!(!render_settings_snippet().contains("{{"));
413        assert!(!render_claude_md_block(temp.path()).contains("{{"));
414        serde_json::from_str::<serde_json::Value>(&render_settings_snippet()).unwrap();
415    }
416
417    #[test]
418    fn test_plugin_manifest_fields_and_version() {
419        let temp = TempDir::new().unwrap();
420        let plan = plan_plugin(temp.path());
421        let plugin = plan
422            .iter()
423            .find(|f| f.rel_path == ".claude-plugin/plugin.json")
424            .unwrap();
425        let value: serde_json::Value = serde_json::from_str(&plugin.content).unwrap();
426        assert_eq!(value["name"], "ctx");
427        assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
428        assert!(value["description"].is_string());
429        assert!(value["author"]["name"].is_string());
430
431        // Permission block matches the spec (allow ctx, deny self-update and
432        // policy-file edits).
433        let settings = plan.iter().find(|f| f.rel_path == "settings.json").unwrap();
434        let value: serde_json::Value = serde_json::from_str(&settings.content).unwrap();
435        assert_eq!(
436            value["permissions"]["allow"],
437            serde_json::json!(["Bash(ctx *)"])
438        );
439        let deny = value["permissions"]["deny"].as_array().unwrap();
440        assert!(deny.contains(&serde_json::json!("Bash(ctx self-update*)")));
441        assert!(deny.contains(&serde_json::json!("Edit(.ctx/rules.toml)")));
442        assert!(deny.contains(&serde_json::json!("Edit(.claude/hooks/ctx/**)")));
443        assert!(deny.contains(&serde_json::json!("Edit(.claude/settings.json)")));
444    }
445
446    #[test]
447    fn test_headers_carry_crate_version() {
448        let temp = TempDir::new().unwrap();
449        for file in plan_local(temp.path()) {
450            assert!(
451                file.content
452                    .contains(&format!("generated by ctx v{}", env!("CARGO_PKG_VERSION"))),
453                "no version header in {}",
454                file.rel_path
455            );
456            assert!(
457                checksum::recorded_checksum(&file.content).is_some(),
458                "no checksum line in {}",
459                file.rel_path
460            );
461        }
462    }
463
464    #[test]
465    fn test_write_plan_ownership_lifecycle() {
466        let temp = TempDir::new().unwrap();
467        let root = temp.path();
468
469        // First run: everything created.
470        let actions = plan_and_write(root, false);
471        for (rel, action) in &actions {
472            assert_eq!(*action, FileAction::Created, "{rel}");
473        }
474
475        // Second run: unmodified owned files regenerate; rules.toml is
476        // policy-skipped.
477        let actions = plan_and_write(root, false);
478        assert_eq!(
479            action_for(&actions, ".claude/hooks/ctx/stop.sh"),
480            FileAction::Regenerated
481        );
482        assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
483
484        // Modify a hook: skipped without --force, content preserved.
485        let stop = root.join(".claude/hooks/ctx/stop.sh");
486        let modified = fs::read_to_string(&stop).unwrap() + "echo tampered\n";
487        fs::write(&stop, &modified).unwrap();
488        let actions = plan_and_write(root, false);
489        assert_eq!(
490            action_for(&actions, ".claude/hooks/ctx/stop.sh"),
491            FileAction::SkippedModified
492        );
493        assert_eq!(fs::read_to_string(&stop).unwrap(), modified);
494
495        // --force regenerates it; rules.toml still survives.
496        fs::write(root.join(RULES_PATH), "version = 1\n# mine\n").unwrap();
497        let actions = plan_and_write(root, true);
498        assert_eq!(
499            action_for(&actions, ".claude/hooks/ctx/stop.sh"),
500            FileAction::Overwritten
501        );
502        assert!(!fs::read_to_string(&stop).unwrap().contains("tampered"));
503        assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
504        assert_eq!(
505            fs::read_to_string(root.join(RULES_PATH)).unwrap(),
506            "version = 1\n# mine\n"
507        );
508    }
509
510    #[test]
511    fn test_foreign_file_is_skipped_without_force() {
512        let temp = TempDir::new().unwrap();
513        let root = temp.path();
514        let rel = ".claude/hooks/ctx/stop.sh";
515        let path = root.join(rel);
516        fs::create_dir_all(path.parent().unwrap()).unwrap();
517        fs::write(&path, "#!/bin/sh\necho my own hook\n").unwrap();
518
519        let actions = plan_and_write(root, false);
520        assert_eq!(action_for(&actions, rel), FileAction::SkippedForeign);
521        assert!(fs::read_to_string(&path).unwrap().contains("my own hook"));
522
523        let actions = plan_and_write(root, true);
524        assert_eq!(action_for(&actions, rel), FileAction::Overwritten);
525    }
526
527    #[test]
528    fn test_lock_tracks_json_files_in_plugin_mode() {
529        let temp = TempDir::new().unwrap();
530        let root = temp.path();
531        let plan = plan_plugin(root);
532        write_plan(root, &plan, false).unwrap();
533
534        let lock = read_lock(root).unwrap();
535        // JSON files have no in-file header; the lock must know them.
536        let entry = lock.files.get(".claude-plugin/plugin.json").unwrap();
537        assert!(entry.checksum.starts_with("sha256:"));
538        assert_eq!(entry.ctx_version, env!("CARGO_PKG_VERSION"));
539
540        // Detect a JSON tamper via the lock (no in-file checksum exists).
541        let manifest = root.join(".claude-plugin/plugin.json");
542        fs::write(&manifest, "{\"name\": \"evil\"}\n").unwrap();
543        let actions = write_plan(root, &plan, false).unwrap();
544        assert_eq!(
545            action_for(&actions, ".claude-plugin/plugin.json"),
546            FileAction::SkippedModified
547        );
548    }
549
550    #[cfg(unix)]
551    #[test]
552    fn test_hook_scripts_are_executable() {
553        use std::os::unix::fs::PermissionsExt;
554        let temp = TempDir::new().unwrap();
555        let root = temp.path();
556        plan_and_write(root, false);
557        let mode = fs::metadata(root.join(".claude/hooks/ctx/stop.sh"))
558            .unwrap()
559            .permissions()
560            .mode();
561        assert_eq!(mode & 0o111, 0o111, "mode: {:o}", mode);
562    }
563
564    #[test]
565    fn test_starter_rules_toml_parses_and_constrains_nothing() {
566        let temp = TempDir::new().unwrap();
567        let plan = plan_local(temp.path());
568        let rules = plan.iter().find(|f| f.rel_path == RULES_PATH).unwrap();
569        let parsed: crate::rules::RulesFile = toml::from_str(&rules.content).unwrap();
570        assert_eq!(parsed.version, 1);
571        assert!(parsed.layers.is_empty());
572        assert!(parsed.rules.forbidden.is_empty());
573        assert!(parsed.rules.allowed_dependents.is_empty());
574        assert!(parsed.rules.limit.is_empty());
575        assert!(parsed.rules.no_new_dependents.is_empty());
576    }
577}