diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index fda114a7..ddfcc1e0 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). ## Product boundary @@ -182,7 +182,7 @@ Use `hack` as the primary interface for local-first development. ## Agent integration maintenance - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index f54cebcb..b339999a 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). ## Product boundary @@ -77,7 +77,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Agent integration maintenance - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/docs/cli.md b/docs/cli.md index 67846089..f085919a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -131,7 +131,9 @@ the Hack CLI version that generated them. Plain `hack agent prime` prints curren an integration scan. Use `hack agent prime --check` for an explicit project/global inventory or a targeted `hack setup ... --check` when diagnosing drift. Missing optional integrations do not require installation. Repair only affected targets within the authorized scope; use -`hack setup sync --all-scopes` for an explicitly requested full refresh. Read updated guidance after +`hack setup sync --all-scopes` for an explicitly requested full refresh. Sync refreshes only installed +Hack-owned integrations; add `--install-missing` to create missing ones (see +[integration setup](integrations.md#refresh-only-hack-setup-sync)). Read updated guidance after repair; restart only if the client cannot reload changed hooks or skills. Ordinary commands, `hack update`, and `hack doctor --fix` never render, repair, remove, or otherwise mutate integration files. CLI upgrades do not trigger integration updates. diff --git a/docs/integrations.md b/docs/integrations.md index b29bce16..e2d8b35b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -12,9 +12,10 @@ What remains: export commands `hack setup sync` keeps interactive output compact: it summarizes each scope and only expands the -path and reason for stale, missing, or failed artifacts. Exit status remains the automation contract, -and the individual `hack setup cursor|claude|codex|agents|mcp --check` commands remain available when -you need per-artifact detail. +path and reason for preserved or failed artifacts (and, with `--check`, the ones sync would change). +Exit status remains the automation contract, `--json` lists every target, and the individual +`hack setup cursor|claude|codex|agents|mcp --check` commands remain available when you need +per-client detail. What does not ship: Hack Tickets, built-in GitHub or Linear integrations, hosted auth/account/org/team surfaces, and the web dashboard control plane. @@ -48,7 +49,38 @@ files Hack owns: the current render or an unedited render from an earlier releas unrecognized `SKILL.md` or `hack.mdc` is reported as `preserved` with a reason and left unchanged; move or delete it yourself to reinstall. Removal deletes only the owned `SKILL.md` and removes its skill directory only when nothing else is in it. Claude hook removal drops only the -`hack agent prime` entries and keeps other commands. Preserved files do not change the exit status. +`hack agent prime` entries and keeps other commands. Preserved files do not change the exit status +of an install, sync, or remove. + +### Refresh-only `hack setup sync` + +`hack setup sync` refreshes only integrations that are already installed and Hack-owned. It never +creates a missing one. Per target, in the selected scope (project by default, `--global`, or +`--all-scopes`): + +| Status | Meaning | +| --- | --- | +| `refreshed` | An installed Hack-owned target was rewritten to the current render | +| `unchanged` | The target already matches the current render | +| `preserved` | The file is customized or unrecognized; it is left byte-identical and the closed reason is reported | +| `missing` | The target is not installed; sync skipped it | +| `installed` | Created only with `--install-missing` | +| `removed` | A recognized retired Hack artifact was deleted (or, with `--remove`, a Hack-owned target) | +| `error` | The target could not be read or parsed; it is left unchanged | + +Hack-owned means: a current or known earlier render of a standalone skill or rule file; the +marker-delimited section of `AGENTS.md`/`CLAUDE.md`; the `hack agent prime` entries of the Claude +settings file; and the `hack` entry of an MCP config. A doc, settings file, or MCP config without +the Hack section counts as `missing`, so the file is not modified. Existing MCP entries are +never replaced. + +Add `--install-missing` to also create missing integrations in the selected scope; customized +files stay `preserved`. `--check` is read-only: it reports what sync would do with the same flags +and exits non-zero when sync would refresh, install, or remove something, a file is preserved, +or a target errors. A missing target fails `--check` only together with `--install-missing`. +`--json` prints `{ok, data: {action, dryRun, installMissing, counts, targets}}`, where each +target carries `scope`, `target`, `path`, `status`, and, when preserved, `reason`. Messages are +fixed text and never include file contents. ## Standalone MCP configuration diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5fe0d631..c01102a9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1801,7 +1801,7 @@ hack setup [options] | `hack setup claude` | Install Claude Code hooks for hack CLI usage | | `hack setup codex` | Install Codex skill for hack CLI usage | | `hack setup agents` | Install AGENTS.md / CLAUDE.md snippets for hack CLI usage | -| `hack setup sync` | Refresh project/global agent guidance | +| `hack setup sync` | Refresh installed Hack-owned agent integrations | | `hack setup mcp` | Install MCP configs for hack CLI usage (no-shell only) | ### Options @@ -1924,7 +1924,7 @@ hack setup agents [options] ## `hack setup sync` -Refresh project/global agent guidance +Refresh installed Hack-owned agent integrations ### Usage @@ -1939,8 +1939,10 @@ hack setup sync [options] | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--global` | Use global (user) scope instead of project scope | | `--all-scopes` | Target both project and global (user) scopes | +| `--install-missing` | Also create missing integrations in the selected scope (default: refresh only installed Hack-owned files) | | `--check` | Check whether integration is installed | | `--remove` | Remove integration files/config | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index ab260e74..8a8ea6e3 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -154,7 +154,7 @@ Daemon (optional): Agent integration maintenance: - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index ab260e74..8a8ea6e3 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -154,7 +154,7 @@ Daemon (optional): Agent integration maintenance: - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 7baca1bd..43aed6e8 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). ## Product boundary @@ -82,7 +82,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Agent integration maintenance - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md index 64db260b..4bcddcf2 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -35,4 +35,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index de90cab1..ccb5b832 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). ## Product boundary @@ -184,7 +184,7 @@ Use `hack` as the primary interface for local-first development. ## Agent integration maintenance - Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files. -- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh. +- Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`. - Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations. - CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills. diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index df1935b2..1ebb43bf 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index a3e5a3a7..4eb918b6 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `5c5c3c9fffea` (version alone is not a freshness guarantee). +- Content revision: `5108f34edd26` (version alone is not a freshness guarantee). diff --git a/src/agents/claude.ts b/src/agents/claude.ts index ca401883..96813ea0 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -241,6 +241,46 @@ export async function removeClaudeHooks(opts: { return preserved ?? { scope: opts.scope, status: "removed", path }; } +/** + * Coverage of the Hack primer hooks in a parsed Claude settings object: + * `none` (no Hack primer entry), `partial` (some events), or `complete`. + */ +export function inspectClaudePrimerHooks({ + settings, +}: { + readonly settings: Record; +}): "none" | "partial" | "complete" { + const hooks = resolveHooksMap(settings); + const present = HOOK_EVENTS.filter((event) => + eventHasCommand({ + eventHooks: resolveEventHooks({ hooks, event }), + command: HOOK_COMMAND, + }) + ).length; + if (present === 0) { + return "none"; + } + return present === HOOK_EVENTS.length ? "complete" : "partial"; +} + +/** Add any missing Hack primer hook entries in place; returns whether it changed. */ +export function ensureClaudePrimerHooks({ + settings, +}: { + readonly settings: Record; +}): boolean { + return upsertHooks({ settings, command: HOOK_COMMAND }); +} + +/** Remove only the Hack primer hook entries in place; returns whether it changed. */ +export function stripClaudePrimerHooks({ + settings, +}: { + readonly settings: Record; +}): boolean { + return removeHooks({ settings, command: HOOK_COMMAND }); +} + function resolveClaudeSettingsPath(opts: { readonly scope: ClaudeScope; readonly projectRoot?: string; diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 2e54c8b2..82b4f7a1 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -344,7 +344,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ surfaces: ALL_SURFACES, bullets: [ "Ordinary Hack commands, `hack update`, `hack doctor --fix`, and primer hooks never install plugins or mutate agent integration files.", - "Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh.", + "Use the matching `hack setup` command only for the affected client and authorized project scope; add `--global` only when user-scope changes are authorized. Use `--check` for read-only inspection. Reserve `hack setup sync --all-scopes` for an explicitly requested full refresh; it refreshes only installed Hack-owned files and creates missing integrations only with `--install-missing`.", "Standalone setup and optional native plugins are alternative integration paths. A missing optional integration does not require installation. Verify the replacement loads before explicitly removing duplicate Hack-owned entries; preserve customizations.", "CLI upgrades do not authorize integration changes. Edit guidance at its generator source and render the affected targets; restart only if the client cannot reload the changed hooks or skills.", ], diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index c098d5d9..54f80806 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -107,11 +107,13 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "6449442a8f230412d4e5f9e7", // v5.0.0-next.4..next "c60f1fc7b1f6579fac892a54", // next: plugin-first agent setup (hack-mcp split) "d83da046cf7541ba30950880", // v5.0.0-next.4..next (offline env-matrix pointer) + "08f0db2d0a1889e5b3ef707f", // v5.0.0-next.4..next (refresh-only setup sync) "257e3019713e8358981fd1af", // next: plugin-first agent setup + offline env-matrix pointer "e620e980660f0443df7732fe", // next: focused plugin skills (HACK-1131) "c75c5f5eb9fdcf6842ed1eda", // v5.0.0-next.4..next (--value-stdin secret entry) "680a52ef6737d979ed2d384b", // next: focused plugin skills + --value-stdin "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup + "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -141,11 +143,13 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "cf0a31e197f9034485413674", // v5.0.0-next.4..next "77f4258cf2570443aee021fd", // next: plugin-first agent setup (hack-mcp split) "3a99c9097855f93808e482cb", // v5.0.0-next.4..next (offline env-matrix pointer) + "19099625646d196aa0e8a0c0", // v5.0.0-next.4..next (refresh-only setup sync) "d70a44aea4651314956b48f2", // next: plugin-first agent setup + offline env-matrix pointer "ea46c0d85989de79dba95fd4", // next: focused plugin skills (HACK-1131) "29499060cad876931810b458", // v5.0.0-next.4..next (--value-stdin secret entry) "afa91b84e719bd416b9dfea3", // next: focused plugin skills + --value-stdin "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup + "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync ]), }; @@ -245,7 +249,7 @@ export function describePreservedReason({ action, }: { readonly reason: IntegrationPreservedReason; - readonly action: "install" | "remove"; + readonly action: "install" | "remove" | "sync"; }): string { const cause: Readonly> = { "customized-content": @@ -254,10 +258,13 @@ export function describePreservedReason({ "not-a-regular-file": "it is not a regular file", unreadable: "it could not be read", }; - const next = - action === "install" - ? "Move or delete it, then rerun setup to install the current version." - : "Delete it yourself if it is no longer needed."; + const nextSteps: Readonly> = { + install: + "Move or delete it, then rerun setup to install the current version.", + sync: "Move or delete it, then run `hack setup sync --install-missing` or the client's `hack setup` command to install the current version.", + remove: "Delete it yourself if it is no longer needed.", + }; + const next = nextSteps[action]; return `Hack left this path unchanged because ${cause[reason]}. ${next}`; } diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 7bd8a0ed..6915c78d 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "5c5c3c9fffea"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "5108f34edd26"; diff --git a/src/agents/setup-sync.ts b/src/agents/setup-sync.ts new file mode 100644 index 00000000..5de2430b --- /dev/null +++ b/src/agents/setup-sync.ts @@ -0,0 +1,762 @@ +import { dirname, resolve } from "node:path"; + +import { + ensureDir, + readOptionalTextFile, + writeTextFileIfChanged, +} from "../lib/fs.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + checkAgentDocs, + removeAgentDocs, + upsertAgentDocs, +} from "../mcp/agent-docs.ts"; +import { + checkMcpConfig, + installMcpConfig, + type McpTarget, + removeMcpConfig, +} from "../mcp/install.ts"; +import { + ensureClaudePrimerHooks, + inspectClaudePrimerHooks, + stripClaudePrimerHooks, +} from "./claude.ts"; +import { renderCodexSkill } from "./codex-skill.ts"; +import { renderCursorRules } from "./cursor.ts"; +import { renderHackInitSkill } from "./hack-init-skill.ts"; +import { normalizeInstructionText } from "./instruction-source.ts"; +import { + classifyIntegrationFile, + describePreservedReason, + type IntegrationPreservedReason, + type IntegrationSurface, + installOwnedIntegrationFile, + removeOwnedIntegrationFile, +} from "./integration-ownership.ts"; +import { + checkLegacyProjectAgentArtifacts, + checkLegacyUserAgentArtifacts, + type LegacyAgentArtifactResult, + removeLegacyProjectAgentArtifacts, + removeLegacyUserAgentArtifacts, +} from "./legacy-artifacts.ts"; + +/** + * Target-level engine for `hack setup sync` (HACK-1132). + * + * Contract: + * - `sync` is refresh-only by default. It rewrites a target only when the + * target already exists and Hack owns it (a current or known prior render + * for standalone files, the marker-delimited section for agent docs, the + * Hack primer entries for Claude hooks). A missing target is reported as + * `missing` and is never created. + * - `installMissing` opts in to creating missing targets in the selected + * scope. It never overrides the ownership gate. + * - A customized or unrecognized standalone file is `preserved` with a + * closed, value-free reason and left byte-identical. + * - `check` computes the outcome `sync` would report and writes nothing. + * - `remove` deletes only Hack-owned content. + * + * Every path derives from the explicit `projectRoot` and `home` inputs; this + * module never reads `HOME` itself. Diagnostics are closed strings that carry + * no file content or caller input. Classification and the following write + * are not atomic (see `integration-ownership.ts`). + */ + +export type SetupSyncAction = "sync" | "check" | "remove"; + +export type SetupSyncScope = "project" | "global"; + +export type SetupSyncTarget = + | "cursor-rules" + | "claude-hooks" + | "claude-hack-init-skill" + | "codex-hack-cli-skill" + | "codex-hack-init-skill" + | "shared-hack-cli-skill" + | "cursor-mcp" + | "claude-mcp" + | "codex-mcp" + | "agents-md" + | "claude-md" + | "retired-artifact" + | "scope-root"; + +/** + * Per-target outcome. With `check`, the status is what `sync` would report. + * `missing` means skipped: the target does not exist and was not created. + */ +export type SetupSyncStatus = + | "refreshed" + | "unchanged" + | "installed" + | "preserved" + | "missing" + | "removed" + | "error"; + +export type SetupSyncTargetResult = { + readonly scope: SetupSyncScope; + readonly target: SetupSyncTarget; + readonly path: string; + readonly status: SetupSyncStatus; + /** Set with `preserved`: why Hack left the file unchanged. */ + readonly reason?: IntegrationPreservedReason; + readonly message?: string; +}; + +type Outcome = Omit; + +type OwnedFileTarget = { + readonly target: SetupSyncTarget; + readonly surface: IntegrationSurface; + readonly relativePath: string; + readonly render: () => string; + readonly removeEmptyDirectory: boolean; +}; + +const PROJECT_OWNED_FILES: readonly OwnedFileTarget[] = [ + { + target: "cursor-rules", + surface: "cursor-rules", + relativePath: ".cursor/rules/hack.mdc", + render: renderCursorRules, + removeEmptyDirectory: false, + }, + { + target: "claude-hack-init-skill", + surface: "hack-init-skill", + relativePath: ".claude/skills/hack-init/SKILL.md", + render: renderHackInitSkill, + removeEmptyDirectory: true, + }, + { + target: "codex-hack-cli-skill", + surface: "hack-cli-skill", + relativePath: ".codex/skills/hack-cli/SKILL.md", + render: renderCodexSkill, + removeEmptyDirectory: true, + }, + { + target: "codex-hack-init-skill", + surface: "hack-init-skill", + relativePath: ".codex/skills/hack-init/SKILL.md", + render: renderHackInitSkill, + removeEmptyDirectory: true, + }, +]; + +const GLOBAL_OWNED_FILES: readonly OwnedFileTarget[] = [ + ...PROJECT_OWNED_FILES, + { + target: "shared-hack-cli-skill", + surface: "hack-cli-skill", + relativePath: ".ai/skills/hack-cli/SKILL.md", + render: renderCodexSkill, + removeEmptyDirectory: true, + }, +]; + +const CLAUDE_SETTINGS_PATHS: Readonly> = { + project: ".claude/settings.local.json", + global: ".claude/settings.json", +}; + +const MCP_TARGETS: readonly { + readonly target: SetupSyncTarget; + readonly client: McpTarget; +}[] = [ + { target: "cursor-mcp", client: "cursor" }, + { target: "claude-mcp", client: "claude" }, + { target: "codex-mcp", client: "codex" }, +]; + +const DOC_TARGETS: readonly { + readonly target: SetupSyncTarget; + readonly doc: "agents" | "claude"; +}[] = [ + { target: "agents-md", doc: "agents" }, + { target: "claude-md", doc: "claude" }, +]; + +/** + * Run one sync action for one scope. `root` is the project root for the + * project scope and the home directory for the global scope; an empty root + * yields one `error` result instead of touching any path. + */ +export async function runSetupSyncScope(opts: { + readonly action: SetupSyncAction; + readonly scope: SetupSyncScope; + readonly root: string; + readonly installMissing: boolean; +}): Promise { + const root = opts.root.trim(); + if (!root) { + return [ + { + scope: opts.scope, + target: "scope-root", + path: "", + status: "error", + message: + opts.scope === "global" + ? "HOME is not set; cannot resolve global integration paths." + : "Project root could not be resolved.", + }, + ]; + } + + const context: ScopeContext = { + action: opts.action, + scope: opts.scope, + root, + installMissing: opts.action === "sync" ? opts.installMissing : false, + planInstall: opts.action === "check" && opts.installMissing, + }; + const ownedFiles = + opts.scope === "global" ? GLOBAL_OWNED_FILES : PROJECT_OWNED_FILES; + + const results: SetupSyncTargetResult[] = []; + for (const file of ownedFiles) { + results.push(await runOwnedFileTarget({ context, file })); + } + results.push(await runClaudeHooksTarget({ context })); + results.push(...(await runRetiredArtifacts({ context }))); + for (const mcp of MCP_TARGETS) { + results.push(await runMcpTarget({ context, ...mcp })); + } + if (opts.scope === "project") { + for (const doc of DOC_TARGETS) { + results.push(await runAgentDocTarget({ context, ...doc })); + } + } + return results; +} + +/** + * Exit status for a sync action. `sync` and `remove` fail only on errors. + * `check` fails when `sync` with the same flags would change something, a + * file is preserved (its content is not Hack's current render), or a target + * could not be checked. A missing target is not a check failure unless + * `--install-missing` was requested, in which case it reports `installed`. + */ +export function setupSyncExitCode(opts: { + readonly action: SetupSyncAction; + readonly results: readonly SetupSyncTargetResult[]; +}): number { + return opts.results.some((result) => + isSetupSyncFailure({ action: opts.action, status: result.status }) + ) + ? 1 + : 0; +} + +/** Whether one target status fails the given action. */ +export function isSetupSyncFailure(opts: { + readonly action: SetupSyncAction; + readonly status: SetupSyncStatus; +}): boolean { + if (opts.status === "error") { + return true; + } + if (opts.action !== "check") { + return false; + } + return ( + opts.status === "refreshed" || + opts.status === "installed" || + opts.status === "removed" || + opts.status === "preserved" + ); +} + +type ScopeContext = { + readonly action: SetupSyncAction; + readonly scope: SetupSyncScope; + readonly root: string; + /** Write missing targets (sync with `--install-missing`). */ + readonly installMissing: boolean; + /** Report missing targets as `installed` (check with `--install-missing`). */ + readonly planInstall: boolean; +}; + +function withTarget(opts: { + readonly context: ScopeContext; + readonly target: SetupSyncTarget; + readonly path: string; + readonly outcome: Outcome; +}): SetupSyncTargetResult { + return { + scope: opts.context.scope, + target: opts.target, + path: opts.path, + ...opts.outcome, + }; +} + +/** Outcome for a target that does not exist yet. */ +function missingOutcome(context: ScopeContext): Outcome | null { + if (context.planInstall) { + return { status: "installed" }; + } + if (!context.installMissing) { + return { status: "missing" }; + } + return null; +} + +function preservedOutcome(reason: IntegrationPreservedReason): Outcome { + return { + status: "preserved", + reason, + message: describePreservedReason({ reason, action: "sync" }), + }; +} + +async function runOwnedFileTarget(opts: { + readonly context: ScopeContext; + readonly file: OwnedFileTarget; +}): Promise { + const { context, file } = opts; + const path = resolve(context.root, file.relativePath); + const outcome = + context.action === "remove" + ? await removeOwnedFile({ file, path }) + : await syncOwnedFile({ context, file, path }); + return withTarget({ context, target: file.target, path, outcome }); +} + +async function syncOwnedFile(opts: { + readonly context: ScopeContext; + readonly file: OwnedFileTarget; + readonly path: string; +}): Promise { + const { context, file, path } = opts; + const render = file.render(); + const ownership = await classifyIntegrationFile({ + surface: file.surface, + path, + currentRender: render, + }); + if (ownership.kind === "absent") { + const planned = missingOutcome(context); + if (planned) { + return planned; + } + const installed = await installOwnedIntegrationFile({ + surface: file.surface, + path, + render, + }); + return installed.status === "preserved" + ? preservedOutcome(installed.reason) + : { status: "installed" }; + } + if (ownership.kind === "customized" || ownership.kind === "unknown") { + return preservedOutcome(ownership.reason); + } + + const existing = await readOptionalTextFile(path); + if (existing.kind !== "text") { + return preservedOutcome("unreadable"); + } + if ( + normalizeInstructionText({ text: existing.text }) === + normalizeInstructionText({ text: render }) + ) { + return { status: "unchanged" }; + } + if (context.action === "check") { + return { status: "refreshed" }; + } + const refreshed = await installOwnedIntegrationFile({ + surface: file.surface, + path, + render, + }); + if (refreshed.status === "preserved") { + return preservedOutcome(refreshed.reason); + } + return { status: refreshed.status === "noop" ? "unchanged" : "refreshed" }; +} + +async function removeOwnedFile(opts: { + readonly file: OwnedFileTarget; + readonly path: string; +}): Promise { + const outcome = await removeOwnedIntegrationFile({ + surface: opts.file.surface, + path: opts.path, + currentRender: opts.file.render(), + removeEmptyDirectory: opts.file.removeEmptyDirectory, + }); + if (outcome.status === "preserved") { + return { + status: "preserved", + reason: outcome.reason, + message: outcome.message, + }; + } + if (outcome.status === "removed" && outcome.message) { + return { status: "removed", message: outcome.message }; + } + return { status: outcome.status }; +} + +type ParsedSettings = + | { readonly kind: "absent" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ok"; readonly value: Record }; + +async function readClaudeSettings(path: string): Promise { + const file = await readOptionalTextFile(path); + if (file.kind === "absent") { + return { kind: "absent" }; + } + if (file.kind === "unreadable") { + return { + kind: "error", + message: "Claude settings exist but could not be read; left unchanged.", + }; + } + if (file.text.trim().length === 0) { + return { kind: "ok", value: {} }; + } + try { + const parsed: unknown = JSON.parse(file.text); + if (isRecord(parsed)) { + return { kind: "ok", value: parsed }; + } + } catch { + // Fall through to the closed diagnostic; never echo parser output. + } + return { + kind: "error", + message: "Claude settings are not a JSON object; left unchanged.", + }; +} + +async function writeClaudeSettings(opts: { + readonly path: string; + readonly settings: Record; +}): Promise { + await ensureDir(dirname(opts.path)); + const result = await writeTextFileIfChanged( + opts.path, + `${JSON.stringify(opts.settings, null, 2)}\n` + ); + return result.changed; +} + +async function runClaudeHooksTarget(opts: { + readonly context: ScopeContext; +}): Promise { + const { context } = opts; + const path = resolve(context.root, CLAUDE_SETTINGS_PATHS[context.scope]); + const settings = await readClaudeSettings(path); + let outcome: Outcome; + if (settings.kind === "error") { + outcome = { status: "error", message: settings.message }; + } else if (context.action === "remove") { + outcome = await removeClaudeHooks({ path, settings }); + } else { + outcome = await syncClaudeHooks({ + context, + path, + value: settings.kind === "ok" ? settings.value : {}, + }); + } + return withTarget({ context, target: "claude-hooks", path, outcome }); +} + +async function removeClaudeHooks(opts: { + readonly path: string; + readonly settings: Exclude; +}): Promise { + if (opts.settings.kind === "absent") { + return { status: "missing" }; + } + if (!stripClaudePrimerHooks({ settings: opts.settings.value })) { + return { status: "missing" }; + } + await writeClaudeSettings({ path: opts.path, settings: opts.settings.value }); + return { status: "removed" }; +} + +/** + * Settings with no Hack primer entry are a missing integration; settings + * with some entries are Hack-owned and are completed on refresh. Other + * settings and hooks are kept. + */ +async function syncClaudeHooks(opts: { + readonly context: ScopeContext; + readonly path: string; + readonly value: Record; +}): Promise { + const { context, path, value } = opts; + const coverage = inspectClaudePrimerHooks({ settings: value }); + if (coverage === "complete") { + return { status: "unchanged" }; + } + if (coverage === "none") { + const planned = missingOutcome(context); + if (planned) { + return planned; + } + ensureClaudePrimerHooks({ settings: value }); + await writeClaudeSettings({ path, settings: value }); + return { status: "installed" }; + } + if (context.action === "check") { + return { status: "refreshed" }; + } + ensureClaudePrimerHooks({ settings: value }); + const changed = await writeClaudeSettings({ path, settings: value }); + return { status: changed ? "refreshed" : "unchanged" }; +} + +async function runMcpTarget(opts: { + readonly context: ScopeContext; + readonly target: SetupSyncTarget; + readonly client: McpTarget; +}): Promise { + const { context, target, client } = opts; + const scope = context.scope === "global" ? "user" : "project"; + const location = + context.scope === "global" + ? { home: context.root } + : { projectRoot: context.root }; + const outcome = await (async (): Promise< + Outcome & { readonly path?: string } + > => { + try { + if (context.action === "remove") { + const [removed] = await removeMcpConfig({ + targets: [client], + scope, + ...location, + }); + return mapMcpResult(removed); + } + const [checked] = await checkMcpConfig({ + targets: [client], + scope, + ...location, + }); + if (checked?.status !== "missing") { + return mapMcpResult(checked); + } + const planned = missingOutcome(context); + if (planned) { + return { ...planned, path: checked.path }; + } + // Ordinary install only adds a missing entry; it never replaces one. + const [installed] = await installMcpConfig({ + targets: [client], + scope, + ...location, + }); + if (installed?.status === "error") { + return mapMcpResult(installed); + } + return { status: "installed", path: installed?.path }; + } catch { + return { + status: "error", + message: "MCP configuration could not be read; left unchanged.", + }; + } + })(); + const { path, ...rest } = outcome; + return withTarget({ context, target, path: path ?? "", outcome: rest }); +} + +function mapMcpResult( + result: + | { + readonly status: string; + readonly path?: string; + readonly message?: string; + } + | undefined +): Outcome & { readonly path?: string } { + if (!result) { + return { status: "error", message: "MCP configuration was not checked." }; + } + const path = result.path; + switch (result.status) { + case "present": + return { status: "unchanged", path }; + case "removed": + return { status: "removed", path }; + case "missing": + case "noop": + return { status: "missing", path }; + default: + return { + status: "error", + path, + message: "MCP configuration is invalid or unreadable; left unchanged.", + }; + } +} + +async function runAgentDocTarget(opts: { + readonly context: ScopeContext; + readonly target: SetupSyncTarget; + readonly doc: "agents" | "claude"; +}): Promise { + const { context, target, doc } = opts; + const outcome = await (async (): Promise< + Outcome & { readonly path: string } + > => { + if (context.action === "remove") { + const [removed] = await removeAgentDocs({ + projectRoot: context.root, + targets: [doc], + }); + return mapAgentDocWrite({ + action: "remove", + result: removed, + root: context.root, + doc, + }); + } + const [checked] = await checkAgentDocs({ + projectRoot: context.root, + targets: [doc], + }); + const path = checked?.path ?? resolve(context.root, docFilename(doc)); + if (!checked || checked.status === "error") { + if (checked?.unmanaged) { + // A user-owned doc without a Hack section: the section is missing. + return await installAgentDocOrSkip({ context, doc, path }); + } + if (checked?.malformed) { + return { ...preservedOutcome("customized-content"), path }; + } + return { + status: "error", + path, + message: "Agent doc could not be read; left unchanged.", + }; + } + if (checked.status === "missing") { + return await installAgentDocOrSkip({ context, doc, path }); + } + if (checked.status === "present") { + return { status: "unchanged", path }; + } + if (context.action === "check") { + return { status: "refreshed", path }; + } + const [written] = await upsertAgentDocs({ + projectRoot: context.root, + targets: [doc], + }); + return mapAgentDocWrite({ + action: "sync", + result: written, + root: context.root, + doc, + }); + })(); + const { path, ...rest } = outcome; + return withTarget({ context, target, path, outcome: rest }); +} + +async function installAgentDocOrSkip(opts: { + readonly context: ScopeContext; + readonly doc: "agents" | "claude"; + readonly path: string; +}): Promise { + const planned = missingOutcome(opts.context); + if (planned) { + return { ...planned, path: opts.path }; + } + const [written] = await upsertAgentDocs({ + projectRoot: opts.context.root, + targets: [opts.doc], + }); + if (written?.status === "error") { + return { + status: "error", + path: opts.path, + message: "Agent doc could not be written; left unchanged.", + }; + } + return { status: "installed", path: opts.path }; +} + +function mapAgentDocWrite(opts: { + readonly action: "sync" | "remove"; + readonly result: + | { readonly status: string; readonly path: string } + | undefined; + readonly root: string; + readonly doc: "agents" | "claude"; +}): Outcome & { readonly path: string } { + const path = opts.result?.path ?? resolve(opts.root, docFilename(opts.doc)); + switch (opts.result?.status) { + case "removed": + return { status: "removed", path }; + case "noop": + // Remove found no Hack section; refresh found the section current. + return { + status: opts.action === "remove" ? "missing" : "unchanged", + path, + }; + case "updated": + case "created": + return { status: "refreshed", path }; + default: + return { + status: "error", + path, + message: "Agent doc could not be updated; left unchanged.", + }; + } +} + +function docFilename(doc: "agents" | "claude"): string { + return doc === "agents" ? "AGENTS.md" : "CLAUDE.md"; +} + +/** + * Retired Hack artifacts are reported only when present. `sync` and `remove` + * delete recognized ones; `check` reports them as `removed` (would remove). + */ +async function runRetiredArtifacts(opts: { + readonly context: ScopeContext; +}): Promise { + const { context } = opts; + const checkOnly = context.action === "check"; + let results: LegacyAgentArtifactResult[]; + if (context.scope === "project") { + results = checkOnly + ? await checkLegacyProjectAgentArtifacts({ projectRoot: context.root }) + : await removeLegacyProjectAgentArtifacts({ projectRoot: context.root }); + } else { + results = checkOnly + ? await checkLegacyUserAgentArtifacts({ home: context.root }) + : await removeLegacyUserAgentArtifacts({ home: context.root }); + } + return results + .filter((result) => result.status !== "absent") + .map((result) => + withTarget({ + context, + target: "retired-artifact", + path: result.path, + outcome: + result.status === "error" + ? { + status: "error", + message: + "Retired artifact path holds unrecognized or malformed content; left unchanged.", + } + : { status: "removed" }, + }) + ); +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f8e1ab11..2711f94f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -22,16 +22,14 @@ import { removeCursorRules, } from "../agents/cursor.ts"; import { - checkLegacyProjectAgentArtifacts, - checkLegacyUserAgentArtifacts, - removeLegacyProjectAgentArtifacts, - removeLegacyUserAgentArtifacts, -} from "../agents/legacy-artifacts.ts"; -import { - checkSharedHackSkill, - installSharedHackSkill, - removeSharedHackSkill, -} from "../agents/shared-skill.ts"; + isSetupSyncFailure, + runSetupSyncScope, + type SetupSyncAction, + type SetupSyncStatus, + type SetupSyncTarget, + type SetupSyncTargetResult, + setupSyncExitCode, +} from "../agents/setup-sync.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { CliUsageError, @@ -39,7 +37,8 @@ import { defineOption, withHandler, } from "../cli/command.ts"; -import { optPath } from "../cli/options.ts"; +import { optJson, optPath } from "../cli/options.ts"; +import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { pathExists, readTextFile, writeTextFile } from "../lib/fs.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { findRepoRootForInit } from "../lib/project.ts"; @@ -115,6 +114,14 @@ const optCodex = defineOption({ description: "Target Codex integration", } as const); +const optInstallMissing = defineOption({ + name: "installMissing", + type: "boolean", + long: "--install-missing", + description: + "Also create missing integrations in the selected scope (default: refresh only installed Hack-owned files)", +} as const); + const optAgentsMd = defineOption({ name: "agentsMd", type: "boolean", @@ -145,8 +152,10 @@ const setupSyncOptions = [ optPath, optGlobal, optAllScopes, + optInstallMissing, optCheck, optRemove, + optJson, ] as const; const setupMcpOptions = [ optPath, @@ -167,12 +176,6 @@ type SetupAgentsArgs = CommandArgs; type SetupSyncArgs = CommandArgs; type SetupMcpArgs = CommandArgs; -type SetupMultiLogResult = { - readonly status: string; - readonly path?: string; - readonly message?: string; -}; - const tmuxSpec = defineCommand({ name: "tmux", summary: "Install the recommended tmux binding for hack workspaces", @@ -220,7 +223,7 @@ const agentsSpec = defineCommand({ const syncSpec = defineCommand({ name: "sync", - summary: "Refresh project/global agent guidance", + summary: "Refresh installed Hack-owned agent integrations", group: "Agents", options: setupSyncOptions, positionals: [], @@ -656,92 +659,157 @@ async function handleSetupAgents({ }); } -type SetupSyncAction = "install" | "check" | "remove"; - -type SetupSyncGroup = { - readonly label: string; - readonly results: readonly SetupMultiLogResult[]; -}; - type SetupSyncScopeResult = { readonly exitCode: number; readonly item: DisplayStatusItem; }; +const SETUP_SYNC_TARGET_LABELS: Readonly> = { + "cursor-rules": "Cursor rules", + "claude-hooks": "Claude hooks", + "claude-hack-init-skill": "Claude hack-init skill", + "codex-hack-cli-skill": "Codex hack-cli skill", + "codex-hack-init-skill": "Codex hack-init skill", + "shared-hack-cli-skill": "Shared Hack skill", + "cursor-mcp": "Cursor MCP config", + "claude-mcp": "Claude MCP config", + "codex-mcp": "Codex MCP config", + "agents-md": "AGENTS.md", + "claude-md": "CLAUDE.md", + "retired-artifact": "Retired agent artifact", + "scope-root": "Scope", +}; + +/** How `--check` words a status that `sync` would produce. */ +const SETUP_SYNC_CHECK_WORDS: Readonly< + Partial> +> = { + refreshed: "stale", + installed: "not installed (would install)", + removed: "retired (would remove)", +}; + +/** + * Collapse one scope's per-target results into a compact status row. Paths + * and reasons are expanded only for errors, preserved files, and (with + * `--check`) targets that sync would change. + */ export function buildSetupSyncScopeResult(input: { readonly action: SetupSyncAction; readonly scope: "Project" | "Global"; - readonly groups: readonly SetupSyncGroup[]; + readonly results: readonly SetupSyncTargetResult[]; }): SetupSyncScopeResult { - const entries = input.groups.flatMap((group) => - group.results.map((result) => ({ ...result, label: group.label })) + const { action, results } = input; + const failures = results.filter((result) => + isSetupSyncFailure({ action, status: result.status }) ); - const failures = entries.filter((entry) => { - if (entry.status === "error") { - return true; - } - return ( - input.action === "check" && - ["missing", "stale", "deprecated"].includes(entry.status) - ); - }); - const errorCount = failures.filter( - (entry) => entry.status === "error" - ).length; - // Preserved files are reported but do not fail an install or remove. - const preserved = entries.filter((entry) => entry.status === "preserved"); + const errorCount = countSetupSyncStatus({ results, status: "error" }); + const preserved = countSetupSyncStatus({ results, status: "preserved" }); + let status: DisplayStatusItem["status"] = "ok"; if (errorCount > 0) { status = "error"; - } else if (failures.length > 0 || preserved.length > 0) { + } else if (failures.length > 0 || preserved > 0) { status = "warn"; } - const meta = (() => { - if (input.action === "check") { - return failures.length === 0 - ? `${entries.length} current` - : `${entries.length - failures.length}/${entries.length} current`; - } - const preservedSuffix = - preserved.length > 0 ? `, ${preserved.length} preserved` : ""; - if (input.action === "remove") { - const removed = entries.filter( - (entry) => entry.status === "removed" - ).length; - return `${removed} removed${preservedSuffix}`; - } - const changed = entries.filter((entry) => - ["created", "updated", "removed"].includes(entry.status) - ).length; - return changed === 0 - ? `already current${preservedSuffix}` - : `${changed} updated${preservedSuffix}`; - })(); - const detail = [...failures, ...preserved] - .map((entry) => { - const location = entry.path ? ` at ${entry.path}` : ""; - if (entry.status === "preserved") { - const reason = entry.message ? ` ${entry.message}` : ""; - return `${entry.label}: preserved${location}.${reason}`; - } - if (entry.message) { - return `${entry.label}: ${entry.message}`; - } - return `${entry.label}: ${entry.status}${location}`; - }) - .join("\n"); + + const detailLines = results + .filter( + (result) => + result.status === "error" || + result.status === "preserved" || + failures.includes(result) + ) + .map((result) => describeSetupSyncResult({ action, result })); + if ( + action === "sync" && + countSetupSyncStatus({ results, status: "missing" }) > 0 + ) { + detailLines.push( + "Missing integrations were skipped; add --install-missing to create them." + ); + } return { - exitCode: failures.length > 0 ? 1 : 0, + exitCode: setupSyncExitCode({ action, results }), item: { label: input.scope, status, - meta, - detail: detail.length > 0 ? detail : undefined, + meta: buildSetupSyncMeta({ action, results, failures }), + detail: detailLines.length > 0 ? detailLines.join("\n") : undefined, }, }; } +function countSetupSyncStatus(opts: { + readonly results: readonly SetupSyncTargetResult[]; + readonly status: SetupSyncStatus; +}): number { + return opts.results.filter((result) => result.status === opts.status).length; +} + +function buildSetupSyncMeta(opts: { + readonly action: SetupSyncAction; + readonly results: readonly SetupSyncTargetResult[]; + readonly failures: readonly SetupSyncTargetResult[]; +}): string { + const count = (status: SetupSyncStatus): number => + countSetupSyncStatus({ results: opts.results, status }); + const preserved = count("preserved"); + const preservedSuffix = preserved > 0 ? `, ${preserved} preserved` : ""; + const missing = count("missing"); + + if (opts.action === "remove") { + return `${count("removed")} removed${preservedSuffix}`; + } + if (opts.action === "check") { + const considered = opts.results.length - missing; + const current = + opts.failures.length === 0 + ? `${count("unchanged")} current` + : `${count("unchanged")}/${considered} current`; + return missing > 0 ? `${current}, ${missing} not installed` : current; + } + + const changed = [ + { status: "refreshed", word: "refreshed" }, + { status: "installed", word: "installed" }, + { status: "removed", word: "retired removed" }, + ] as const; + const parts = changed + .filter((entry) => count(entry.status) > 0) + .map((entry) => `${count(entry.status)} ${entry.word}`); + let head = parts.join(", "); + if (parts.length === 0) { + head = count("unchanged") > 0 ? "already current" : "nothing to refresh"; + } + const missingSuffix = missing > 0 ? `, ${missing} missing (skipped)` : ""; + return `${head}${preservedSuffix}${missingSuffix}`; +} + +function describeSetupSyncResult(opts: { + readonly action: SetupSyncAction; + readonly result: SetupSyncTargetResult; +}): string { + const { action, result } = opts; + const label = SETUP_SYNC_TARGET_LABELS[result.target]; + const location = result.path ? ` at ${result.path}` : ""; + if (result.status === "preserved") { + const reason = result.message ? ` ${result.message}` : ""; + return `${label}: preserved${location}.${reason}`; + } + if (result.status === "error") { + return result.message + ? `${label}: ${result.message}` + : `${label}: error${location}`; + } + const word = + action === "check" + ? (SETUP_SYNC_CHECK_WORDS[result.status] ?? result.status) + : result.status; + return `${label}: ${word}${location}`; +} + async function handleSetupSync({ ctx, args, @@ -749,174 +817,108 @@ async function handleSetupSync({ readonly ctx: CliContext; readonly args: SetupSyncArgs; }): Promise { - const action = resolveAction(args.options); + const resolved = resolveAction(args.options); + const installMissing = args.options.installMissing === true; + if (resolved === "remove" && installMissing) { + throw new CliUsageError( + "Use either --install-missing or --remove, not both." + ); + } + const action: SetupSyncAction = resolved === "install" ? "sync" : resolved; const scopes = resolveSyncScopes({ global: args.options.global === true, allScopes: args.options.allScopes === true, }); - const includesProject = scopes.includes("project"); - const includesUser = scopes.includes("user"); - const projectRoot = includesProject - ? await resolveSetupRoot({ ctx, pathOpt: args.options.path }) - : undefined; - const scopeResults: SetupSyncScopeResult[] = []; - if (includesProject && projectRoot) { + const results: SetupSyncTargetResult[] = []; + const scopeResults: SetupSyncScopeResult[] = []; + if (scopes.includes("project")) { + const projectRoot = await resolveSetupRoot({ + ctx, + pathOpt: args.options.path, + }); + const projectResults = await runSetupSyncScope({ + action, + scope: "project", + root: projectRoot, + installMissing, + }); + results.push(...projectResults); scopeResults.push( - await runProjectScopeSync({ + buildSetupSyncScopeResult({ action, - projectRoot, + scope: "Project", + results: projectResults, + }) + ); + } + if (scopes.includes("user")) { + const globalResults = await runSetupSyncScope({ + action, + scope: "global", + root: process.env.HOME ?? "", + installMissing, + }); + results.push(...globalResults); + scopeResults.push( + buildSetupSyncScopeResult({ + action, + scope: "Global", + results: globalResults, }) ); } - if (includesUser) { - scopeResults.push(await runUserScopeSync({ action })); + const exitCode = setupSyncExitCode({ action, results }); + if (args.options.json === true) { + emitCliResult({ + result: okResult({ + data: buildSetupSyncJsonData({ action, installMissing, results }), + }), + }); + return exitCode; } const titles: Readonly> = { check: "Agent integrations", - install: "Agent integrations updated", + sync: "Agent integrations refreshed", remove: "Agent integrations removed", }; await display.statusList({ title: titles[action], items: scopeResults.map((result) => result.item), }); - return Math.max(0, ...scopeResults.map((result) => result.exitCode)); -} - -/** Run one explicit sync action across all project-scope integrations. */ -async function runProjectScopeSync(opts: { - readonly action: SetupSyncAction; - readonly projectRoot: string; -}): Promise { - const { action, projectRoot } = opts; - let cursorResult: Awaited>; - let claudeResult: Awaited>; - let codexResult: Awaited>; - let legacyResults: SetupMultiLogResult[]; - let mcpResults: SetupMultiLogResult[]; - let docsResults: SetupMultiLogResult[]; - - if (action === "check") { - cursorResult = await checkCursorRules({ scope: "project", projectRoot }); - claudeResult = await checkClaudeHooks({ scope: "project", projectRoot }); - codexResult = await checkCodexSkill({ scope: "project", projectRoot }); - legacyResults = await checkLegacyProjectAgentArtifacts({ projectRoot }); - mcpResults = await checkMcpConfig({ - scope: "project", - targets: ["cursor", "claude", "codex"], - projectRoot, - }); - docsResults = await checkAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); - } else if (action === "remove") { - cursorResult = await removeCursorRules({ scope: "project", projectRoot }); - claudeResult = await removeClaudeHooks({ scope: "project", projectRoot }); - codexResult = await removeCodexSkill({ scope: "project", projectRoot }); - legacyResults = await removeLegacyProjectAgentArtifacts({ projectRoot }); - mcpResults = await removeMcpConfig({ - scope: "project", - targets: ["cursor", "claude", "codex"], - projectRoot, - }); - docsResults = await removeAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); - } else { - cursorResult = await installCursorRules({ scope: "project", projectRoot }); - claudeResult = await installClaudeHooks({ scope: "project", projectRoot }); - codexResult = await installCodexSkill({ scope: "project", projectRoot }); - legacyResults = await removeLegacyProjectAgentArtifacts({ projectRoot }); - mcpResults = await installMcpConfig({ - scope: "project", - targets: ["cursor", "claude", "codex"], - projectRoot, - }); - docsResults = await upsertAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); - } - - return buildSetupSyncScopeResult({ - action, - scope: "Project", - groups: [ - { label: "Cursor", results: [cursorResult] }, - { label: "Claude", results: [claudeResult] }, - { label: "Codex", results: [codexResult] }, - { label: "Retired agent artifacts", results: legacyResults }, - { label: "MCP config", results: mcpResults }, - { label: "Agent docs", results: docsResults }, - ], - }); + return exitCode; } /** - * Run one sync action across all global (user) scope integrations and log - * results. Shared `~/.ai/skills` guidance is managed alongside client-specific - * integrations. Known retired Hack-owned skills are cleaned up safely. + * Machine-readable `hack setup sync --json` payload: one entry per target. + * With `--check`, each status is what `sync` would report (`dryRun: true`). + * The envelope is `ok` whenever the action ran; the exit code carries + * failure, as with `hack doctor --json`. */ -async function runUserScopeSync(opts: { +export function buildSetupSyncJsonData(opts: { readonly action: SetupSyncAction; -}): Promise { - const { action } = opts; - let cursorResult: Awaited>; - let claudeResult: Awaited>; - let codexResult: Awaited>; - let sharedSkillResult: SetupMultiLogResult & { readonly path: string }; - let legacyResults: SetupMultiLogResult[]; - let mcpResults: SetupMultiLogResult[]; - - if (action === "check") { - cursorResult = await checkCursorRules({ scope: "user" }); - claudeResult = await checkClaudeHooks({ scope: "user" }); - codexResult = await checkCodexSkill({ scope: "user" }); - sharedSkillResult = await checkSharedHackSkill(); - legacyResults = await checkLegacyUserAgentArtifacts(); - mcpResults = await checkMcpConfig({ - scope: "user", - targets: ["cursor", "claude", "codex"], - }); - } else if (action === "remove") { - cursorResult = await removeCursorRules({ scope: "user" }); - claudeResult = await removeClaudeHooks({ scope: "user" }); - codexResult = await removeCodexSkill({ scope: "user" }); - sharedSkillResult = await removeSharedHackSkill(); - legacyResults = await removeLegacyUserAgentArtifacts(); - mcpResults = await removeMcpConfig({ - scope: "user", - targets: ["cursor", "claude", "codex"], - }); - } else { - cursorResult = await installCursorRules({ scope: "user" }); - claudeResult = await installClaudeHooks({ scope: "user" }); - codexResult = await installCodexSkill({ scope: "user" }); - sharedSkillResult = await installSharedHackSkill(); - legacyResults = await removeLegacyUserAgentArtifacts(); - mcpResults = await installMcpConfig({ - scope: "user", - targets: ["cursor", "claude", "codex"], - }); + readonly installMissing: boolean; + readonly results: readonly SetupSyncTargetResult[]; +}): { + readonly action: SetupSyncAction; + readonly dryRun: boolean; + readonly installMissing: boolean; + readonly counts: Readonly>>; + readonly targets: readonly SetupSyncTargetResult[]; +} { + const counts: Partial> = {}; + for (const result of opts.results) { + counts[result.status] = (counts[result.status] ?? 0) + 1; } - - return buildSetupSyncScopeResult({ - action, - scope: "Global", - groups: [ - { label: "Cursor", results: [cursorResult] }, - { label: "Claude", results: [claudeResult] }, - { label: "Codex", results: [codexResult] }, - { label: "Shared Hack skill", results: [sharedSkillResult] }, - { label: "Retired agent artifacts", results: legacyResults }, - { label: "MCP config", results: mcpResults }, - ], - }); + return { + action: opts.action, + dryRun: opts.action === "check", + installMissing: opts.installMissing, + counts, + targets: opts.results, + }; } async function handleSetupMcp({ diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index ee529022..8664b42f 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -31,6 +31,11 @@ export type AgentDocCheckResult = { * marker is a corrupted managed section and is never `unmanaged`. */ readonly unmanaged?: true; + /** + * Set only when the Hack markers are present but do not form one + * start-before-end pair, so the managed section cannot be located safely. + */ + readonly malformed?: true; }; export type AgentDocRemoveResult = { @@ -171,6 +176,7 @@ async function checkAgentDocTarget(opts: { path, message: "Malformed hack agent-docs markers: only one of the start/end markers is present.", + malformed: true, }; } @@ -181,6 +187,7 @@ async function checkAgentDocTarget(opts: { status: "error", path, message: "Malformed hack agent-docs markers.", + malformed: true, }; } if (normalizeInstructionText({ text: region }) !== opts.expected) { diff --git a/src/mcp/install.ts b/src/mcp/install.ts index 27715812..5ee8edb0 100644 --- a/src/mcp/install.ts +++ b/src/mcp/install.ts @@ -84,6 +84,8 @@ export async function installMcpConfig(opts: { readonly targets: readonly McpTarget[]; readonly scope: McpInstallScope; readonly projectRoot?: string; + /** User-scope root; defaults to `HOME`. */ + readonly home?: string; readonly bundle?: McpBundleSelection; }): Promise { const launch = opts.bundle @@ -99,6 +101,7 @@ export async function installMcpConfig(opts: { target, scope: opts.scope, projectRoot: opts.projectRoot, + home: opts.home, }); if (!resolved.ok) { results.push({ @@ -132,6 +135,8 @@ export async function checkMcpConfig(opts: { readonly targets: readonly McpTarget[]; readonly scope: McpInstallScope; readonly projectRoot?: string; + /** User-scope root; defaults to `HOME`. */ + readonly home?: string; }): Promise { const results: McpCheckResult[] = []; @@ -140,6 +145,7 @@ export async function checkMcpConfig(opts: { target, scope: opts.scope, projectRoot: opts.projectRoot, + home: opts.home, }); if (!resolved.ok) { results.push({ @@ -211,6 +217,8 @@ export async function removeMcpConfig(opts: { readonly targets: readonly McpTarget[]; readonly scope: McpInstallScope; readonly projectRoot?: string; + /** User-scope root; defaults to `HOME`. */ + readonly home?: string; }): Promise { const results: McpRemoveResult[] = []; @@ -219,6 +227,7 @@ export async function removeMcpConfig(opts: { target, scope: opts.scope, projectRoot: opts.projectRoot, + home: opts.home, }); if (!resolved.ok) { results.push({ @@ -471,6 +480,7 @@ function resolveConfigPath(opts: { readonly target: McpTarget; readonly scope: McpInstallScope; readonly projectRoot?: string; + readonly home?: string; }): | { readonly ok: true; readonly path: string } | { readonly ok: false; readonly message: string } { @@ -481,7 +491,7 @@ function resolveConfigPath(opts: { }; } - const home = resolveHomeDir(); + const home = opts.home === undefined ? resolveHomeDir() : opts.home.trim(); if (opts.scope === "user" && !home) { return { ok: false, diff --git a/tests/e2e/README.md b/tests/e2e/README.md index d3773098..6ae45ede 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -494,7 +494,9 @@ sentinel client config (`~/.claude/settings.json`, `~/.claude.json`, `.claude/settings.local.json` and `.mcp.json` into two fixtures. It then runs `init --auto`, `init --auto --with codex`, `agent prime`, `agent prime --check`, `agent onboard`, `agent patterns`, `doctor --json`, -`doctor --fix --no-interactive` and `setup sync --check` twice each. After +`doctor --fix --no-interactive`, `setup sync --check`, and refresh-only +`setup sync --all-scopes` twice each (`setup sync --check` exits 1 because the +customized files are reported as preserved). After every run it requires byte-identical hashes for those trees, no new entries under them, and `git status` changes only under `.hack/`. Each command must also exit with its expected code, so a usage error cannot pass as "no write". diff --git a/tests/e2e/scenarios/agent-docs-sync.ts b/tests/e2e/scenarios/agent-docs-sync.ts index afdf47f6..7d692aea 100644 --- a/tests/e2e/scenarios/agent-docs-sync.ts +++ b/tests/e2e/scenarios/agent-docs-sync.ts @@ -147,15 +147,43 @@ export const agentDocsSyncScenario: Scenario = { message: "check after repair should report clean (exit 0)", }); - const fullSync = await ctx.cli({ + const globalSkillPath = join( + ctx.hackHome, + ".codex", + "skills", + "hack-cli", + "SKILL.md" + ); + // Refresh-only by default (HACK-1132): sync must not create missing + // integrations in either scope. + const refreshOnly = await ctx.cli({ args: ["setup", "sync", "--all-scopes"], cwd: fixture.root, env: isolatedUserEnv, }); + expectExit({ + result: refreshOnly, + codes: [0], + message: "refresh-only hack setup sync should succeed", + }); + expect({ + that: !( + (await Bun.file(globalSkillPath).exists()) || + (await Bun.file(join(fixture.root, "CLAUDE.md")).exists()) + ), + message: "refresh-only hack setup sync must not create missing targets", + result: refreshOnly, + }); + + const fullSync = await ctx.cli({ + args: ["setup", "sync", "--all-scopes", "--install-missing"], + cwd: fixture.root, + env: isolatedUserEnv, + }); expectExit({ result: fullSync, codes: [0], - message: "hack setup sync (project scope) should succeed", + message: "hack setup sync --install-missing should succeed", }); const fullSyncCheck = await ctx.cli({ args: ["setup", "sync", "--all-scopes", "--check"], @@ -187,13 +215,6 @@ export const agentDocsSyncScenario: Scenario = { "outside-project inventory must inspect installed global integrations", result: globalPrime, }); - const globalSkillPath = join( - ctx.hackHome, - ".codex", - "skills", - "hack-cli", - "SKILL.md" - ); const globalSkill = await Bun.file(globalSkillPath).text(); await Bun.write(globalSkillPath, `${globalSkill}\nSTALE-GLOBAL-PROBE\n`); const staleGlobalPrime = await ctx.cli({ diff --git a/tests/e2e/scenarios/agent-surface-freeze.ts b/tests/e2e/scenarios/agent-surface-freeze.ts index e9ae98c9..06057ed5 100644 --- a/tests/e2e/scenarios/agent-surface-freeze.ts +++ b/tests/e2e/scenarios/agent-surface-freeze.ts @@ -42,8 +42,10 @@ type FreezeCommand = { * onboarding prompt for the existing setup (exit 0). `doctor` exits 1 for the * unreachable Docker and missing global install, and `--fix` runs only its * Docker-free remediations (see `freezeEnv`). `setup sync --check` exits 1 - * because the customized guidance differs from Hack's render; it reports the - * drift and must not repair it. + * because the customized guidance is reported as preserved; it must not + * repair it. Refresh-only `setup sync --all-scopes` (HACK-1132) exits 0: every + * seeded file is customized or lacks a Hack section, so nothing is refreshed + * and nothing missing is created. */ const FREEZE_COMMANDS: readonly FreezeCommand[] = [ { @@ -106,6 +108,12 @@ const FREEZE_COMMANDS: readonly FreezeCommand[] = [ args: ["setup", "sync", "--check"], codes: [[1], [1]], }, + { + label: "setup sync --all-scopes", + fixture: "project", + args: ["setup", "sync", "--all-scopes"], + codes: [[0], [0]], + }, ]; type FreezeState = { diff --git a/tests/setup-sync.test.ts b/tests/setup-sync.test.ts new file mode 100644 index 00000000..d96e224f --- /dev/null +++ b/tests/setup-sync.test.ts @@ -0,0 +1,588 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { checkClaudeHooks } from "../src/agents/claude.ts"; +import { + checkCodexSkill, + renderCodexSkill, +} from "../src/agents/codex-skill.ts"; +import { checkCursorRules, renderCursorRules } from "../src/agents/cursor.ts"; +import { renderHackInitSkill } from "../src/agents/hack-init-skill.ts"; +import { + runSetupSyncScope, + type SetupSyncAction, + type SetupSyncScope, + type SetupSyncTarget, + type SetupSyncTargetResult, + setupSyncExitCode, +} from "../src/agents/setup-sync.ts"; +import { checkSharedHackSkill } from "../src/agents/shared-skill.ts"; +import { buildSetupSyncJsonData } from "../src/commands/setup.ts"; +import { + checkAgentDocs, + renderAgentDocsSnippet, +} from "../src/mcp/agent-docs.ts"; +import { checkMcpConfig } from "../src/mcp/install.ts"; + +/** + * `hack setup sync` engine tests (HACK-1132). Every call passes a temp + * project root or temp home explicitly. `HOME` is pointed at an empty decoy + * directory for each test and asserted untouched afterwards, so a fallback + * to the process `HOME` fails the test instead of writing real client config. + */ + +/** The `/hack-init` render shipped from v3.1.0 through v4.0.3. */ +const HACK_INIT_V4_0_3 = [ + "---", + "name: hack-init", + "description: >", + " Stand up or adopt hack in a project (agent-assisted onboarding).", + " Trigger when asked to set a repo up with hack, migrate a project onto", + " hack, or finish/repair a partial hack setup.", + "---", + "", + "# hack init (agent-assisted onboarding)", + "", + "The full onboarding prompt is generated by the hack CLI — do not improvise the steps.", + "", + "1. Fetch the canonical onboarding prompt:", + " - With shell access: run `hack agent onboard` (works for new and existing projects).", + " - Without shell access: fetch the `hack-init` MCP prompt from the hack MCP server.", + "2. Follow the printed prompt phase by phase (inventory, setup, platform nuances, running things, verification).", + "3. Finish only when `hack doctor` is clean and every routable service responds.", + "", +].join("\n"); + +let tempRoot: string | null = null; +let decoyHome = ""; +const originalHome = process.env.HOME; + +beforeEach(async () => { + tempRoot = await mkdtemp(join(tmpdir(), "hack-setup-sync-")); + decoyHome = join(tempRoot, "decoy-home"); + await mkdir(decoyHome, { recursive: true }); + process.env.HOME = decoyHome; +}); + +afterEach(async () => { + try { + expect( + await listFiles(decoyHome), + "setup sync must not resolve paths from the process HOME" + ).toEqual([]); + } finally { + if (originalHome === undefined) { + Reflect.deleteProperty(process.env, "HOME"); + } else { + process.env.HOME = originalHome; + } + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + } +}); + +async function makeRoot(name: string): Promise { + if (!tempRoot) { + throw new Error("temp root not initialized"); + } + const root = join(tempRoot, name); + await mkdir(root, { recursive: true }); + return root; +} + +async function writeFile(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, content); +} + +async function listFiles(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) + .sort(); +} + +async function snapshot(root: string): Promise> { + const files = await listFiles(root); + const contents = new Map(); + for (const file of files) { + contents.set(file, await Bun.file(join(root, file)).text()); + } + return contents; +} + +async function run(opts: { + readonly action: SetupSyncAction; + readonly scope: SetupSyncScope; + readonly root: string; + readonly installMissing?: boolean; +}): Promise { + return await runSetupSyncScope({ + action: opts.action, + scope: opts.scope, + root: opts.root, + installMissing: opts.installMissing ?? false, + }); +} + +function byTarget( + results: readonly SetupSyncTargetResult[], + target: SetupSyncTarget +): SetupSyncTargetResult { + const found = results.find((result) => result.target === target); + if (!found) { + throw new Error(`no result for ${target}`); + } + return found; +} + +function statuses( + results: readonly SetupSyncTargetResult[] +): Record { + return Object.fromEntries( + results.map((result) => [result.target, result.status]) + ); +} + +const PROJECT_TARGETS: readonly SetupSyncTarget[] = [ + "cursor-rules", + "claude-hack-init-skill", + "codex-hack-cli-skill", + "codex-hack-init-skill", + "claude-hooks", + "cursor-mcp", + "claude-mcp", + "codex-mcp", + "agents-md", + "claude-md", +]; + +const GLOBAL_TARGETS: readonly SetupSyncTarget[] = [ + "cursor-rules", + "claude-hack-init-skill", + "codex-hack-cli-skill", + "codex-hack-init-skill", + "shared-hack-cli-skill", + "claude-hooks", + "cursor-mcp", + "claude-mcp", + "codex-mcp", +]; + +test("default sync skips missing targets in both scopes and creates nothing", async () => { + const project = await makeRoot("project"); + const home = await makeRoot("home"); + + const projectResults = await run({ + action: "sync", + scope: "project", + root: project, + }); + const globalResults = await run({ + action: "sync", + scope: "global", + root: home, + }); + + expect(projectResults.map((result) => result.target)).toEqual([ + ...PROJECT_TARGETS, + ]); + expect(globalResults.map((result) => result.target)).toEqual([ + ...GLOBAL_TARGETS, + ]); + for (const result of [...projectResults, ...globalResults]) { + expect(result.status, result.target).toBe("missing"); + } + expect(await listFiles(project)).toEqual([]); + expect(await listFiles(home)).toEqual([]); + expect( + setupSyncExitCode({ + action: "sync", + results: [...projectResults, ...globalResults], + }) + ).toBe(0); +}); + +test("--install-missing creates missing targets that the client checks accept", async () => { + const project = await makeRoot("project"); + const home = await makeRoot("home"); + + const planned = await run({ + action: "check", + scope: "project", + root: project, + installMissing: true, + }); + expect(new Set(Object.values(statuses(planned)))).toEqual( + new Set(["installed"]) + ); + expect(setupSyncExitCode({ action: "check", results: planned })).toBe(1); + expect(await listFiles(project)).toEqual([]); + + const projectResults = await run({ + action: "sync", + scope: "project", + root: project, + installMissing: true, + }); + const globalResults = await run({ + action: "sync", + scope: "global", + root: home, + installMissing: true, + }); + for (const result of [...projectResults, ...globalResults]) { + expect(result.status, result.target).toBe("installed"); + expect(await Bun.file(result.path).exists(), result.target).toBe(true); + } + + // Path and content parity with the per-client checks. + expect( + (await checkCursorRules({ scope: "project", projectRoot: project })).status + ).toBe("noop"); + expect( + (await checkClaudeHooks({ scope: "project", projectRoot: project })).status + ).toBe("noop"); + expect( + (await checkCodexSkill({ scope: "project", projectRoot: project })).status + ).toBe("noop"); + for (const check of await checkMcpConfig({ + scope: "project", + projectRoot: project, + targets: ["cursor", "claude", "codex"], + })) { + expect(check.status, check.target).toBe("present"); + } + for (const check of await checkMcpConfig({ + scope: "user", + home, + targets: ["cursor", "claude", "codex"], + })) { + expect(check.status, check.target).toBe("present"); + } + for (const check of await checkAgentDocs({ + projectRoot: project, + targets: ["agents", "claude"], + })) { + expect(check.status, check.target).toBe("present"); + } + process.env.HOME = home; + try { + expect((await checkCursorRules({ scope: "user" })).status).toBe("noop"); + expect((await checkClaudeHooks({ scope: "user" })).status).toBe("noop"); + expect((await checkCodexSkill({ scope: "user" })).status).toBe("noop"); + expect((await checkSharedHackSkill()).status).toBe("noop"); + } finally { + process.env.HOME = decoyHome; + } + + const again = await run({ action: "sync", scope: "global", root: home }); + expect(new Set(Object.values(statuses(again)))).toEqual( + new Set(["unchanged"]) + ); + const checked = await run({ + action: "check", + scope: "project", + root: project, + }); + expect(setupSyncExitCode({ action: "check", results: checked })).toBe(0); +}); + +test("customized and unowned files are preserved byte-identical", async () => { + const project = await makeRoot("project"); + const rules = join(project, ".cursor/rules/hack.mdc"); + const skill = join(project, ".codex/skills/hack-cli/SKILL.md"); + const customizedRules = renderCursorRules().replace( + "# END HACK INTEGRATION", + "- Team rule: run checks before pushing.\n# END HACK INTEGRATION" + ); + const foreignSkill = "---\nname: team-skill\n---\nOur own skill.\n"; + await writeFile(rules, customizedRules); + await writeFile(skill, foreignSkill); + const before = await snapshot(project); + + for (const action of ["check", "sync"] as const) { + for (const installMissing of [false, true]) { + const results = await run({ + action, + scope: "project", + root: project, + installMissing, + }); + expect(byTarget(results, "cursor-rules")).toMatchObject({ + status: "preserved", + reason: "customized-content", + }); + expect(byTarget(results, "codex-hack-cli-skill")).toMatchObject({ + status: "preserved", + reason: "missing-ownership-marker", + }); + const message = byTarget(results, "cursor-rules").message ?? ""; + expect(message).toContain("--install-missing"); + expect(message).not.toContain("Team rule"); + if (action === "check") { + expect(setupSyncExitCode({ action, results })).toBe(1); + } + expect(await Bun.file(rules).text()).toBe(customizedRules); + expect(await Bun.file(skill).text()).toBe(foreignSkill); + } + } + // The refresh-only runs left every other path absent. + const after = await snapshot(project); + for (const [path, content] of before) { + expect(after.get(path)).toBe(content); + } +}); + +test("an old known Hack render is refreshed; check reports it without writing", async () => { + const project = await makeRoot("project"); + const initSkill = join(project, ".claude/skills/hack-init/SKILL.md"); + await writeFile(initSkill, HACK_INIT_V4_0_3); + + const checked = await run({ + action: "check", + scope: "project", + root: project, + }); + expect(byTarget(checked, "claude-hack-init-skill").status).toBe("refreshed"); + expect(byTarget(checked, "codex-hack-init-skill").status).toBe("missing"); + expect(setupSyncExitCode({ action: "check", results: checked })).toBe(1); + expect(await Bun.file(initSkill).text()).toBe(HACK_INIT_V4_0_3); + + const synced = await run({ action: "sync", scope: "project", root: project }); + expect(byTarget(synced, "claude-hack-init-skill").status).toBe("refreshed"); + expect(await Bun.file(initSkill).text()).toBe(renderHackInitSkill()); + expect(await listFiles(project)).toEqual([ + ".claude/skills/hack-init/SKILL.md", + ]); + + const recheck = await run({ + action: "check", + scope: "project", + root: project, + }); + expect(byTarget(recheck, "claude-hack-init-skill").status).toBe("unchanged"); + expect(setupSyncExitCode({ action: "check", results: recheck })).toBe(0); +}); + +test("a current render stamped by another CLI version is refreshed", async () => { + const home = await makeRoot("home"); + const shared = join(home, ".ai/skills/hack-cli/SKILL.md"); + const older = renderCodexSkill().replace( + /generated by hack CLI v\S+?;/, + "generated by hack CLI v0.0.1;" + ); + expect(older).not.toBe(renderCodexSkill()); + await writeFile(shared, older); + + const results = await run({ action: "sync", scope: "global", root: home }); + expect(byTarget(results, "shared-hack-cli-skill").status).toBe("refreshed"); + expect(byTarget(results, "codex-hack-cli-skill").status).toBe("missing"); + expect(await Bun.file(shared).text()).toBe(renderCodexSkill()); +}); + +test("Claude settings without Hack hooks are skipped; partial hooks are completed", async () => { + const project = await makeRoot("project"); + const settingsPath = join(project, ".claude/settings.local.json"); + const foreign = `${JSON.stringify({ permissions: { allow: ["Bash(true)"] } }, null, 2)}\n`; + await writeFile(settingsPath, foreign); + + const skipped = await run({ + action: "sync", + scope: "project", + root: project, + }); + expect(byTarget(skipped, "claude-hooks").status).toBe("missing"); + expect(await Bun.file(settingsPath).text()).toBe(foreign); + + const partial = { + permissions: { allow: ["Bash(true)"] }, + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [{ type: "command", command: "hack agent prime" }], + }, + ], + }, + }; + await writeFile(settingsPath, `${JSON.stringify(partial, null, 2)}\n`); + const refreshed = await run({ + action: "sync", + scope: "project", + root: project, + }); + expect(byTarget(refreshed, "claude-hooks").status).toBe("refreshed"); + const written: unknown = JSON.parse(await Bun.file(settingsPath).text()); + expect(written).toMatchObject({ + permissions: { allow: ["Bash(true)"] }, + hooks: { SessionStart: partial.hooks.SessionStart }, + }); + expect( + (await checkClaudeHooks({ scope: "project", projectRoot: project })).status + ).not.toBe("missing"); + + await writeFile(settingsPath, "{ not json"); + const broken = await run({ action: "sync", scope: "project", root: project }); + expect(byTarget(broken, "claude-hooks")).toMatchObject({ + status: "error", + message: "Claude settings are not a JSON object; left unchanged.", + }); + expect(await Bun.file(settingsPath).text()).toBe("{ not json"); + expect(setupSyncExitCode({ action: "sync", results: broken })).toBe(1); +}); + +test("agent docs and MCP configs without a Hack section are skipped unless installing", async () => { + const project = await makeRoot("project"); + const agentsPath = join(project, "AGENTS.md"); + const mcpPath = join(project, ".mcp.json"); + const agents = "# Team rules\n\nUse pnpm.\n"; + const mcp = `${JSON.stringify({ mcpServers: { other: { command: "true" } } }, null, 2)}\n`; + await writeFile(agentsPath, agents); + await writeFile(mcpPath, mcp); + + const skipped = await run({ + action: "sync", + scope: "project", + root: project, + }); + expect(byTarget(skipped, "agents-md").status).toBe("missing"); + expect(byTarget(skipped, "claude-mcp").status).toBe("missing"); + expect(await Bun.file(agentsPath).text()).toBe(agents); + expect(await Bun.file(mcpPath).text()).toBe(mcp); + + const installed = await run({ + action: "sync", + scope: "project", + root: project, + installMissing: true, + }); + expect(byTarget(installed, "agents-md").status).toBe("installed"); + expect(byTarget(installed, "claude-mcp").status).toBe("installed"); + const agentsAfter = await Bun.file(agentsPath).text(); + expect(agentsAfter.startsWith(agents.trimEnd())).toBe(true); + expect(agentsAfter).toContain(renderAgentDocsSnippet().trimEnd()); + const mcpAfter: unknown = JSON.parse(await Bun.file(mcpPath).text()); + expect(mcpAfter).toMatchObject({ + mcpServers: { other: { command: "true" }, hack: { command: "hack" } }, + }); + + const stale = agentsAfter.replace( + "", + "\nDRIFTED LINE" + ); + await writeFile(agentsPath, stale); + const refreshed = await run({ + action: "sync", + scope: "project", + root: project, + }); + expect(byTarget(refreshed, "agents-md").status).toBe("refreshed"); + expect(await Bun.file(agentsPath).text()).toBe(agentsAfter); + + await writeFile( + agentsPath, + `${agents}\n\nhalf a section\n` + ); + const malformed = await run({ + action: "sync", + scope: "project", + root: project, + }); + expect(byTarget(malformed, "agents-md")).toMatchObject({ + status: "preserved", + reason: "customized-content", + }); +}); + +test("remove deletes only Hack-owned targets and preserves customized ones", async () => { + const home = await makeRoot("home"); + await run({ + action: "sync", + scope: "global", + root: home, + installMissing: true, + }); + const rules = join(home, ".cursor/rules/hack.mdc"); + const customized = `${renderCursorRules()}\nPersonal note.\n`; + await writeFile(rules, customized); + + const removed = await run({ action: "remove", scope: "global", root: home }); + expect(byTarget(removed, "cursor-rules").status).toBe("preserved"); + expect(byTarget(removed, "shared-hack-cli-skill").status).toBe("removed"); + expect(byTarget(removed, "claude-hooks").status).toBe("removed"); + expect(byTarget(removed, "codex-mcp").status).toBe("removed"); + expect(await Bun.file(rules).text()).toBe(customized); + expect(await Bun.file(join(home, ".ai/skills/hack-cli")).exists()).toBe( + false + ); + expect(setupSyncExitCode({ action: "remove", results: removed })).toBe(0); +}); + +test("retired Hack artifacts are reported when present and removed on sync", async () => { + const home = await makeRoot("home"); + const retired = join(home, ".codex/skills/hack-tickets/SKILL.md"); + await writeFile(retired, "---\nname: hack-tickets\n---\nRetired.\n"); + + const checked = await run({ action: "check", scope: "global", root: home }); + expect(byTarget(checked, "retired-artifact")).toMatchObject({ + status: "removed", + path: retired, + }); + expect(await Bun.file(retired).exists()).toBe(true); + expect(setupSyncExitCode({ action: "check", results: checked })).toBe(1); + + const synced = await run({ action: "sync", scope: "global", root: home }); + expect(byTarget(synced, "retired-artifact").status).toBe("removed"); + expect(await Bun.file(retired).exists()).toBe(false); +}); + +test("an unresolved scope root is a closed error and touches nothing", async () => { + const results = await run({ action: "sync", scope: "global", root: " " }); + expect(results).toEqual([ + { + scope: "global", + target: "scope-root", + path: "", + status: "error", + message: "HOME is not set; cannot resolve global integration paths.", + }, + ]); + expect(setupSyncExitCode({ action: "sync", results })).toBe(1); +}); + +test("JSON data lists every target with its status and reason", async () => { + const project = await makeRoot("project"); + await writeFile(join(project, ".cursor/rules/hack.mdc"), "team rules\n"); + const results = await run({ + action: "sync", + scope: "project", + root: project, + }); + const data = buildSetupSyncJsonData({ + action: "sync", + installMissing: false, + results, + }); + expect(data.action).toBe("sync"); + expect(data.dryRun).toBe(false); + expect(data.counts).toEqual({ + preserved: 1, + missing: PROJECT_TARGETS.length - 1, + }); + expect(data.targets[0]).toEqual({ + scope: "project", + target: "cursor-rules", + path: join(project, ".cursor/rules/hack.mdc"), + status: "preserved", + reason: "missing-ownership-marker", + message: expect.stringContaining("no Hack ownership marker"), + }); + const serialized = JSON.stringify(data); + expect(serialized).not.toContain("team rules"); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 86615693..66edc6a7 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -139,17 +139,24 @@ test("setup sync collapses healthy artifacts into one scope row", () => { const result = buildSetupSyncScopeResult({ action: "check", scope: "Project", - groups: [ + results: [ { - label: "Cursor", - results: [{ status: "noop", path: "/repo/.cursor/rules/hack.mdc" }], + scope: "project", + target: "cursor-rules", + status: "unchanged", + path: "/repo/.cursor/rules/hack.mdc", }, { - label: "MCP config", - results: [ - { status: "present", path: "/repo/.cursor/mcp.json" }, - { status: "present", path: "/repo/.codex/config.toml" }, - ], + scope: "project", + target: "cursor-mcp", + status: "unchanged", + path: "/repo/.cursor/mcp.json", + }, + { + scope: "project", + target: "codex-mcp", + status: "unchanged", + path: "/repo/.codex/config.toml", }, ], }); @@ -169,14 +176,18 @@ test("setup sync keeps failing artifact paths visible", () => { const result = buildSetupSyncScopeResult({ action: "check", scope: "Global", - groups: [ + results: [ { - label: "Codex", - results: [{ status: "stale", path: "/.codex/skill.md" }], + scope: "global", + target: "codex-hack-cli-skill", + status: "refreshed", + path: "/.codex/skills/hack-cli/SKILL.md", }, { - label: "Claude", - results: [{ status: "noop", path: "/.claude/settings.json" }], + scope: "global", + target: "claude-hooks", + status: "unchanged", + path: "/.claude/settings.json", }, ], }); @@ -186,7 +197,8 @@ test("setup sync keeps failing artifact paths visible", () => { label: "Global", status: "warn", meta: "1/2 current", - detail: "Codex: stale at /.codex/skill.md", + detail: + "Codex hack-cli skill: stale at /.codex/skills/hack-cli/SKILL.md", }); }); @@ -194,15 +206,12 @@ test("setup sync check reports retired agent artifacts as stale", () => { const result = buildSetupSyncScopeResult({ action: "check", scope: "Project", - groups: [ + results: [ { - label: "Retired agent artifacts", - results: [ - { - status: "deprecated", - path: "/repo/.codex/skills/hack-tickets/SKILL.md", - }, - ], + scope: "project", + target: "retired-artifact", + status: "removed", + path: "/repo/.codex/skills/hack-tickets/SKILL.md", }, ], }); @@ -212,6 +221,42 @@ test("setup sync check reports retired agent artifacts as stale", () => { expect(result.item.meta).toBe("0/1 current"); }); +test("setup sync check counts missing targets without failing", () => { + const missing = { + scope: "project", + target: "agents-md", + status: "missing", + path: "/repo/AGENTS.md", + } as const; + const result = buildSetupSyncScopeResult({ + action: "check", + scope: "Project", + results: [ + missing, + { + scope: "project", + target: "cursor-rules", + status: "unchanged", + path: "/repo/.cursor/rules/hack.mdc", + }, + ], + }); + expect(result.exitCode).toBe(0); + expect(result.item.status).toBe("ok"); + expect(result.item.meta).toBe("1 current, 1 not installed"); + + // With --install-missing, check plans an install and fails like a stale file. + const planned = buildSetupSyncScopeResult({ + action: "check", + scope: "Project", + results: [{ ...missing, status: "installed" }], + }); + expect(planned.exitCode).toBe(1); + expect(planned.item.detail).toBe( + "AGENTS.md: not installed (would install) at /repo/AGENTS.md" + ); +}); + test("buildInitAssistantReport captures repo signals", async () => { const repoRoot = await setupTempRepo(); await Bun.write( @@ -388,22 +433,22 @@ test("Codex project and user duplicates keep separate ownership", async () => { test("setup sync reports preserved files without failing the action", () => { const result = buildSetupSyncScopeResult({ - action: "install", + action: "sync", scope: "Global", - groups: [ + results: [ { - label: "Codex", - results: [ - { - status: "preserved", - path: "/.codex/skills/hack-cli/SKILL.md", - message: "Hack left this path unchanged because it was edited.", - }, - ], + scope: "global", + target: "codex-hack-cli-skill", + status: "preserved", + reason: "customized-content", + path: "/.codex/skills/hack-cli/SKILL.md", + message: "Hack left this path unchanged because it was edited.", }, { - label: "Cursor", - results: [{ status: "updated", path: "/.cursor/rules/hack.mdc" }], + scope: "global", + target: "cursor-rules", + status: "refreshed", + path: "/.cursor/rules/hack.mdc", }, ], }); @@ -412,28 +457,72 @@ test("setup sync reports preserved files without failing the action", () => { expect(result.item).toEqual({ label: "Global", status: "warn", - meta: "1 updated, 1 preserved", + meta: "1 refreshed, 1 preserved", detail: - "Codex: preserved at /.codex/skills/hack-cli/SKILL.md. Hack left this path unchanged because it was edited.", + "Codex hack-cli skill: preserved at /.codex/skills/hack-cli/SKILL.md. Hack left this path unchanged because it was edited.", }); const removed = buildSetupSyncScopeResult({ action: "remove", scope: "Project", - groups: [ + results: [ { - label: "Cursor", - results: [ - { status: "preserved", path: "/repo/.cursor/rules/hack.mdc" }, - ], + scope: "project", + target: "cursor-rules", + status: "preserved", + path: "/repo/.cursor/rules/hack.mdc", }, ], }); expect(removed.exitCode).toBe(0); expect(removed.item.meta).toBe("0 removed, 1 preserved"); expect(removed.item.detail).toBe( - "Cursor: preserved at /repo/.cursor/rules/hack.mdc." + "Cursor rules: preserved at /repo/.cursor/rules/hack.mdc." ); + + // A preserved file means sync cannot make it current, so check fails. + const checked = buildSetupSyncScopeResult({ + action: "check", + scope: "Project", + results: [ + { + scope: "project", + target: "cursor-rules", + status: "preserved", + path: "/repo/.cursor/rules/hack.mdc", + }, + ], + }); + expect(checked.exitCode).toBe(1); +}); + +test("setup sync summarizes skipped missing targets", () => { + const result = buildSetupSyncScopeResult({ + action: "sync", + scope: "Global", + results: [ + { + scope: "global", + target: "cursor-rules", + status: "unchanged", + path: "/.cursor/rules/hack.mdc", + }, + { + scope: "global", + target: "codex-mcp", + status: "missing", + path: "/.codex/config.toml", + }, + ], + }); + expect(result.exitCode).toBe(0); + expect(result.item).toEqual({ + label: "Global", + status: "ok", + meta: "already current, 1 missing (skipped)", + detail: + "Missing integrations were skipped; add --install-missing to create them.", + }); }); async function setupTempRepo(): Promise {