Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/macos/Sources/AutophagyKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
///
/// Corresponds to the highest immutable migration in
/// `crates/autophagy-store/migrations` at the time of writing.
public let knownSchemaVersion = 6
public let knownSchemaVersion = 7

/// How the opened database's schema relates to what this app understands.
public enum SchemaCompatibility: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct DatabaseReaderTests {
let temp = TempPath(FixtureDatabase.populated())
let reader = try DatabaseReader(path: temp.path)
#expect(reader.isAutophagyDatabase())
#expect(reader.schemaInfo().compatibility == .supported(version: 6))
#expect(reader.schemaInfo().compatibility == .supported(version: 7))
}

@Test func readsSessionsWithSourceMetadata() throws {
Expand Down
5 changes: 3 additions & 2 deletions apps/macos/Tests/AutophagyKitTests/FixtureDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ enum FixtureDatabase {
/// source, several sessions/events, and one challenged + one rejected
/// mutation candidate (with evidence links and transitions).
static func populated() -> String {
make(schemaVersion: 6) { db in
make(schemaVersion: 7) { db in
createSchema(db)
exec(db, """
INSERT INTO schema_migrations(version, description, checksum, applied_at) VALUES
(1,'initial',zeroblob(32),'2026-07-16T00:00:00Z'),
(6,'retrieval',zeroblob(32),'2026-07-16T00:00:00Z');
(6,'retrieval',zeroblob(32),'2026-07-16T00:00:00Z'),
(7,'claude_code_install',zeroblob(32),'2026-07-16T00:00:00Z');
""")
exec(db, """
INSERT INTO sources(source_id, adapter, instance_key, display_name,
Expand Down
50 changes: 39 additions & 11 deletions crates/autophagy-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ use autophagy_adapter_codex::{
use autophagy_core::{ImportOptions, ImportSummary, import_jsonl};
use autophagy_events::Event;
use autophagy_install::{
CodexSkillPlan, InstallError, InstalledArtifact, materialize, plan_codex_skill, unmaterialize,
InstallError, InstallTarget, InstalledArtifact, SkillPlan, materialize, plan_skill,
unmaterialize,
};
use autophagy_mutations::{GenerationOutcome, equivalence_key, generate_candidates};
use autophagy_patterns::{DetectorConfig, EvidencePacket, detect};
Expand Down Expand Up @@ -382,7 +383,7 @@ enum MutationAction {
#[arg(long, value_name = "PATH")]
observations: PathBuf,
},
/// Install one shadow-passed mutation as a repo-scoped Codex skill.
/// Install one shadow-passed mutation as a repo-scoped coding-agent skill.
Install {
/// Stable mutation identity.
mutation_id: String,
Expand All @@ -391,6 +392,10 @@ enum MutationAction {
#[arg(long, value_name = "PATH")]
repository: PathBuf,

/// Coding agent to materialize the skill for.
#[arg(long, value_enum, default_value_t = InstallTargetChoice::Codex)]
target: InstallTargetChoice,

/// Required phrase acknowledging the scoped filesystem write: `repo-skill-write`.
#[arg(long, value_name = "PHRASE")]
confirm_permissions: String,
Expand All @@ -399,13 +404,32 @@ enum MutationAction {
#[arg(long)]
dry_run: bool,
},
/// Remove an audited Codex skill and retire its mutation.
/// Remove an audited repo-scoped skill and retire its mutation.
Uninstall {
/// Stable mutation identity.
mutation_id: String,
},
}

/// Coding-agent installation target selectable on the command line.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)]
#[serde(rename_all = "kebab-case")]
enum InstallTargetChoice {
/// Codex repo-scoped skill under `.agents/skills`.
Codex,
/// Claude Code repo-scoped skill under `.claude/skills`.
ClaudeCode,
}

impl From<InstallTargetChoice> for InstallTarget {
fn from(choice: InstallTargetChoice) -> Self {
match choice {
InstallTargetChoice::Codex => Self::Codex,
InstallTargetChoice::ClaudeCode => Self::ClaudeCode,
}
}
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
enum SynthesisProviderChoice {
Expand Down Expand Up @@ -1090,6 +1114,7 @@ fn execute_mutation_action(
MutationAction::Install {
mutation_id,
repository,
target,
confirm_permissions,
dry_run,
} => {
Expand All @@ -1106,7 +1131,7 @@ fn execute_mutation_action(
.into());
}
let package = serde_json::from_value(details.mutation.package)?;
let plan = plan_codex_skill(&package, &repository)?;
let plan = plan_skill(&package, &repository, target.into())?;
if dry_run {
return Ok(CommandReport::MutationInstall(install_report(
&plan, true, false, None,
Expand All @@ -1116,7 +1141,7 @@ fn execute_mutation_action(
let registration = InstallationRegistration {
installation_id: plan.installation_id.clone(),
mutation_id: plan.mutation_id.clone(),
target: "codex_repo_skill".to_owned(),
target: plan.target.registry_id().to_owned(),
repository_root: plan.repository_root.to_string_lossy().into_owned(),
relative_path: portable_relative_path(&plan.relative_path),
content_hash: plan.content_hash.clone(),
Expand Down Expand Up @@ -1146,11 +1171,13 @@ fn execute_mutation_action(
let audit = store.get_installation(&mutation_id)?;
let details = store.get_mutation(&mutation_id)?;
let package = serde_json::from_value(details.mutation.package)?;
let plan = plan_codex_skill(&package, Path::new(&audit.repository_root))?;
let target = InstallTarget::from_registry_id(&audit.target)
.ok_or(CliError::InstallationAuditMismatch)?;
let plan = plan_skill(&package, Path::new(&audit.repository_root), target)?;
if plan.installation_id != audit.installation_id
|| portable_relative_path(&plan.relative_path) != audit.relative_path
|| plan.content_hash != audit.content_hash
|| audit.target != "codex_repo_skill"
|| plan.target.registry_id() != audit.target
{
return Err(CliError::InstallationAuditMismatch);
}
Expand All @@ -1176,14 +1203,14 @@ fn execute_mutation_action(
}

fn install_report(
plan: &CodexSkillPlan,
plan: &SkillPlan,
dry_run: bool,
materialized: bool,
transition: Option<InstallationTransitionOutcome>,
) -> MutationInstallReport {
MutationInstallReport {
installation_id: plan.installation_id.clone(),
target: "codex_repo_skill",
target: plan.target.registry_id(),
repository_root: plan.repository_root.to_string_lossy().into_owned(),
relative_path: portable_relative_path(&plan.relative_path),
content_hash: plan.content_hash.clone(),
Expand Down Expand Up @@ -1411,15 +1438,16 @@ fn write_report(
)?,
CommandReport::MutationInstall(report) => writeln!(
writer,
"{}\t{}\t{}\t{}",
"{}\t{}\t{}\t{}\t{}",
report.installation_id,
report.relative_path,
if report.dry_run {
"dry-run"
} else {
"installed"
},
report.content_hash
report.content_hash,
report.target
)?,
CommandReport::MutationUninstall(outcome) => writeln!(
writer,
Expand Down
144 changes: 144 additions & 0 deletions crates/autophagy-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,8 +531,36 @@ fn milestone_demo_digests_exports_deletes_and_prunes_offline() {
);
assert_eq!(preview_install["result"]["dry_run"], true);
assert_eq!(preview_install["result"]["materialized"], false);
assert_eq!(preview_install["result"]["target"], "codex_repo_skill");
assert!(!install_repository.join(".agents").exists());

// The `--target claude-code` selector plans a `.claude/skills` skill and
// reports the Claude Code target without writing anything on a dry run.
let claude_preview = run_json(
&database,
[
"mutations",
"install",
&failure_id,
"--repository",
install_repository.to_str().expect("UTF-8 path"),
"--target",
"claude-code",
"--confirm-permissions",
"repo-skill-write",
"--dry-run",
],
);
assert_eq!(claude_preview["result"]["target"], "claude_code_repo_skill");
assert!(
claude_preview["result"]["relative_path"]
.as_str()
.expect("relative path")
.starts_with(".claude/skills/")
);
assert_eq!(claude_preview["result"]["materialized"], false);
assert!(!install_repository.join(".claude").exists());

let installed = run_json(
&database,
[
Expand Down Expand Up @@ -752,6 +780,122 @@ fn import_redacts_secrets_excludes_paths_and_requires_delete_confirmation() {
assert_eq!(deleted["result"]["sessions_deleted"], 1);
}

#[test]
#[allow(clippy::too_many_lines)]
fn claude_code_install_and_uninstall_round_trip_through_cli() {
let directory = tempfile::tempdir().expect("temporary directory");
let database = directory.path().join("autophagy.db");
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../evals/fixtures/findings/deterministic.jsonl");
run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]);
run_json(&database, ["mutations", "propose"]);
let registry = run_json(&database, ["mutations", "list"]);
let failure_id = registry["result"]
.as_array()
.expect("registry")
.iter()
.find(|mutation| mutation["source_detector"] == "repeated_command_failure")
.expect("failure mutation")["mutation_id"]
.as_str()
.expect("mutation ID")
.to_owned();

run_json(
&database,
[
"mutations",
"challenge",
&failure_id,
"--check",
"coincidence-considered",
"--check",
"sessions-comparable",
"--check",
"trigger-observable",
"--check",
"legitimate-uses-bounded",
"--check",
"equivalent-searched",
"--check",
"counterexamples-reviewed",
],
);
let passing_replay = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../evals/fixtures/replay/command-preflight-pass.json");
run_json(
&database,
[
"mutations",
"replay",
&failure_id,
"--scenarios",
passing_replay.to_str().expect("UTF-8 path"),
],
);
let passing_shadow = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../evals/fixtures/shadow/command-preflight-pass.json");
run_json(
&database,
[
"mutations",
"shadow",
&failure_id,
"--observations",
passing_shadow.to_str().expect("UTF-8 path"),
],
);

let repository = directory.path().join("claude-target");
fs::create_dir(&repository).expect("repository");
fs::create_dir(repository.join(".git")).expect("git marker");

// Real (non-dry-run) install materializes the Claude Code skill.
let installed = run_json(
&database,
[
"mutations",
"install",
&failure_id,
"--repository",
repository.to_str().expect("UTF-8 path"),
"--target",
"claude-code",
"--confirm-permissions",
"repo-skill-write",
],
);
assert_eq!(installed["result"]["target"], "claude_code_repo_skill");
assert_eq!(installed["result"]["materialized"], true);
assert_eq!(
installed["result"]["transition"]["mutation_state"],
"active"
);
let installed_path = repository.join(
installed["result"]["relative_path"]
.as_str()
.expect("relative path"),
);
assert!(installed_path.is_file());
let body = fs::read_to_string(&installed_path).expect("installed skill");
assert!(body.contains("## Evidence"));
assert!(body.contains(&failure_id));

// Uninstall (no --target flag) reconstructs the materializer from the
// stored audit target and reverses cleanly.
let uninstalled = run_json(&database, ["mutations", "uninstall", &failure_id]);
assert_eq!(uninstalled["result"]["mutation_state"], "retired");
assert_eq!(uninstalled["result"]["installation_state"], "uninstalled");
assert!(!installed_path.exists());

// The retired installation audit retains the Claude Code target.
let shown = run_json(&database, ["mutations", "show", &failure_id]);
assert_eq!(
shown["result"]["installations"][0]["target"],
"claude_code_repo_skill"
);
assert_eq!(shown["result"]["installations"][0]["state"], "uninstalled");
}

fn run_json<const N: usize>(database: &Path, args: [&str; N]) -> Value {
let output = command(database)
.args(["--output", "json"])
Expand Down
Loading