diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 66a7801..615e55f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mstack", - "version": "0.4.0", + "version": "0.4.1", "description": "Portable engineering skills for Codex, Claude Code, OpenCode, and pi.", "author": { "name": "3metaJun" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c345e4..59ec2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.4.1 - 2026-09-12 + +### Upgrade notes + +Run `npx @3metajun/mstack@0.4.1 --harness all --migrate --replace --dry-run` +to inspect the migration, then repeat without `--dry-run`. Recognized legacy +copies are backed up outside skill discovery roots. Unrecognized local +copies stop migration before writes. SSH migration must run locally on the +target machine. + +### Fixes + +- Share one canonical skill copy across Codex, OpenCode and pi. Claude keeps + its adapter and derives skill names from directories, so OpenCode skips its + duplicate copy without changing global settings. +- Migrate legacy copies, archived backups and interrupted stages with retained + originals and rollback. Validate physical path aliases before writing. +- Keep agent definitions in native directories and shared tools beside their + skills. Deduplicate compatible explicit and remote targets. +- Reuse target resolution in the installer and smoke checks so OpenCode and pi + checks inspect the shared installation. + ## 0.4.0 - 2026-09-12 This release adds a shared project workflow for teams using pstack and mstack diff --git a/README.md b/README.md index 6ee7450..97876c4 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ npx @3metajun/mstack --harness all \ The installer preserves an existing skill directory. Use `--dry-run` to inspect the plan. Use `--replace` to move existing directories into a timestamped -`.harness-skills-backups/` directory under the replaced target's parent. Skill -backups therefore stay inside that Harness's `skills/` directory, and custom -artifact backups stay beside the artifact target. mstack retains backups until -you inspect and remove them. +`.harness-skills-backups/` directory outside the skill root. Backups, staged +skills and failed replacements must stay outside discovery roots because +OpenCode also scans hidden subdirectories. Artifact backups stay beside the +artifact target. The installer prints each backup path and retains its contents. ## Install optional artifacts @@ -61,9 +61,9 @@ The available installable artifacts are: | Artifact | Default destination | | --- | --- | -| `agents` | Codex: `$CODEX_HOME/agents/` as TOML; other harnesses: `agents/` beside `skills/` as Markdown | -| `meta-mode-tools` | `tools/meta-mode/` beside `skills/` | -| `guide` | `docs/guide/` beside `skills/` | +| `agents` | Codex: `$CODEX_HOME/agents/` as TOML; other harnesses: their native `agents/` directory as Markdown | +| `meta-mode-tools` | `tools/meta-mode/` beside the resolved skill root; shared consumers use `~/.agents/tools/meta-mode/` | +| `guide` | `docs/guide/` beside the resolved skill root | Codex skills default to `~/.agents/skills/`, while Codex agents default to `~/.codex/agents/`. An unset or empty `CODEX_HOME` uses `~/.codex`. @@ -115,11 +115,56 @@ The default directories are defined in | --- | --- | --- | | Codex | `~/.agents/skills/` | `HARNESS_SKILLS_CODEX_DIR` | | Claude Code | `~/.claude/skills/` | `HARNESS_SKILLS_CLAUDE_DIR` | -| OpenCode | `~/.config/opencode/skills/` | `HARNESS_SKILLS_OPENCODE_DIR` | -| pi | `~/.pi/agent/skills/` | `HARNESS_SKILLS_PI_DIR` | +| OpenCode | `~/.agents/skills/` | `HARNESS_SKILLS_OPENCODE_DIR` | +| pi | `~/.agents/skills/` | `HARNESS_SKILLS_PI_DIR` | + +Codex, OpenCode and pi share one physical copy per skill by default. Updating +through any of these Harnesses updates that shared copy. Claude gets its own +adapter output without a frontmatter `name`: Claude uses the directory name, +while OpenCode skips that copy. No global Harness settings are changed. + +OpenCode and pi agent artifacts retain their native configuration roots. +`CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` and `PI_CODING_AGENT_DIR` still resolve +native roots and legacy migration locations; the latter two no longer move +the default shared skills. Use `HARNESS_SKILLS_*_DIR` for explicit skill paths. Set an override to install into a mounted Fleet directory or another local -path. The path must be absolute or start with `~/`. +path. The path must be absolute or start with `~/`. Explicit overrides and +named environment targets are not rewritten. Equal targets are combined only +when their selected skill adapters agree; Claude and canonical output cannot +share a target. Keep explicit copies out of overlapping discovery paths. + +### Migrate an existing installation + +Run the new installer locally on the machine that owns the skills: + +```bash +npx @3metajun/mstack --harness all --migrate --replace --dry-run +npx @3metajun/mstack --harness all --migrate --replace +``` + +Migration moves recognized legacy OpenCode and pi copies into backups outside +skill discovery. It also moves old installer backup trees and recognized +staging leftovers out of discovery. +A partial shared update checks the same selected names in all native roots, +including Harnesses not named on the command line. Existing Claude copies +that need only directory-based identity are adapted from their installed +contents, preserving supporting files and body edits. Directly selected skills +are replaced from the package, with their original contents backed up. + +Ownership comes from an mstack install receipt or known released `SKILL.md` +content from versions 0.2.0 through 0.4.0. An unrecognized same-name legacy copy +stops migration before writes; inspect and relocate that copy before retrying. +Unselected active skills and unrelated skill names remain in place. Re-running +the migration is safe, and a later failed write rolls back earlier migrations. + +Without `--migrate`, the installer reports legacy copies that would remain +active. Automatic migration requires a default shared local target. For SSH or +mounted custom layouts, run migration on the target machine with its native +paths; SSH `--migrate` is rejected before connecting. SSH dry-run validates +configured destinations but does not inspect the remote filesystem. + +### Custom and remote targets ```powershell $env:HARNESS_SKILLS_OPENCODE_DIR = 'C:\path\to\fleet\opencode\skills' diff --git a/adapters/claude.json b/adapters/claude.json index f853de8..6d2452e 100644 --- a/adapters/claude.json +++ b/adapters/claude.json @@ -1,4 +1,5 @@ { + "directoryName": true, "removeFrontmatter": { "show-me-your-work": ["metadata"] }, diff --git a/docs/harness-adapters.md b/docs/harness-adapters.md index 040deba..d366ffd 100644 --- a/docs/harness-adapters.md +++ b/docs/harness-adapters.md @@ -9,8 +9,8 @@ to the official documentation used for each entry. | --- | --- | --- | --- | | Codex | `~/.agents/skills/` | `.agents/skills/` | Agent Skills fields; Codex-specific `agents/openai.yaml` lives beside each skill | | Claude Code | `~/.claude/skills/` | `.claude/skills/` | `name`, `description`, `license`, `compatibility`, `metadata`, and Claude invocation fields | -| OpenCode | `~/.config/opencode/skills/` | `.opencode/skills/` | `license`, `compatibility`, and `metadata` | -| pi | `~/.pi/agent/skills/` | `.pi/skills/` | `license`, `compatibility`, `metadata`, `allowed-tools`, and `disable-model-invocation` | +| OpenCode | `~/.agents/skills/`, `~/.config/opencode/skills/`, `~/.claude/skills/` | `.opencode/skills/` | `license`, `compatibility`, and `metadata` | +| pi | `~/.agents/skills/`, `~/.pi/agent/skills/` | `.pi/skills/` | `license`, `compatibility`, `metadata`, `allowed-tools`, and `disable-model-invocation` | Sources: @@ -25,6 +25,24 @@ can read. Claude Code accepts `metadata` but does not act on its contents, so the `compatibility` field instead. OpenCode and pi retain `metadata` because their official references support it. +mstack installs one canonical copy in `~/.agents/skills/` for Codex, OpenCode +and pi. Claude output omits frontmatter `name`; Claude's documented fallback +uses the directory name, so `/meta-mode` keeps its name. OpenCode requires an +explicit name before registration and skips the Claude copy. This behavior +was checked with OpenCode 1.18.30 using a named Claude control skill and an +unnamed Claude copy alongside the canonical shared skill. Claude Code 2.1.267 +also returned the directory-derived command name during an isolated +stream-JSON initialization without a user prompt or model request. Recheck this adapter +if OpenCode adds a directory-name fallback for external skills. + +pi 0.85.1 keeps the first same-name skill and reports a collision for later +independent copies. A stale native pi copy can therefore hide a shared update. +The installer migration removes recognized redundant copies from discovery; +see [migration instructions](../README.md#migrate-an-existing-installation). +It also keeps backups outside skill roots so recursive scanners do not load +archived versions. Project wrappers use their separate generator and are not +migrated by the user-level installer. + ## Shared project verification wrappers Project verification has one canonical contract at diff --git a/package.json b/package.json index bcb6997..5393dde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@3metajun/mstack", - "version": "0.4.0", + "version": "0.4.1", "description": "Portable engineering skills for Codex, Claude Code, OpenCode, and pi.", "repository": { "type": "git", @@ -25,7 +25,7 @@ "install-skills": "node scripts/install.mjs", "optimize-context": "node scripts/optimize-context.mjs", "reconcile-context": "node scripts/reconcile-context.mjs", - "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs" + "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/install-migration.test.mjs scripts/migration-integration.test.mjs scripts/skill-discovery.test.mjs scripts/harness-targets.test.mjs scripts/remote-discovery.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs" }, "bin": { "mstack": "scripts/install.mjs", diff --git a/profiles/environments.example.json b/profiles/environments.example.json index ff5dac4..59d16c2 100644 --- a/profiles/environments.example.json +++ b/profiles/environments.example.json @@ -30,8 +30,8 @@ "targets": { "codex": "/home/dev/.agents/skills", "claude": "/home/dev/.claude/skills", - "opencode": "/home/dev/.config/opencode/skills", - "pi": "/home/dev/.pi/agent/skills" + "opencode": "/home/dev/.agents/skills", + "pi": "/home/dev/.agents/skills" }, "artifacts": { "agents": { @@ -43,8 +43,8 @@ "meta-mode-tools": { "codex": "/home/dev/.agents/tools/meta-mode", "claude": "/home/dev/.claude/tools/meta-mode", - "opencode": "/home/dev/.config/opencode/tools/meta-mode", - "pi": "/home/dev/.pi/agent/tools/meta-mode" + "opencode": "/home/dev/.agents/tools/meta-mode", + "pi": "/home/dev/.agents/tools/meta-mode" } } } diff --git a/profiles/harnesses.json b/profiles/harnesses.json index 8f2c6c0..967a473 100644 --- a/profiles/harnesses.json +++ b/profiles/harnesses.json @@ -23,6 +23,7 @@ }, "opencode": { "directoryVariable": "HARNESS_SKILLS_OPENCODE_DIR", + "sharedSkills": true, "configVariable": "XDG_CONFIG_HOME", "configFallback": ".config", "prefix": ["opencode"], @@ -36,6 +37,7 @@ }, "pi": { "directoryVariable": "HARNESS_SKILLS_PI_DIR", + "sharedSkills": true, "configVariable": "PI_CODING_AGENT_DIR", "configFallback": ".pi/agent", "suffix": ["skills"], diff --git a/profiles/legacy-skill-digests.json b/profiles/legacy-skill-digests.json new file mode 100644 index 0000000..3ea4612 --- /dev/null +++ b/profiles/legacy-skill-digests.json @@ -0,0 +1,185 @@ +{ + "schemaVersion": 1, + "package": "@3metajun/mstack", + "sources": [ + "v0.2.0", + "v0.2.1", + "v0.3.0", + "v0.4.0" + ], + "normalization": "CRLF to LF; SHA-256 of UTF-8 SKILL.md, including released Claude adapter variants", + "skills": { + "architect": [ + "cc8a035aa7a2aa417676c4e364b86f4a3e2b85748bf2d0916402105b011a1083" + ], + "arena": [ + "f100383e190b806567015d2cbbaf90b9a1b0151ae60b08aa6185fc6eca3c9552" + ], + "automate-me": [ + "6ab6c4c4a4b50a7cb182149408bd1758229d4386fbb8b1a66cf6927847b02c27", + "971364464d32e0f991168193fc4561e8bc16f7663ccf7fd75541472ad62965df" + ], + "blast-radius": [ + "ca63c902c065c3de95d5c7f5db665b4cf8c3b881eafbef9dde3db66ca340a752" + ], + "bro": [ + "8b822d47b9299627f5e4fe42684a031105792c8531443da1ae3423adfa2de96f" + ], + "codebase-design": [ + "2033d2660df023eddc9a72d840daf997bbbe15e9e768228948dbde906e03cf80", + "4b6d115f53163365388e25bf5306eeafceacb9b739780ac4e788c47c80598f0c" + ], + "create-verification-skill": [ + "7242b478eb0face01b1f1d27950efa69e415219b278993bc3e75f297707bba48", + "eca219cac2808714d6aa3090fd3065d1da6a579d432f277aef334f1ca0dfe770" + ], + "diagnosing-bugs": [ + "a68cc23f0b3bd9b9c00db6243100b59ec1edda045182ed12a0d51a2cb647cf85", + "ecc513932a6cbc3e83a0e8fbd6e6a80d669e8f86c473586b878be58712d58a81" + ], + "figure-it-out": [ + "a892d780bae548ec537ad1edea44b11ab85c3223345c84276f567ef30bfdca97" + ], + "how": [ + "3f936290068d8c1e34ddfde844f840f84b89f0478eaa7dcddebc0502fc71c7bd", + "92b3be3cd554a192f762d2be0344ad53a90386795b018c29c832686189fe4a52", + "a3e2acb068e126bad1ddadb4b16bb14154d5687fffc8ce2fe8209660b83c2e4b" + ], + "interrogate": [ + "8772ecf8ed7e64118459160d4cd61096877b52cc557104a3ae305b036be16ac9", + "ab2508809da00eac47444b102cd7d11730f15b2476cd6cd947e330cc08814e78", + "dd6cd20978aa29e66875ed91a05db9b30e429675a1987b605f3df35c7a2b6777" + ], + "maintain-verification-skill": [ + "b236ab1a439ae067d41d0fc191517992da1130d7935d3c8fa5084def26512296", + "bfa8b97a5ca35d618228c37eb02c74589c0fcb1af13d06201b10e0eb3274e32c" + ], + "make-bot-ui": [ + "2f04984c34d64ad8119eac43f24298d480528b14a9876d672aee0c942aa0c2da" + ], + "meta-mode": [ + "29dd37fbd946ebbcfc9950657a41e0a6f26b10536b7cea5c896b7d12d0f322e4", + "53cb6ae809072973802f78fac92fc75efbe111330271513fa756cd2f0d053ad4", + "798df0dae0ca4959b81d6465b1c3c392ffc5cefd98f5b0834a53262eb1b2fa86", + "9546ad48d518152b537e8e9c450bc3a94f11cfd3477f459269f24597821c6f80" + ], + "no-comments": [ + "ab2c513e04c211b3cd3d56e2905b2aa29d5f2859ebd8c4aafb4ae5d7cd983144", + "f5bc3892fd057751d5928cffbebabbcde58804021a520bf67fac6b550d3399f5" + ], + "principle-attack-the-premise": [ + "58c2998cbe0318b648d2f78c080003c5d3685a0ede2a98e56bc29e2fdfa7417a" + ], + "principle-boundary-discipline": [ + "0ebee28a5f8bf5cac890dc37a18f3cdf66c360d85336fe8efa0be04430ecdddf" + ], + "principle-build-the-lever": [ + "b49623e8e41c072ce14e6479a0a93cb932cd1a320642334cf0ac8f9e7cf31ded" + ], + "principle-encode-lessons-in-structure": [ + "e66d95bd954a1e8ba298bc451ba1e9190c758f6d77c240540bedf8f28c895694" + ], + "principle-exhaust-the-design-space": [ + "67e7e29ab4f484bfddcac55ece4d70f6efc25ccbf91de7de31fcec385f9f059e" + ], + "principle-experience-first": [ + "3013367ce42fabb55799ffbcc51ad20b2ae7ec1b898bad4ac4ceaa2f093edbe1" + ], + "principle-fix-root-causes": [ + "2f48a508674c8b9f3339aba95929079af54fb7fbd367b4eb45175388f1e170f9" + ], + "principle-foundational-thinking": [ + "e69eea824d81c4bb32cbcdb26513bf964882daeeb647bb99210978e86bec1a49" + ], + "principle-guard-the-context-window": [ + "d42b8f8b02cdcb4f84b466ffcd623ca911a8bbf93b46b545d8b3ca4e950b2580" + ], + "principle-laziness-protocol": [ + "f45159ad7ea5f01a08d364897307f69ca18fa57b87d6ed6b89ca222a2388bf7d" + ], + "principle-make-operations-idempotent": [ + "5cf54da6cc34714fbe97e45fd61a4678662d087d7f6f6dc81cace3f34041f541" + ], + "principle-migrate-callers-then-delete-legacy-apis": [ + "388e9cbc9e52bd72d9044d7e37126bd3355c4e8f1188447e2a6850fe4fc549d7" + ], + "principle-minimize-reader-load": [ + "d44ded81ac7cfc194dca65c9943a55fe8a058660485049a6e607e6bdf5d70738" + ], + "principle-model-the-domain": [ + "4464cc1a0a2366ff8249659c30f525dba6206947f037f07f5931238ed0e96f82" + ], + "principle-never-block-on-the-human": [ + "15d583bb6125a88dd0ff375bfdbdc1ac874027bf741cda5f6f4d3e939363051e" + ], + "principle-outcome-oriented-execution": [ + "f2fe80adf2da1e1e15aa1d1b465f1cb963acb05114be9032a89ae6ead06df288" + ], + "principle-prove-it-works": [ + "eb89aa6f3283cd73ba56d6cc01e0d4b9ec12367144988525b2c43761cbed4ada" + ], + "principle-redesign-from-first-principles": [ + "b3ac6bc1c707aa8ad1a331851ad71aeb48f179136907ab2015f34881c674d056" + ], + "principle-separate-before-serializing-shared-state": [ + "30cc80284a90bb5d94cde5a3533a8a27755832c2c4e638f8e3b97bbf8ba94924" + ], + "principle-sequence-verifiable-units": [ + "84bf426fd9c7f2bb8561baad84a06bb5b2c78534e7599bdd3fde47ce5596d707" + ], + "principle-subtract-before-you-add": [ + "65fa4e1a2a630040eb0abbcfeaf0e82241fd968443021b535f94b4b6d4b7aa0f" + ], + "principle-test-behavior-not-implementation": [ + "c6104107aa4902342d36eb5c6b7f99862eadff5ba27f367cde011ccdde4d8d17" + ], + "principle-type-system-discipline": [ + "9397a9ce6e26e54d0e6e9f81e488f626f3daefd6903509536fbdfb0d021a274f" + ], + "recall": [ + "be67c9e064a2b6d2ec52e915c19f6507f9f389a112e9961eb93b3c793b565e70", + "fbf2ae86fe5f3aeff6a6454b1a0f0899eebb5311a37ba44a3a0088fc2523ca77" + ], + "reflect": [ + "5478382b85614482fdf8799c9b0113d0c12c6595ee1003aa62c91f6288b91089", + "80b93579e53f7fdcaf2e95e363bd6f5cb8c4689a67ba4f6823eb011bb92ce40c", + "ab350465ad395806d8d844139ced88da3f48cb0c05caa1afc31a91b3927c5476" + ], + "setup-mstack": [ + "555c577bf494baafafa980ee3f647149c180a615a706ef409845cd03cd16e142", + "5fbe454ff5cea061d8b29e5be4a0eb1d2f040f99927f23ccd6007ec5c02e5b6a", + "77194b522f7ceab59c2bc140bdcce5df5f900b7199469fa343ec0735daa1f866" + ], + "show-me-your-work": [ + "a62b58b5f0bab5a99ac4111973ad40cfd30d8e729eb56ed09d9a1cdda7a440c0", + "d189454d45306b8b2ace4456684fc9dbaf9d3c5305e2fe3c35afed8977f1552f" + ], + "swarm": [ + "015c31b71a020a585b628ed8308245530c008b42facedd3040b385628b3ea599", + "d0ddb740c26ef3da2a47bd2ef5eab12aa224aa19a9d02ff7a0e6ce9dd2a7d3ac" + ], + "tdd": [ + "bb077c26ad22c0171f3c06e72d288556a46a8fce34164ea7da686d78c135544c" + ], + "teach": [ + "5a85664f8cce57429af0d8c060a20d8740579867f0976f4981032e8855cab2b5" + ], + "technical-writing": [ + "40dafbe83d76600dfd9417bb3861b2a73ac75010838412a10b8fa2b82b00b9a0" + ], + "typescript-best-practices": [ + "02b41b2cbb0ac43940b8f4df8b54c55f1aac0a68890a9379f9b006b2a8f1f4a0" + ], + "unslop": [ + "6948335a00f9fed7a29204b9f08f74a9d9596d90989fd06f58f02d984ceab6f3" + ], + "why": [ + "873aedc86d1321093e9a195ac8bff1783241b309a1d33808ab74f181c4b8f22d", + "b1edfe9a6a5fd2cac3111e12a2a3bbf84160bf8921bc559e49ec91d6e15d6ecc" + ], + "writing-for-agents": [ + "3b50712cac5d212e449bc9e9baeed4866412b152aa30feef53fdc4a0f3791840", + "c24b51c9e1e4c9618bf103262c2e89af3391c06d3589a64d9a0b9f826a166763" + ] + } +} diff --git a/scripts/environment.test.mjs b/scripts/environment.test.mjs index 808403b..bd4ba96 100644 --- a/scripts/environment.test.mjs +++ b/scripts/environment.test.mjs @@ -284,7 +284,7 @@ test("remote installer passes an unquoted rsync destination argument", () => { 'import { appendFileSync } from "node:fs";', 'const [mode, ...args] = process.argv.slice(2);', 'appendFileSync(process.env.MSTACK_FAKE_TRANSPORT_LOG, JSON.stringify({ mode, args }) + "\\n");', - 'if (mode === "ssh" && args.at(-1)?.includes("mktemp -d")) { console.log("/srv/mstack remote/pi/skills/.mstack-stage.fake"); console.error("Authorized use only"); }', + 'if (mode === "ssh" && args.at(-1)?.includes("mktemp -d")) { console.log("/srv/mstack remote/pi/.mstack-stage.fake"); console.error("Authorized use only"); }', ].join("\n"), "utf8"); writeFileSync(configPath, JSON.stringify({ fleet: { @@ -319,7 +319,7 @@ test("remote installer passes an unquoted rsync destination argument", () => { .map((line) => JSON.parse(line)); const rsync = calls.find((call) => call.mode === "rsync"); assert.ok(rsync, "expected a fake rsync invocation"); - assert.equal(rsync.args.at(-1), "dev@tailnet-host:/srv/mstack remote/pi/skills/.mstack-stage.fake/"); + assert.equal(rsync.args.at(-1), "dev@tailnet-host:/srv/mstack remote/pi/.mstack-stage.fake/"); assert.doesNotMatch(rsync.args.at(-1), /'/); } finally { rmSync(root, { recursive: true, force: true }); @@ -580,7 +580,7 @@ test("remote installer uploads everything before commit and rolls back a partial 'if (mode === "ssh" && script.includes("mktemp -d")) {', ' state.stageCount += 1;', ' save();', - ' console.log(`/srv/rollback/skills/.mstack-stage.fake-${state.stageCount}`);', + ' console.log(`/srv/rollback/.mstack-stage.fake-${state.stageCount}`);', ' process.exit(0);', '}', 'const target = script.match(/(?:^|; )target=\'([^\']+)\'/)?.[1];', diff --git a/scripts/harness-targets.mjs b/scripts/harness-targets.mjs new file mode 100644 index 0000000..71776ba --- /dev/null +++ b/scripts/harness-targets.mjs @@ -0,0 +1,34 @@ +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; + +export function configuredPath(value, fallback, label, home = homedir()) { + const path = value ?? fallback; + const expanded = path === "~" ? home : /^~[\\/]/.test(path) ? join(home, path.slice(2)) : path; + if (value && !isAbsolute(expanded)) throw new Error(`${label} must be an absolute path or start with ~/`); + return resolve(expanded); +} + +export function resolveHarnessRoots(registry, { env = process.env, home = homedir(), environmentTargets = {}, environmentName } = {}) { + const sharedRoot = join(home, ".agents", "skills"); + const externalClaudeRoot = join(home, ".claude", "skills"); + const skills = {}; + const native = {}; + const legacy = {}; + for (const [harness, config] of Object.entries(registry)) { + if (config.fallback) { + legacy[harness] = join(home, ...config.fallback); + } else { + const configRoot = env[config.configVariable] + ? configuredPath(env[config.configVariable], "", config.configVariable, home) + : join(home, config.configFallback); + legacy[harness] = join(configRoot, ...(config.prefix ?? []), ...(config.suffix ?? [])); + } + const environmentTarget = environmentTargets[harness]; + const directory = env[config.directoryVariable]; + native[harness] = environmentTarget + ? configuredPath(environmentTarget, "", `${environmentName}.${harness}`, home) + : directory ? configuredPath(directory, "", config.directoryVariable, home) : legacy[harness]; + skills[harness] = config.sharedSkills && !environmentTarget && !directory ? sharedRoot : native[harness]; + } + return { skills, native, legacy, sharedRoot, externalClaudeRoot }; +} diff --git a/scripts/harness-targets.test.mjs b/scripts/harness-targets.test.mjs new file mode 100644 index 0000000..6c4c6d7 --- /dev/null +++ b/scripts/harness-targets.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { configuredPath, resolveHarnessRoots } from "./harness-targets.mjs"; + +const repository = resolve("."); +const registry = JSON.parse(readFileSync(join(repository, "profiles", "harnesses.json"), "utf8")); +const home = resolve("fixture-home"); + +test("default skills share a root while native roots retain harness-specific locations", () => { + const roots = resolveHarnessRoots(registry, { env: {}, home }); + const shared = join(home, ".agents", "skills"); + assert.deepEqual(roots.skills, { codex: shared, claude: join(home, ".claude", "skills"), opencode: shared, pi: shared }); + assert.deepEqual(roots.native, { + codex: shared, + claude: join(home, ".claude", "skills"), + opencode: join(home, ".config", "opencode", "skills"), + pi: join(home, ".pi", "agent", "skills"), + }); + assert.deepEqual(roots.legacy, roots.native); + assert.equal(roots.externalClaudeRoot, join(home, ".claude", "skills")); +}); + +test("configuration roots affect native and legacy locations without moving shared skills", () => { + const roots = resolveHarnessRoots(registry, { + home, + env: { CLAUDE_CONFIG_DIR: "~/claude-config", XDG_CONFIG_HOME: "~/xdg", PI_CODING_AGENT_DIR: "~/pi-config" }, + }); + assert.equal(roots.skills.claude, join(home, "claude-config", "skills")); + assert.equal(roots.skills.opencode, join(home, ".agents", "skills")); + assert.equal(roots.skills.pi, join(home, ".agents", "skills")); + assert.equal(roots.native.opencode, join(home, "xdg", "opencode", "skills")); + assert.equal(roots.native.pi, join(home, "pi-config", "skills")); + assert.deepEqual(roots.legacy, roots.native); + assert.equal(roots.externalClaudeRoot, join(home, ".claude", "skills")); +}); + +test("named environment targets override directory variables while legacy roots ignore both", () => { + const roots = resolveHarnessRoots(registry, { + home, + env: { HARNESS_SKILLS_PI_DIR: "~/pi-explicit", HARNESS_SKILLS_OPENCODE_DIR: "~/opencode-explicit", XDG_CONFIG_HOME: "~/xdg" }, + environmentTargets: { pi: "~/environment-pi" }, + environmentName: "local-workspace", + }); + assert.equal(roots.skills.pi, join(home, "environment-pi")); + assert.equal(roots.native.pi, join(home, "environment-pi")); + assert.equal(roots.legacy.pi, join(home, ".pi", "agent", "skills")); + assert.equal(roots.skills.opencode, join(home, "opencode-explicit")); + assert.equal(roots.legacy.opencode, join(home, "xdg", "opencode", "skills")); +}); + +test("configured paths expand home and reject relative overrides at the common boundary", () => { + assert.equal(configuredPath("~", "", "override", home), home); + assert.equal(configuredPath("~/custom", "", "override", home), join(home, "custom")); + assert.throws(() => configuredPath("relative", "", "override", home), /override must be an absolute path/); + assert.throws(() => resolveHarnessRoots(registry, { home, env: { HARNESS_SKILLS_PI_DIR: "relative" } }), /HARNESS_SKILLS_PI_DIR must be an absolute path/); + assert.throws(() => resolveHarnessRoots(registry, { home, env: {}, environmentTargets: { pi: "relative" }, environmentName: "example" }), /example.pi must be an absolute path/); +}); + +for (const explicit of [false, true]) { + test(`smoke finds skills from a real isolated ${explicit ? "explicit" : "default"} installation`, (t) => { + const root = mkdtempSync(join(tmpdir(), "mstack-harness-targets-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const bin = join(root, "bin"); + mkdirSync(bin); + for (const harness of Object.keys(registry)) { + const executable = join(bin, `${harness}${process.platform === "win32" ? ".ps1" : ""}`); + writeFileSync(executable, process.platform === "win32" + ? 'if ($args.Count -eq 1 -and $args[0] -eq "--version") { Write-Output "fixture-cli 1.0"; exit 0 }; exit 9\n' + : '#!/bin/sh\nif [ "$#" = 1 ] && [ "$1" = "--version" ]; then printf "fixture-cli 1.0\\n"; exit 0; fi\nexit 9\n'); + if (process.platform !== "win32") chmodSync(executable, 0o755); + } + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(HARNESS_SKILLS_|MSTACK_|CLAUDE_|CODEX_|PI_CODING_AGENT_DIR$|XDG_|NODE_OPTIONS$|PATH$)/i.test(key))); + Object.assign(env, { HOME: root, USERPROFILE: root, APPDATA: join(root, "appdata"), LOCALAPPDATA: join(root, "localappdata"), PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ""}` }); + if (explicit) { + env.HARNESS_SKILLS_PI_DIR = join(root, "custom-pi-skills"); + env.HARNESS_SKILLS_OPENCODE_DIR = join(root, "custom-opencode-skills"); + } + const installed = spawnSync(process.execPath, [join(repository, "scripts", "install.mjs"), "--harness", "all", "--skill", "bro"], { cwd: root, env, encoding: "utf8" }); + assert.equal(installed.status, 0, installed.stderr); + const smoked = spawnSync(process.execPath, [join(repository, "scripts", "smoke-harnesses.mjs"), "--harness", "all", "--skill", "bro", "--require-installed", "--json"], { cwd: root, env, encoding: "utf8" }); + assert.equal(smoked.status, 0, `${smoked.stderr}\n${smoked.stdout}`); + const entries = JSON.parse(smoked.stdout); + assert.equal(entries.length, 4); + for (const entry of entries) { + const expected = entry.harness === "claude" ? join(root, ".claude", "skills") + : explicit && ["pi", "opencode"].includes(entry.harness) ? join(root, `custom-${entry.harness}-skills`) + : join(root, ".agents", "skills"); + assert.equal(entry.target, expected, entry.harness); + assert.equal(entry.installed, true, entry.harness); + assert.equal(entry.cli, "available", entry.harness); + assert.equal(entry.version, "fixture-cli 1.0", entry.harness); + assert.equal(entry.live, undefined); + } + }); +} diff --git a/scripts/install-migration.mjs b/scripts/install-migration.mjs new file mode 100644 index 0000000..6618f45 --- /dev/null +++ b/scripts/install-migration.mjs @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { physicalPathKey } from "./install-paths.mjs"; + +const packageName = "@3metajun/mstack"; +const archivedDirectories = [".harness-skills-backups", ".harness-skills-failed", ".mstack-backups"]; + +function plainFile(path) { + return existsSync(path) && lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink(); +} + +function ownedSkill(path, name, digests) { + if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) return false; + const receipt = join(path, ".mstack-install.json"); + if (plainFile(receipt)) { + try { + const data = JSON.parse(readFileSync(receipt, "utf8")); + if (data.package === packageName && data.schemaVersion === 1) return true; + } catch {} + } + const skillFile = join(path, "SKILL.md"); + if (!plainFile(skillFile)) return false; + const digest = createHash("sha256").update(readFileSync(skillFile, "utf8").replaceAll("\r\n", "\n")).digest("hex"); + return digests.skills[name]?.includes(digest) ?? false; +} + +export function planSkillMigration({ repoRoot, skills, targets, nativeTargets, sharedRoot, claudeRoot, externalClaudeRoot, migrate, replace, enabled }) { + const result = { retirements: [], adaptations: [] }; + if (!enabled || skills.length === 0) return result; + const digests = JSON.parse(readFileSync(join(repoRoot, "profiles", "legacy-skill-digests.json"), "utf8")); + const directTargets = new Set(targets.map(({ target }) => physicalPathKey(target))); + const seen = new Set(); + const candidates = []; + const sharedKey = physicalPathKey(sharedRoot); + const claudeRoots = new Map(); + for (const root of [claudeRoot, externalClaudeRoot]) { + if (!root) continue; + const key = physicalPathKey(root); + if (key === sharedKey) throw new Error(`Claude and shared skills resolve to the same physical directory: ${root}; configure separate directories for Claude and shared skills`); + if (!claudeRoots.has(key)) claudeRoots.set(key, root); + } + for (const harness of ["opencode", "pi"]) { + const root = nativeTargets[harness]; + if (!root || physicalPathKey(root) === sharedKey) continue; + for (const name of skills) { + const target = join(root, name); + if (!existsSync(target) || directTargets.has(physicalPathKey(target)) || seen.has(physicalPathKey(target))) continue; + if (!ownedSkill(target, name, digests)) throw new Error(`Cannot migrate unrecognized skill at ${target}; preserve or relocate this local copy before retrying`); + seen.add(physicalPathKey(target)); + candidates.push({ kind: "retired", harness, name: `${harness}-${name}`, target }); + } + } + for (const root of claudeRoots.values()) { + for (const name of skills) { + const target = join(root, name); + if (!existsSync(target) || directTargets.has(physicalPathKey(target))) continue; + const skillFile = join(target, "SKILL.md"); + if (!plainFile(skillFile)) throw new Error(`Cannot inspect Claude skill at ${target}`); + const frontmatter = readFileSync(skillFile, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; + if (!frontmatter || !/^name\s*:/m.test(frontmatter)) continue; + if (!ownedSkill(target, name, digests)) throw new Error(`Cannot migrate unrecognized skill at ${target}; preserve or relocate this local copy before retrying`); + result.adaptations.push({ kind: "skill", harness: "claude", name, skill: name, source: target, target }); + } + } + const roots = new Map(); + for (const [harness, root] of Object.entries({ shared: sharedRoot, claude: claudeRoot, externalClaude: externalClaudeRoot, ...nativeTargets })) { + if (root && !roots.has(physicalPathKey(root))) roots.set(physicalPathKey(root), { harness, root }); + } + for (const { harness, root } of roots.values()) { + for (const directory of archivedDirectories) { + const target = join(root, directory); + if (!existsSync(target)) continue; + if (!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink()) throw new Error(`Cannot archive unexpected backup entry at ${target}`); + candidates.push({ kind: "retired", harness, name: `${harness}-${directory.slice(1)}`, target }); + } + if (!existsSync(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!/^(?:\.harness-skills-stage-|\.mstack-stage\.)/.test(entry.name)) continue; + const target = join(root, entry.name); + const skillFile = join(target, "SKILL.md"); + if (!existsSync(skillFile)) continue; + if (!plainFile(skillFile) || !entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error(`Cannot migrate unrecognized staged skill at ${target}; preserve or relocate this local copy before retrying`); + } + const content = readFileSync(skillFile, "utf8").replaceAll("\r\n", "\n"); + const frontmatter = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1]; + const declared = frontmatter?.match(/^name:\s*(?:"([a-z0-9-]+)"|'([a-z0-9-]+)'|([a-z0-9-]+))\s*(?:#.*)?$/m); + const digest = createHash("sha256").update(content).digest("hex"); + const name = declared?.slice(1).find(Boolean) ?? Object.keys(digests.skills).find((name) => digests.skills[name].includes(digest)); + if (name && !skills.includes(name)) continue; + if (!name || !ownedSkill(target, name, digests)) { + throw new Error(`Cannot migrate unrecognized staged skill at ${target}; preserve or relocate this local copy before retrying`); + } + candidates.push({ kind: "retired", harness, name: `${harness}-${entry.name.slice(1)}`, target }); + } + } + if (candidates.length || result.adaptations.length) { + if (!migrate || !replace) { + const paths = [...candidates, ...result.adaptations].map(({ target }) => target).join("\n"); + throw new Error(`Legacy skill copies remain discoverable. Run with --migrate --replace to back up and migrate:\n${paths}`); + } + } + result.retirements = candidates; + return result; +} diff --git a/scripts/install-migration.test.mjs b/scripts/install-migration.test.mjs new file mode 100644 index 0000000..df764ff --- /dev/null +++ b/scripts/install-migration.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { planSkillMigration } from "./install-migration.mjs"; + +const repository = resolve("."); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "mstack-migration-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const sharedRoot = join(root, ".agents", "skills"); + const claudeRoot = join(root, ".claude", "skills"); + const nativeTargets = { codex: sharedRoot, claude: claudeRoot, opencode: join(root, ".config", "opencode", "skills"), pi: join(root, ".pi", "agent", "skills") }; + const options = { repoRoot: repository, skills: ["meta-mode"], targets: [], nativeTargets, sharedRoot, claudeRoot, migrate: true, replace: true, enabled: true }; + function write(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + function skill(harness, name = "meta-mode", content = "---\nname: meta-mode\ndescription: Local content\n---\nPreserve this content\n", receipt = true) { + const path = join(nativeTargets[harness], name); + write(join(path, "SKILL.md"), content); + if (receipt) write(join(path, ".mstack-install.json"), JSON.stringify({ package: "@3metajun/mstack", schemaVersion: 1 })); + return path; + } + return { root, options, write, skill, plan: (overrides = {}) => planSkillMigration({ ...options, ...overrides }) }; +} + +test("recognized receipt copies retire without mutation and partial selection preserves other skills", (t) => { + const f = fixture(t); + const pi = f.skill("pi"); + const unrelated = f.skill("pi", "other"); + const opencode = f.skill("opencode"); + const result = f.plan(); + assert.deepEqual(result.retirements.map(({ target }) => target), [opencode, pi]); + assert.deepEqual(result.adaptations, []); + for (const path of [pi, opencode, unrelated]) assert.equal(existsSync(join(path, "SKILL.md")), true); +}); + +test("released content identifies an older copy even with CRLF and no receipt", (t) => { + const f = fixture(t); + // https://github.com/3metaJun/mstack/blob/v0.2.0/skills/bro/SKILL.md + const old = "---\nname: bro\ndescription: Restate the last message in plain human language, with no jargon.\n---\n\nRestate your last message. Stop using jargon and speak coherently. State it more simply and concisely, like one human talking to another.\n"; + const target = f.skill("pi", "bro", old.replaceAll("\n", "\r\n"), false); + assert.equal(f.plan({ skills: ["bro"] }).retirements[0].target, target); +}); + +test("unknown local copies reject the whole plan before any mutation", (t) => { + const f = fixture(t); + const recognized = f.skill("opencode"); + const unknown = f.skill("pi", "meta-mode", "---\nname: meta-mode\ndescription: My own skill\n---\nLocal\n", false); + const before = readFileSync(join(unknown, "SKILL.md"), "utf8"); + assert.throws(() => f.plan(), { message: `Cannot migrate unrecognized skill at ${unknown}; preserve or relocate this local copy before retrying` }); + assert.equal(readFileSync(join(unknown, "SKILL.md"), "utf8"), before); + assert.equal(existsSync(join(recognized, "SKILL.md")), true); +}); + +test("Claude adaptation uses existing content and skips directly selected destinations", (t) => { + const f = fixture(t); + const target = f.skill("claude"); + f.write(join(target, "notes.txt"), "Keep my supporting files"); + assert.deepEqual(f.plan().adaptations, [{ kind: "skill", harness: "claude", name: "meta-mode", skill: "meta-mode", source: target, target }]); + assert.equal(readFileSync(join(target, "notes.txt"), "utf8"), "Keep my supporting files"); + assert.deepEqual(f.plan({ targets: [{ target }] }).adaptations, []); +}); + +test("nameless Claude skill does not require migration or a receipt", (t) => { + const f = fixture(t); + f.skill("claude", "meta-mode", "---\ndescription: Claude skill\n---\nKeep this\n", false); + assert.deepEqual(f.plan({ migrate: false, replace: false }), { retirements: [], adaptations: [] }); +}); + +test("migration requires both flags and archives old backup trees outside the selected skill filter", (t) => { + const f = fixture(t); + const oldBackup = join(f.options.sharedRoot, ".harness-skills-backups"); + f.write(join(oldBackup, "old-run", "other", "SKILL.md"), "Old content"); + for (const flags of [{ migrate: false }, { replace: false }]) assert.throws(() => f.plan(flags), /--migrate --replace/); + assert.deepEqual(f.plan().retirements.map(({ target }) => target), [oldBackup]); + assert.equal(readFileSync(join(oldBackup, "old-run", "other", "SKILL.md"), "utf8"), "Old content"); +}); + +test("disabled discovery migration does not inspect unknown custom copies", (t) => { + const f = fixture(t); + f.skill("pi", "meta-mode", "Unrecognized local skill", false); + assert.deepEqual(f.plan({ enabled: false }), { retirements: [], adaptations: [] }); + assert.deepEqual(f.plan({ skills: [] }), { retirements: [], adaptations: [] }); +}); + +test("digest provenance includes actual released canonical and Claude adapter content", () => { + const data = JSON.parse(readFileSync(join(repository, "profiles", "legacy-skill-digests.json"), "utf8")); + assert.deepEqual(data.sources, ["v0.2.0", "v0.2.1", "v0.3.0", "v0.4.0"]); + for (const name of ["meta-mode", "show-me-your-work"]) { + const text = readFileSync(join(repository, "skills", name, "SKILL.md"), "utf8").replaceAll("\r\n", "\n"); + const hash = createHash("sha256").update(text).digest("hex"); + assert.equal(data.skills[name].includes(hash), true, name); + } + const canonical = readFileSync(join(repository, "skills", "show-me-your-work", "SKILL.md"), "utf8").replaceAll("\r\n", "\n"); + const claude = canonical.replace( + "metadata:\n requirements: Node.js 18 or newer for scripts/log.mjs\n", + 'compatibility: "Node.js 18 or newer is required for scripts/log.mjs."\n', + ); + assert.notEqual(claude, canonical); + assert.equal(data.skills["show-me-your-work"].includes(createHash("sha256").update(claude).digest("hex")), true); +}); + +test("native roots physically aliasing shared skills cannot retire canonical copies", (t) => { + const f = fixture(t); + const shared = f.skill("codex"); + for (const harness of ["pi", "opencode"]) { + mkdirSync(dirname(f.options.nativeTargets[harness]), { recursive: true }); + symlinkSync(f.options.sharedRoot, f.options.nativeTargets[harness], process.platform === "win32" ? "junction" : "dir"); + } + assert.deepEqual(f.plan({ migrate: false, replace: false }), { retirements: [], adaptations: [] }); + assert.equal(existsSync(join(shared, "SKILL.md")), true); +}); + +test("Claude physical aliases of shared skills require separate directories", (t) => { + const f = fixture(t); + f.skill("codex"); + mkdirSync(dirname(f.options.claudeRoot), { recursive: true }); + symlinkSync(f.options.sharedRoot, f.options.claudeRoot, process.platform === "win32" ? "junction" : "dir"); + assert.throws(() => f.plan(), /configure separate directories for Claude and shared skills/); +}); + +test("physical aliases deduplicate direct targets and Claude discovery roots", (t) => { + const f = fixture(t); + const target = f.skill("claude"); + const alias = join(f.root, "claude-alias"); + symlinkSync(f.options.claudeRoot, alias, process.platform === "win32" ? "junction" : "dir"); + assert.equal(f.plan({ externalClaudeRoot: alias }).adaptations.length, 1); + assert.deepEqual(f.plan({ externalClaudeRoot: alias, targets: [{ target: join(alias, "meta-mode") }] }).adaptations, []); + assert.equal(existsSync(join(target, "SKILL.md")), true); +}); + +test("configured Claude root does not hide the external default root from migration", (t) => { + const f = fixture(t); + const external = f.skill("claude"); + const configuredRoot = join(f.root, "custom-claude", "skills"); + const configured = join(configuredRoot, "meta-mode"); + f.write(join(configured, "SKILL.md"), "---\nname: meta-mode\ndescription: Configured Claude skill\n---\nKeep configured body\n"); + f.write(join(configured, ".mstack-install.json"), JSON.stringify({ package: "@3metajun/mstack", schemaVersion: 1 })); + const backup = join(f.options.claudeRoot, ".harness-skills-backups"); + f.write(join(backup, "old", "SKILL.md"), "Preserve external backup"); + const result = f.plan({ claudeRoot: configuredRoot, externalClaudeRoot: f.options.claudeRoot, nativeTargets: { ...f.options.nativeTargets, claude: configuredRoot } }); + assert.deepEqual(result.adaptations.map(({ target }) => target), [configured, external]); + assert.deepEqual(result.retirements.map(({ target }) => target), [backup]); +}); diff --git a/scripts/install-paths.mjs b/scripts/install-paths.mjs index d145d92..74576f8 100644 --- a/scripts/install-paths.mjs +++ b/scripts/install-paths.mjs @@ -1,3 +1,4 @@ +import { existsSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; export function localPathKey(value, platform = process.platform) { @@ -5,6 +6,13 @@ export function localPathKey(value, platform = process.platform) { return platform === "win32" || platform === "darwin" ? normalized.toLowerCase() : normalized; } +export function physicalPathKey(value) { + const absolute = resolve(value); + let ancestor = absolute; + while (!existsSync(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor); + return localPathKey(resolve(realpathSync(ancestor), relative(ancestor, absolute))); +} + export function pathIsWithin(root, candidate, platform = process.platform) { const difference = relative(localPathKey(root, platform), localPathKey(candidate, platform)); return difference === "" || (!isAbsolute(difference) && difference !== ".." && !difference.startsWith(`..${sep}`)); diff --git a/scripts/install.mjs b/scripts/install.mjs index 97c7468..7cbdb39 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -20,11 +20,14 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { readEnvironment } from "./environment-lib.mjs"; +import { configuredPath, resolveHarnessRoots } from "./harness-targets.mjs"; +import { planSkillMigration } from "./install-migration.mjs"; import { convertAgentMarkdown } from "./agent-format.mjs"; import { artifactPathParts, localPathKey, pathIsWithin, + physicalPathKey, stagingPath, targetPathsOverlap, validateArtifactOverrides, @@ -57,6 +60,8 @@ if (args.includes("--help") || !args.includes("--harness")) { Installs canonical skills and optional artifacts into user-level harness directories. Artifacts: ${validArtifacts.join(", ")} (use --artifact all for installable artifacts). +Codex, OpenCode and pi share ~/.agents/skills by default. Claude uses directory-based names. +Use --migrate --replace to back up recognized legacy copies outside skill discovery roots. Existing directories are preserved unless --replace is supplied.`); process.exit(args.includes("--help") ? 0 : 1); } @@ -73,6 +78,8 @@ if (new Set(requestedHarnesses).size !== requestedHarnesses.length) { const harnesses = [...requestedHarnesses]; const dryRun = args.includes("--dry-run"); const replace = args.includes("--replace"); +const migrate = args.includes("--migrate"); +if (migrate && !replace) throw new Error("--migrate requires --replace to preserve legacy copies in backups"); const userHome = homedir(); const hasEnvironment = args.includes("--environment"); const environmentName = valueAfter("--environment"); @@ -80,30 +87,12 @@ if (hasEnvironment && (!environmentName || environmentName.startsWith("--"))) { throw new Error("--environment requires a name"); } -function expandHome(path) { - if (path === "~") return userHome; - if (/^~[\\/]/.test(path)) return join(userHome, path.slice(2)); - return path; -} - -function configuredPath(value, fallback, label) { - const expanded = expandHome(value ?? fallback); - if (value && !isAbsolute(expanded)) { - throw new Error(`${label} must be an absolute path or start with ~/`); - } - return resolve(expanded); -} - -function pathFromParts(parts) { - return parts.reduce((current, part) => join(current, part), userHome); -} - function artifactBase(name, harness) { const base = artifactRegistry[name]?.harnesses?.[harness]?.base; if (base === "codex-home") { return configuredPath(process.env.CODEX_HOME || undefined, join(userHome, ".codex"), "CODEX_HOME"); } - return dirname(targets[harness]); + return dirname(name === "agents" ? nativeTargets[harness] : targets[harness]); } const environment = readEnvironment(environmentName, harnesses); @@ -118,23 +107,11 @@ if (environment.transport === "ssh") { } const environmentTargets = environment.targets; const environmentArtifacts = environment.artifacts; -function harnessTarget(harness) { - const config = harnessRegistry[harness]; - if (environmentTargets[harness]) { - return configuredPath(environmentTargets[harness], "", `${environmentName}.${harness}`); - } - const directory = process.env[config.directoryVariable]; - if (directory) return configuredPath(directory, "", config.directoryVariable); - - if (config.fallback) return pathFromParts(config.fallback); - - const configRoot = process.env[config.configVariable] - ? configuredPath(process.env[config.configVariable], "", config.configVariable) - : pathFromParts([config.configFallback]); - return join(configRoot, ...(config.prefix ?? []), ...(config.suffix ?? [])); -} - -const targets = Object.fromEntries(validHarnesses.map((harness) => [harness, harnessTarget(harness)])); +const { skills: targets, native: nativeTargets, legacy: legacyTargets, sharedRoot, externalClaudeRoot } = + resolveHarnessRoots(harnessRegistry, { home: userHome, environmentTargets, environmentName }); +const discoveryRoots = [...new Set([ + ...Object.values(targets), ...Object.values(nativeTargets), ...Object.values(legacyTargets), sharedRoot, externalClaudeRoot, +].map(physicalPathKey))]; function artifactEnvironmentPath(name, harness) { const byArtifact = environmentArtifacts[name]; @@ -177,7 +154,7 @@ function artifactTarget(name, harness) { return target; } -const targetKeys = harnesses.map((harness) => localPathKey(targets[harness])); +const targetKeys = [...new Set(harnesses.map((harness) => physicalPathKey(targets[harness])))]; const adapters = Object.fromEntries( validHarnesses.map((harness) => [ @@ -220,7 +197,11 @@ function parseFrontmatter(content, path) { } function applyAdapter(skillPath, harness, skill) { - const removedFields = adapters[harness].removeFrontmatter?.[skill] ?? []; + // Claude falls back to the directory name; OpenCode requires an explicit name. + const removedFields = [ + ...(adapters[harness].directoryName ? ["name"] : []), + ...(adapters[harness].removeFrontmatter?.[skill] ?? []), + ]; const fields = adapters[harness].frontmatter?.[skill]; if (!fields && !removedFields.length) return; @@ -233,12 +214,14 @@ function applyAdapter(skillPath, harness, skill) { writeFileSync(path, content.replace(match[0], `---\n${adaptedFrontmatter}\n---\n`), "utf8"); } -function validateAdaptedSkill(skillPath, expectedName) { +function validateAdaptedSkill(skillPath, expectedName, harness) { const path = join(skillPath, "SKILL.md"); const frontmatter = parseFrontmatter(readFileSync(path, "utf8"), path)[1]; const keys = [...frontmatter.matchAll(/^([A-Za-z][A-Za-z0-9_-]*):/gm)].map((match) => match[1]); if (new Set(keys).size !== keys.length) throw new Error(`${path} has duplicate frontmatter keys`); - if (!frontmatter.includes(`name: ${expectedName}`)) throw new Error(`${path} lost its name`); + if (adapters[harness].directoryName) { + if (keys.includes("name")) throw new Error(`${path} must use its directory name`); + } else if (!frontmatter.includes(`name: ${expectedName}`)) throw new Error(`${path} lost its name`); if (!/^description:\s*.+$/m.test(frontmatter)) throw new Error(`${path} lost its description`); } @@ -376,7 +359,7 @@ const rawArtifactPlan = harnesses.flatMap((harness) => const artifactPlan = []; const artifactTargetsByPath = new Map(); for (const item of rawArtifactPlan) { - const key = localPathKey(item.target); + const key = physicalPathKey(item.target); const existing = artifactTargetsByPath.get(key); if (existing) { if (existing.name === item.name && existing.source === item.source) { @@ -392,14 +375,15 @@ for (const item of rawArtifactPlan) { } const allTargetKeys = [ ...targetKeys, - ...artifactPlan.map(({ target }) => localPathKey(target)), + ...artifactPlan.map(({ target }) => physicalPathKey(target)), ]; if (new Set(allTargetKeys).size !== allTargetKeys.length) { throw new Error("Selected skills and artifacts resolve to the same target directory"); } -const plan = [ - ...harnesses.flatMap((harness) => +const skillPlan = []; +const skillsByTarget = new Map(); +for (const item of harnesses.flatMap((harness) => skills.map((skill) => ({ kind: "skill", harness, @@ -408,10 +392,37 @@ const plan = [ source: join(sourceRoot, skill), target: join(targets[harness], skill), })), - ), - ...artifactPlan, -]; + )) { + const key = physicalPathKey(item.target); + const signature = (harness) => JSON.stringify({ + directoryName: adapters[harness].directoryName ?? false, + remove: adapters[harness].removeFrontmatter?.[item.skill] ?? [], + fields: adapters[harness].frontmatter?.[item.skill] ?? {}, + }); + const previous = skillsByTarget.get(key); + if (previous) { + if (signature(previous.harness) !== signature(item.harness)) { + throw new Error(`Selected skills resolve to the same target directory with incompatible adapters: ${item.target}`); + } + previous.consumers.push(item.harness); + } else { + item.consumers = [item.harness]; + skillsByTarget.set(key, item); + skillPlan.push(item); + } +} +const migrationEnabled = skills.length > 0 && !hasEnvironment && skillPlan.some((item) => + item.harness !== "claude" && localPathKey(dirname(item.target)) === localPathKey(sharedRoot)); +if (migrate && !migrationEnabled) { + throw new Error("--migrate requires a local default shared skill target; run the installer on the target machine without --environment"); +} +const migration = planSkillMigration({ + repoRoot, skills, targets: skillPlan, nativeTargets: legacyTargets, sharedRoot, + claudeRoot: legacyTargets.claude, externalClaudeRoot, migrate, replace, enabled: migrationEnabled, +}); +const plan = [...skillPlan, ...migration.adaptations, ...artifactPlan, ...migration.retirements]; if (plan.length === 0) throw new Error("Nothing selected: choose skills or --artifact"); +const physicalTargets = plan.map((item) => physicalPathKey(item.target)); for (const item of plan) { if (dirname(item.target) === item.target) { throw new Error(`Install target cannot be a filesystem root: ${item.target}`); @@ -419,24 +430,34 @@ for (const item of plan) { } for (let left = 0; left < plan.length; left += 1) { for (let right = left + 1; right < plan.length; right += 1) { - if (targetPathsOverlap(plan[left].target, plan[right].target)) { + if (targetPathsOverlap(physicalTargets[left], physicalTargets[right])) { throw new Error( `Selected install targets overlap: ${plan[left].target} and ${plan[right].target}`, ); } } } -for (const source of new Set(plan.map((item) => item.source))) validateSourceTree(source); +for (const source of new Set(plan.map((item) => item.source).filter(Boolean))) validateSourceTree(source); const conflicts = plan.filter(({ target }) => existsSync(target)); +const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); +const transactionId = `${stamp}-${process.pid}`; +for (const item of plan) { + for (const category of ["backups", "failed", "stage"]) transactionPath(item, category); +} for (const harness of harnesses) console.log(`${harness}: ${targets[harness]}`); +for (const item of skillPlan.filter((item) => item.consumers.length > 1)) { + console.log(` shared ${item.name}: ${item.target} (${item.consumers.join(", ")})`); +} for (const item of plan.filter(({ kind }) => kind === "artifact")) { console.log(` artifact ${item.name}: ${item.target}`); } if (dryRun) { for (const item of plan) { - const action = existsSync(item.target) ? (replace ? "replace with backup" : "conflict") : "install"; - console.log(` ${action}: ${item.kind === "skill" ? item.skill : `artifact/${item.name}`}`); + const action = item.kind === "retired" ? "migrate to backup outside skills" : + existsSync(item.target) ? (replace ? "replace with backup" : "conflict") : "install"; + console.log(` ${action}: ${item.kind === "skill" ? item.skill : `${item.kind}/${item.name}`} -> ${item.target}`); + if (replace && existsSync(item.target)) console.log(` backup -> ${transactionPath(item, "backups")}`); } if (conflicts.length && !replace) process.exitCode = 2; else console.log("Dry run complete."); @@ -450,13 +471,25 @@ if (conflicts.length && !replace) { ); } -const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); -const transactionId = `${stamp}-${process.pid}`; const lockOwner = `${process.pid} ${transactionId}`; const locks = []; const stagedPaths = []; const committed = []; +function transactionPath(item, category) { + const parent = dirname(item.target); + // Recursive discovery includes dot-directories, so no skill transaction data may live under skills/. + const root = item.kind === "artifact" ? parent : dirname(parent); + const path = join(root, `.harness-skills-${category}`, basename(parent), transactionId, item.name); + const physical = physicalPathKey(path); + for (const skillsRoot of discoveryRoots) { + if (pathIsWithin(skillsRoot, physical)) { + throw new Error(`Transaction storage would be discoverable as skills: ${path}; choose non-nested skill targets`); + } + } + return path; +} + function processIsAlive(pid) { try { process.kill(pid, 0); @@ -548,8 +581,7 @@ function rollbackCommitted() { let failed; try { if (existsSync(item.target)) { - const failedName = item.kind === "skill" ? item.name : `artifact-${item.harness}-${item.name}`; - failed = join(dirname(item.target), ".harness-skills-failed", stamp, failedName); + failed = transactionPath(item, "failed"); mkdirSync(dirname(failed), { recursive: true, mode: 0o700 }); renameSync(item.target, failed); } @@ -577,7 +609,7 @@ try { const lockRootsByPath = new Map(); for (const item of plan) { const lockRoot = dirname(item.target); - const key = localPathKey(lockRoot); + const key = physicalPathKey(lockRoot); if (!lockRootsByPath.has(key)) lockRootsByPath.set(key, lockRoot); } const lockRoots = [...lockRootsByPath.values()].sort((left, right) => left.localeCompare(right)); @@ -589,14 +621,19 @@ try { } for (const [index, item] of plan.entries()) { + if (item.kind === "retired") continue; mkdirSync(dirname(item.target), { recursive: true }); - const staged = stagingPath(item.target, transactionId, index); + const staged = item.kind === "artifact" ? stagingPath(item.target, transactionId, index) : transactionPath(item, "stage"); + mkdirSync(dirname(staged), { recursive: true }); stagedPaths.push(staged); copyDirectoryContents(item.source, staged); if (item.kind === "artifact") applyArtifactAdapter(staged, item); if (item.kind === "skill") { applyAdapter(staged, item.harness, item.skill); - validateAdaptedSkill(staged, item.skill); + validateAdaptedSkill(staged, item.skill, item.harness); + writeFileSync(join(staged, ".mstack-install.json"), JSON.stringify({ + package: "@3metajun/mstack", schemaVersion: 1, + }) + "\n", "utf8"); } item.staged = staged; } @@ -609,14 +646,15 @@ try { let backup; if (existsSync(target)) { - const backupName = item.kind === "skill" ? item.name : `artifact-${item.harness}-${item.name}`; - backup = join(dirname(target), ".harness-skills-backups", stamp, backupName); + backup = transactionPath(item, "backups"); mkdirSync(dirname(backup), { recursive: true, mode: 0o700 }); renameSync(target, backup); } const entry = { ...item, harness, target, backup }; committed.push(entry); + if (backup) console.log(` backup: ${target} -> ${backup}`); + if (item.kind === "retired") continue; mkdirSync(dirname(target), { recursive: true }); renameSync(item.staged, target); } @@ -637,5 +675,5 @@ try { } const skillCount = plan.filter(({ kind }) => kind === "skill").length; -const artifactCount = plan.length - skillCount; -console.log(`Installed ${skillCount} skill copies and ${artifactCount} artifact copies; replaced ${conflicts.length}.`); +const artifactCount = artifactPlan.length; +console.log(`Installed ${skillCount} skill copies and ${artifactCount} artifact copies; replaced ${conflicts.length}; retired ${migration.retirements.length} legacy entries.`); diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs index cc925a8..bca1fdc 100644 --- a/scripts/install.test.mjs +++ b/scripts/install.test.mjs @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; import { localPathKey, stagingPath, targetPathsOverlap } from "./install-paths.mjs"; @@ -89,6 +89,23 @@ test("treats local macOS targets as case-insensitive", () => { assert.notEqual(localPathKey(upper, "linux"), localPathKey(lower, "linux")); }); +test("rejects transaction storage reached through a discovery-root alias before writing", () => { + const { root, env } = fixture(); + const discovery = env.HARNESS_SKILLS_CODEX_DIR; + const alias = join(root, "alias"); + try { + mkdirSync(discovery, { recursive: true }); + symlinkSync(discovery, alias, process.platform === "win32" ? "junction" : "dir"); + env.HARNESS_SKILLS_PI_DIR = join(alias, "custom"); + const result = run(["--harness", "pi", "--skill", "meta-mode", "--replace", "--dry-run"], env); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Transaction storage would be discoverable/); + assert.deepEqual(readdirSync(discovery), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("dry-run reports a plan without creating target directories", () => { const { root, env } = fixture(); try { @@ -132,7 +149,7 @@ test("recovers a dead installer lock but preserves a live lock", () => { } }); -test("uses pi's agent directory for the default skill target", () => { +test("uses the shared directory for pi skills", () => { const { env } = fixture(); env.USERPROFILE = join(env.TEMP ?? tmpdir(), "mstack-pi-home"); env.HOME = env.USERPROFILE; @@ -140,7 +157,7 @@ test("uses pi's agent directory for the default skill target", () => { const result = run(["--harness", "pi", "--skill", "meta-mode", "--dry-run"], env); assert.equal(result.status, 0, result.stderr); - assert.ok(result.stdout.includes(join(env.USERPROFILE, ".pi", "agent", "skills"))); + assert.ok(result.stdout.includes(join(env.USERPROFILE, ".agents", "skills"))); }); test("installs harness-specific frontmatter and preserves conflicts", () => { @@ -176,7 +193,7 @@ test("installs harness-specific frontmatter and preserves conflicts", () => { const replaced = run(["--harness", "codex", "--replace"], env); assert.equal(replaced.status, 0, replaced.stderr); assert.equal(existsSync(marker), false); - const backupRoot = join(env.HARNESS_SKILLS_CODEX_DIR, ".harness-skills-backups"); + const backupRoot = join(dirname(env.HARNESS_SKILLS_CODEX_DIR), ".harness-skills-backups", basename(env.HARNESS_SKILLS_CODEX_DIR)); const backupMarker = findFile(backupRoot, "marker.txt"); assert.equal(readFileSync(backupMarker, "utf8"), "keep me"); } finally { @@ -184,7 +201,7 @@ test("installs harness-specific frontmatter and preserves conflicts", () => { } }); -test("keeps skill backups and failed replacements beside their actual targets", () => { +test("keeps skill backups and failed replacements outside discovery roots", () => { const { root, env } = fixture(); const codexTarget = join(env.HARNESS_SKILLS_CODEX_DIR, "meta-mode"); const claudeTarget = join(env.HARNESS_SKILLS_CLAUDE_DIR, "meta-mode"); @@ -192,7 +209,8 @@ test("keeps skill backups and failed replacements beside their actual targets", mkdirSync(claudeTarget, { recursive: true }); writeFileSync(join(codexTarget, "marker.txt"), "old codex", "utf8"); writeFileSync(join(claudeTarget, "marker.txt"), "old claude", "utf8"); - writeFileSync(join(env.HARNESS_SKILLS_CLAUDE_DIR, ".harness-skills-backups"), "block", "utf8"); + mkdirSync(join(root, ".harness-skills-backups"), { recursive: true }); + writeFileSync(join(dirname(env.HARNESS_SKILLS_CLAUDE_DIR), ".harness-skills-backups", basename(env.HARNESS_SKILLS_CLAUDE_DIR)), "block", "utf8"); try { const result = run( @@ -203,10 +221,10 @@ test("keeps skill backups and failed replacements beside their actual targets", assert.equal(readFileSync(join(codexTarget, "marker.txt"), "utf8"), "old codex"); assert.equal(readFileSync(join(claudeTarget, "marker.txt"), "utf8"), "old claude"); assert.ok( - findFile(join(env.HARNESS_SKILLS_CODEX_DIR, ".harness-skills-failed"), "SKILL.md"), - "expected the rolled-back replacement beside the Codex skill target", + findFile(join(dirname(env.HARNESS_SKILLS_CODEX_DIR), ".harness-skills-failed", basename(env.HARNESS_SKILLS_CODEX_DIR)), "SKILL.md"), + "expected the rolled-back replacement outside the Codex skill root", ); - assert.equal(existsSync(join(root, ".harness-skills-failed")), false); + assert.equal(existsSync(join(env.HARNESS_SKILLS_CODEX_DIR, ".harness-skills-failed")), false); } finally { rmSync(root, { recursive: true, force: true }); } @@ -224,8 +242,10 @@ test("continues rollback after one target cannot be restored", () => { mkdirSync(target, { recursive: true }); writeFileSync(join(target, "marker.txt"), `old ${harness}`, "utf8"); } - writeFileSync(join(env.HARNESS_SKILLS_CLAUDE_DIR, ".harness-skills-failed"), "block rollback", "utf8"); - writeFileSync(join(env.HARNESS_SKILLS_OPENCODE_DIR, ".harness-skills-backups"), "block commit", "utf8"); + mkdirSync(join(root, ".harness-skills-backups"), { recursive: true }); + mkdirSync(join(root, ".harness-skills-failed"), { recursive: true }); + writeFileSync(join(dirname(env.HARNESS_SKILLS_CLAUDE_DIR), ".harness-skills-failed", basename(env.HARNESS_SKILLS_CLAUDE_DIR)), "block rollback", "utf8"); + writeFileSync(join(dirname(env.HARNESS_SKILLS_OPENCODE_DIR), ".harness-skills-backups", basename(env.HARNESS_SKILLS_OPENCODE_DIR)), "block commit", "utf8"); try { const result = run( @@ -241,7 +261,7 @@ test("continues rollback after one target cannot be restored", () => { assert.equal(readFileSync(join(targets.codex, "marker.txt"), "utf8"), "old codex"); assert.equal(readFileSync(join(targets.opencode, "marker.txt"), "utf8"), "old opencode"); assert.equal( - readFileSync(findFile(join(env.HARNESS_SKILLS_CLAUDE_DIR, ".harness-skills-backups"), "marker.txt"), "utf8"), + readFileSync(findFile(join(dirname(env.HARNESS_SKILLS_CLAUDE_DIR), ".harness-skills-backups", basename(env.HARNESS_SKILLS_CLAUDE_DIR)), "marker.txt"), "utf8"), "old claude", ); } finally { diff --git a/scripts/migration-integration.test.mjs b/scripts/migration-integration.test.mjs new file mode 100644 index 0000000..3a3a0f2 --- /dev/null +++ b/scripts/migration-integration.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const installer = resolve("scripts", "install.mjs"); +const canonical = readFileSync(resolve("skills", "meta-mode", "SKILL.md"), "utf8"); + +function snapshot(root) { + const result = {}; + function visit(path) { + for (const entry of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const child = join(path, entry.name); + result[relative(root, child)] = entry.isDirectory() ? null : readFileSync(child, "utf8"); + if (entry.isDirectory()) visit(child); + } + } + visit(root); + return result; +} + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "mstack-migration-integration-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !/^(HARNESS_SKILLS_|MSTACK_|CLAUDE_|CODEX_|PI_CODING_AGENT_DIR$|XDG_|NODE_OPTIONS$)/i.test(key))); + Object.assign(env, { HOME: root, USERPROFILE: root, APPDATA: join(root, "appdata"), LOCALAPPDATA: join(root, "localappdata") }); + const roots = { + shared: join(root, ".agents", "skills"), + claude: join(root, ".claude", "skills"), + opencode: join(root, ".config", "opencode", "skills"), + pi: join(root, ".pi", "agent", "skills"), + }; + function write(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + function installOld(harness, { content = canonical, receipt = false, name = "meta-mode" } = {}) { + const path = join(roots[harness], name); + write(join(path, "SKILL.md"), content); + write(join(path, "local-support.txt"), `${harness} supporting content`); + if (receipt) write(join(path, ".mstack-install.json"), JSON.stringify({ package: "@3metajun/mstack", schemaVersion: 1 })); + return path; + } + function run(flags = []) { + return spawnSync(process.execPath, [installer, "--harness", "codex", "--skill", "meta-mode", "--replace", ...flags], { cwd: root, env, encoding: "utf8" }); + } + return { root, roots, write, installOld, run }; +} + +test("migration dry-run leaves the isolated home unchanged", (t) => { + const f = fixture(t); + f.installOld("opencode"); + f.installOld("pi"); + f.installOld("claude"); + const before = snapshot(f.root); + const result = f.run(["--migrate", "--dry-run"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /migrate to backup outside skills/); + assert.deepEqual(snapshot(f.root), before); +}); + +test("migration retires redundant copies and old backups while preserving partial Claude content", (t) => { + const f = fixture(t); + const opencode = f.installOld("opencode"); + const pi = f.installOld("pi"); + const claudeContent = "---\nname: meta-mode\ndescription: Locally configured Claude workflow\n---\nKeep this local body.\n"; + const claude = f.installOld("claude", { content: claudeContent, receipt: true }); + const unselected = f.installOld("pi", { name: "local-only", content: "Preserve this unrelated skill" }); + f.write(join(unselected, "local-support.txt"), "Unselected supporting content"); + const oldBackup = join(f.roots.shared, ".harness-skills-backups"); + f.write(join(oldBackup, "old", "meta-mode", "SKILL.md"), "Historical backup"); + const result = f.run(["--migrate"]); + assert.equal(result.status, 0, result.stderr); + for (const path of [opencode, pi, oldBackup]) assert.equal(existsSync(path), false, path); + assert.equal(readFileSync(join(unselected, "SKILL.md"), "utf8"), "Preserve this unrelated skill"); + assert.equal(readFileSync(join(claude, "SKILL.md"), "utf8"), claudeContent.replace("name: meta-mode\n", "")); + assert.equal(readFileSync(join(claude, "local-support.txt"), "utf8"), "claude supporting content"); + assert.equal(readFileSync(join(f.roots.shared, "meta-mode", "SKILL.md"), "utf8"), canonical); + const files = snapshot(f.root); + for (const content of ["opencode supporting content", "pi supporting content", "Historical backup"]) { + const matches = Object.entries(files).filter(([, value]) => value === content); + assert.equal(matches.length, 1, content); + assert.match(matches[0][0], /\.harness-skills-backups/); + for (const root of Object.values(f.roots)) { + assert.equal(join(f.root, matches[0][0]).startsWith(`${root}\\`) || join(f.root, matches[0][0]).startsWith(`${root}/`), false); + } + } + const again = f.run(); + assert.equal(again.status, 0, again.stderr); + assert.match(again.stdout, /retired 0 legacy entries/); +}); + +test("unrecognized legacy copy prevents shared installation and all migration writes", (t) => { + const f = fixture(t); + f.installOld("opencode"); + const unknown = f.installOld("pi", { content: "---\nname: meta-mode\ndescription: Unrelated workflow\n---\nMine\n" }); + const before = snapshot(f.root); + const result = f.run(["--migrate"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot migrate unrecognized skill/); + assert.ok(result.stderr.includes(unknown)); + assert.deepEqual(snapshot(f.root), before); +}); + +test("a later retirement failure restores previously retired copies and Claude content", (t) => { + const f = fixture(t); + const opencode = f.installOld("opencode"); + const pi = f.installOld("pi"); + const claude = f.installOld("claude"); + f.write(join(dirname(f.roots.pi), ".harness-skills-backups"), "Block the later pi backup directory"); + const result = f.run(["--migrate"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Rollback results:/); + assert.ok(result.stdout.includes(`backup: ${opencode} ->`), "OpenCode retirement must have committed before the injected failure"); + for (const [harness, target] of [["opencode", opencode], ["pi", pi], ["claude", claude]]) { + assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), canonical); + assert.equal(readFileSync(join(target, "local-support.txt"), "utf8"), `${harness} supporting content`); + } + assert.equal(existsSync(join(f.roots.shared, "meta-mode")), false); +}); + +test("migration archives authenticated crashed stages and preserves unselected or empty stages", (t) => { + const f = fixture(t); + const localStage = join(f.roots.shared, ".harness-skills-stage-old-0"); + const remoteStage = join(f.roots.opencode, ".mstack-stage.old"); + const unselectedStage = join(f.roots.pi, ".mstack-stage.unselected"); + const emptyStage = join(f.roots.shared, ".harness-skills-stage-empty"); + f.write(join(localStage, "SKILL.md"), canonical); + f.write(join(localStage, "support.txt"), "Preserve crashed local support"); + f.write(join(remoteStage, "SKILL.md"), "---\nname: meta-mode\ndescription: Customized installed copy\n---\nRemote stage body\n"); + f.write(join(remoteStage, ".mstack-install.json"), JSON.stringify({ package: "@3metajun/mstack", schemaVersion: 1 })); + f.write(join(unselectedStage, "SKILL.md"), "---\nname: local-only\ndescription: Unselected staged skill\n---\nUnselected\n"); + f.write(join(emptyStage, "partial.txt"), "No discoverable skill"); + const result = f.run(["--migrate"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(localStage), false); + assert.equal(existsSync(remoteStage), false); + assert.equal(existsSync(join(unselectedStage, "SKILL.md")), true); + assert.equal(readFileSync(join(emptyStage, "partial.txt"), "utf8"), "No discoverable skill"); + const files = snapshot(f.root); + const retainedSupport = Object.entries(files).filter(([, content]) => content === "Preserve crashed local support"); + assert.equal(retainedSupport.length, 1); + assert.match(retainedSupport[0][0], /\.harness-skills-backups/); + const again = f.run(); + assert.equal(again.status, 0, again.stderr); +}); + +test("unrecognized selected crashed stage rejects migration before any write", (t) => { + const f = fixture(t); + f.installOld("opencode"); + const stage = join(f.roots.shared, ".harness-skills-stage-unknown"); + f.write(join(stage, "SKILL.md"), "---\nname: meta-mode\ndescription: Unrecognized staged copy\n---\nKeep mine\n"); + const before = snapshot(f.root); + const result = f.run(["--migrate"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot migrate unrecognized staged skill/); + assert.ok(result.stderr.includes(stage)); + assert.deepEqual(snapshot(f.root), before); +}); diff --git a/scripts/remote-discovery.test.mjs b/scripts/remote-discovery.test.mjs new file mode 100644 index 0000000..4e97bb2 --- /dev/null +++ b/scripts/remote-discovery.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const installer = resolve("scripts/remote-install.mjs"); + +function fixture(t, targets) { + const root = mkdtempSync(join(tmpdir(), "mstack-remote-discovery-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const config = join(root, "environments.json"); + const helper = join(root, "transport.mjs"); + const log = join(root, "calls.jsonl"); + writeFileSync(helper, [ + 'import { appendFileSync } from "node:fs";', + 'const [mode, ...args] = process.argv.slice(2);', + 'appendFileSync(process.env.MSTACK_TEST_REMOTE_LOG, JSON.stringify({ mode, args }) + "\\n");', + 'const script = args.at(-1) ?? "";', + 'if (mode === "ssh" && script.includes("mktemp -d")) {', + ' const template = script.match(/mktemp -d \'([^\']+)\'/)?.[1];', + ' if (!template) process.exit(2);', + ' console.log(template.replace("XXXXXX", "fixture"));', + '}', + ].join("\n")); + writeFileSync(config, JSON.stringify({ + fleet: { + transport: "ssh", + host: "dev@example.invalid", + targets, + sshCommand: process.execPath, + sshArgs: [helper, "ssh"], + rsyncCommand: process.execPath, + rsyncArgs: [helper, "rsync"], + }, + })); + return { + run(args) { + return spawnSync(process.execPath, [installer, "--environment", "fleet", ...args], { + cwd: resolve("."), + env: { ...process.env, MSTACK_ENVIRONMENTS_FILE: config, MSTACK_TEST_REMOTE_LOG: log }, + encoding: "utf8", + }); + }, + calls() { + return existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : []; + }, + }; +} + +test("remote shared canonical skills are planned once without connecting", (t) => { + const f = fixture(t, { + codex: "/home/dev/.agents/skills", + opencode: "/home/dev/.agents/skills", + pi: "/home/dev/.agents/skills", + }); + const result = f.run(["--harness", "codex,opencode,pi", "--skill", "meta-mode", "--dry-run"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Planned 1 skill copies and 0 artifact copies/); + assert.equal(result.stdout.split("skill/meta-mode ->").length - 1, 1); + assert.deepEqual(f.calls(), []); +}); + +test("remote Claude and canonical skill outputs cannot share a destination in either order", (t) => { + const f = fixture(t, { codex: "/home/dev/shared/skills", claude: "/home/dev/shared/skills" }); + for (const harnesses of ["codex,claude", "claude,codex"]) { + const result = f.run(["--harness", harnesses, "--skill", "meta-mode", "--dry-run"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Remote target collision/); + } + assert.deepEqual(f.calls(), []); +}); + +test("remote migration is rejected before connecting even in dry run", (t) => { + const f = fixture(t, { pi: "/home/dev/.agents/skills" }); + for (const extra of [[], ["--dry-run"]]) { + const result = f.run(["--harness", "pi", "--skill", "meta-mode", "--migrate", ...extra]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /--migrate is not supported over SSH/); + assert.match(result.stderr, /local installer.*remote machine/); + } + assert.deepEqual(f.calls(), []); +}); + +test("remote discovery at filesystem root is rejected before connecting", (t) => { + const f = fixture(t, { pi: "/", codex: "/home/dev/shared" }); + const result = f.run(["--harness", "codex", "--skill", "meta-mode", "--replace", "--dry-run"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Cannot place skill backups outside remote discovery root/); + assert.deepEqual(f.calls(), []); +}); + +test("remote backups escape an unselected declared discovery root", (t) => { + const f = fixture(t, { pi: "/home/dev/custom/nested", codex: "/home/dev/custom" }); + const result = f.run(["--harness", "pi", "--skill", "meta-mode", "--replace"]); + assert.equal(result.status, 0, result.stderr); + const commit = f.calls().find(({ mode, args }) => mode === "ssh" && args.at(-1).includes("; stage=")); + assert.ok(commit); + assert.match(commit.args.at(-1), /backup='\/home\/dev\/\.mstack-backups\//); +}); + +for (const skillRoot of ["/home/dev/.agents/skills", "/home/dev/.agents/skills/custom"]) { + test(`remote shared installation preserves backups outside discovery for ${skillRoot}`, (t) => { + const f = fixture(t, { + codex: skillRoot, + pi: skillRoot, + }); + const result = f.run(["--harness", "codex,pi", "--skill", "meta-mode", "--replace"]); + assert.equal(result.status, 0, result.stderr); + const calls = f.calls(); + assert.equal(calls.filter(({ mode }) => mode === "rsync").length, 1); + const stages = calls.filter(({ mode, args }) => mode === "ssh" && args.at(-1).includes("mktemp -d")); + assert.equal(stages.length, 1); + assert.match(stages[0].args.at(-1), /mktemp -d '\/home\/dev\/\.agents\/\.mstack-stage\.XXXXXX'/); + assert.doesNotMatch(stages[0].args.at(-1), /skills\//); + const commits = calls.filter(({ mode, args }) => mode === "ssh" && args.at(-1).includes("; stage=")); + assert.equal(commits.length, 1); + assert.match(commits[0].args.at(-1), /backup='\/home\/dev\/\.agents\/\.mstack-backups\//); + assert.doesNotMatch(commits[0].args.at(-1), /skills\/\.mstack-backups/); + }); +} diff --git a/scripts/remote-install.mjs b/scripts/remote-install.mjs index 97e69bc..77cec37 100644 --- a/scripts/remote-install.mjs +++ b/scripts/remote-install.mjs @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import { dirname, join, posix, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; import { parseRemoteStage, quotePosix, readEnvironment } from "./environment-lib.mjs"; import { artifactPathParts, validateArtifactOverrides } from "./install-paths.mjs"; @@ -19,6 +20,9 @@ const harnessRegistry = JSON.parse(readFileSync(join(profilesRoot, "harnesses.js const skillInventory = JSON.parse(readFileSync(join(profilesRoot, "skills.json"), "utf8")).skills; const artifactRegistry = JSON.parse(readFileSync(join(profilesRoot, "artifacts.json"), "utf8")).artifacts; const args = process.argv.slice(2); +if (args.includes("--migrate")) { + throw new Error("--migrate is not supported over SSH; run the local installer with --migrate on the remote machine to inspect and migrate its discovered skill directories."); +} function valueAfter(flag) { const index = args.indexOf(flag); @@ -37,6 +41,10 @@ if (!harnesses.length || harnesses.some((harness) => !Object.hasOwn(harnessRegis throw new Error(`Invalid --harness value: ${requestedHarnesses}`); } if (new Set(harnesses).size !== harnesses.length) throw new Error(`Duplicate harness in --harness: ${requestedHarnesses}`); +const adapters = Object.fromEntries(harnesses.map((harness) => [ + harness, + JSON.parse(readFileSync(join(repoRoot, "adapters", `${harness}.json`), "utf8")), +])); const environmentName = requireValue("--environment"); const environment = readEnvironment(environmentName, harnesses); @@ -158,7 +166,15 @@ for (const item of requestedItems) { item.name === existing.name && item.source === existing.source && item.format === existing.format; - if (!sharedArtifact) { + const sharedSkill = + item.kind === "skill" && + existing.kind === "skill" && + item.name === existing.name && + item.source === existing.source && + item.harness !== "claude" && + existing.harness !== "claude" && + isDeepStrictEqual(adapters[item.harness], adapters[existing.harness]); + if (!sharedArtifact && !sharedSkill) { throw new Error( `Remote target collision: ${item.target} is selected by ` + `${existing.kind}/${existing.name} and ${item.kind}/${item.name}`, @@ -170,6 +186,23 @@ const itemCounts = { artifacts: items.filter((item) => item.kind === "artifact").length, }; +function backupParent(item) { + if (item.kind !== "skill") return posix.dirname(item.target); + let root = posix.dirname(item.target); + for (let parent = root; parent !== posix.dirname(parent); parent = posix.dirname(parent)) { + // Custom targets may be nested inside a recursively discovered skill root. + if (["skill", "skills"].includes(posix.basename(parent))) root = parent; + } + for (const value of Object.values(environment.targets)) { + if (typeof value !== "string" || !value.startsWith("/")) continue; + const declared = posix.normalize(value).replace(/\/$/, "") || "/"; + if (declared === "/" || root === declared || root.startsWith(`${declared}/`)) root = declared; + } + if (root === "/") throw new Error(`Cannot place skill backups outside remote discovery root / for ${item.target}`); + return posix.dirname(root); +} +const backupParents = new Map(items.map((item) => [item.target, backupParent(item)])); + function remoteScript(script, allowFailure = false) { const result = spawnSync( environment.sshCommand ?? "ssh", @@ -239,7 +272,7 @@ try { ? join(localRoot, item.harness, "skills", item.name) : join(localRoot, item.harness, ...artifactPaths[item.name][item.harness]); if (!existsSync(localSource)) throw new Error(`Staged source is missing: ${localSource}`); - const parent = posix.dirname(item.target); + const parent = item.kind === "skill" ? backupParents.get(item.target) : posix.dirname(item.target); const stageOutput = remoteScript(`set -eu; mkdir -p ${quotePosix(parent)}; mktemp -d ${quotePosix(posix.join(parent, ".mstack-stage.XXXXXX"))}`); const remoteStage = parseRemoteStage(stageOutput, parent); item.stage = remoteStage; @@ -255,7 +288,7 @@ try { for (const [index, item] of items.entries()) { const backup = posix.join( - posix.dirname(item.target), + backupParents.get(item.target), ".mstack-backups", stamp, `${String(index).padStart(4, "0")}-${item.kind}-${item.name}`, diff --git a/scripts/skill-discovery.test.mjs b/scripts/skill-discovery.test.mjs new file mode 100644 index 0000000..894fa96 --- /dev/null +++ b/scripts/skill-discovery.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const installer = resolve("scripts/install.mjs"); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "mstack-discovery-")); + const env = { ...process.env, HOME: root, USERPROFILE: root }; + for (const key of Object.keys(env)) { + if (key.startsWith("HARNESS_SKILLS_") || ["CODEX_HOME", "CLAUDE_CONFIG_DIR", "PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME"].includes(key)) delete env[key]; + } + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, env }; +} + +function run(args, env) { + return spawnSync(process.execPath, [installer, ...args], { env, encoding: "utf8" }); +} + +function succeeded(result) { + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +} + +function skillFiles(root, prefix = "") { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) return skillFiles(join(root, entry.name), relative); + return entry.name === "SKILL.md" ? [relative] : []; + }).sort(); +} + +function installedText(root, directory, skill) { + return readFileSync(join(root, directory, skill, "SKILL.md"), "utf8"); +} + +test("all harnesses install two canonical skills and two directory-named Claude skills", (t) => { + const { root, env } = fixture(t); + succeeded(run(["--harness", "all", "--skill", "meta-mode,show-me-your-work"], env)); + assert.deepEqual(skillFiles(root), [ + ".agents/skills/meta-mode/SKILL.md", + ".agents/skills/show-me-your-work/SKILL.md", + ".claude/skills/meta-mode/SKILL.md", + ".claude/skills/show-me-your-work/SKILL.md", + ]); + for (const skill of ["meta-mode", "show-me-your-work"]) { + assert.match(installedText(root, ".agents/skills", skill), new RegExp(`^name: ${skill}\\r?$`, "m")); + const claude = installedText(root, ".claude/skills", skill); + assert.doesNotMatch(claude.split(/^---\s*$/m)[1], /^name:/m); + assert.match(claude, /^description:/m); + } + assert.equal(existsSync(join(root, ".pi/agent/skills")), false); + assert.equal(existsSync(join(root, ".config/opencode/skills")), false); +}); + +test("repeat replacement and a pi-only refresh keep one current shared copy", (t) => { + const { root, env } = fixture(t); + const args = ["--harness", "all", "--skill", "meta-mode", "--replace"]; + succeeded(run(args, env)); + succeeded(run(args, env)); + const canonical = join(root, ".agents/skills/meta-mode/SKILL.md"); + const expected = readFileSync(resolve("skills/meta-mode/SKILL.md"), "utf8"); + writeFileSync(canonical, `${readFileSync(canonical, "utf8")}\nStale local marker\n`); + succeeded(run(["--harness", "pi", "--skill", "meta-mode", "--replace"], env)); + assert.equal(readFileSync(canonical, "utf8"), expected); + for (const directory of [".agents/skills", ".claude/skills", ".pi/agent/skills", ".config/opencode/skills"]) { + assert.deepEqual(skillFiles(join(root, directory)), directory.startsWith(".agents") || directory.startsWith(".claude") ? ["meta-mode/SKILL.md"] : []); + } +}); + +test("artifact-only installation preserves native harness roots", (t) => { + const { root, env } = fixture(t); + succeeded(run(["--harness", "all", "--no-skills", "--artifact", "agents"], env)); + for (const file of [ + ".codex/agents/meta-agent.toml", + ".claude/agents/meta-agent.md", + ".config/opencode/agents/meta-agent.md", + ".pi/agent/agents/meta-agent.md", + ]) assert.equal(existsSync(join(root, file)), true, file); + assert.equal(existsSync(join(root, ".agents")), false); +}); + +test("equal explicit Codex and pi roots install one usable skill", (t) => { + const { root, env } = fixture(t); + env.HARNESS_SKILLS_CODEX_DIR = join(root, "custom/skills"); + env.HARNESS_SKILLS_PI_DIR = join(root, "custom/skills"); + succeeded(run(["--harness", "codex,pi", "--skill", "meta-mode"], env)); + assert.deepEqual(skillFiles(root), ["custom/skills/meta-mode/SKILL.md"]); + assert.match(installedText(root, "custom/skills", "meta-mode"), /^name: meta-mode\r?$/m); +}); + +test("incompatible Claude and canonical output at one root fails before writes", (t) => { + const { root, env } = fixture(t); + env.HARNESS_SKILLS_CODEX_DIR = join(root, "custom/skills"); + env.HARNESS_SKILLS_CLAUDE_DIR = join(root, "custom/skills"); + const result = run(["--harness", "codex,claude", "--skill", "meta-mode"], env); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /incompatible|overlap|conflict/i); + assert.deepEqual(readdirSync(root), []); +}); + +test("dry run reports shared destinations without creating files", (t) => { + const { root, env } = fixture(t); + const result = run(["--harness", "all", "--skill", "meta-mode", "--dry-run"], env); + succeeded(result); + assert.ok(result.stdout.includes(join(root, ".agents/skills/meta-mode")), result.stdout); + assert.ok(result.stdout.includes(join(root, ".claude/skills/meta-mode")), result.stdout); + assert.deepEqual(readdirSync(root), []); +}); + +test("optional tools coalesce for all harnesses and resolve relative to a pi skill", (t) => { + const { root, env } = fixture(t); + const result = run(["--harness", "all", "--no-skills", "--artifact", "meta-mode-tools"], env); + succeeded(result); + assert.match(result.stdout, /Installed 0 skill copies and 2 artifact copies/); + const expected = readFileSync(resolve("tools/meta-mode/package.json"), "utf8"); + for (const directory of [".agents/tools/meta-mode", ".claude/tools/meta-mode"]) { + assert.equal(readFileSync(join(root, directory, "package.json"), "utf8"), expected); + } + for (const directory of [".pi/agent/tools", ".config/opencode/tools"]) { + assert.equal(existsSync(join(root, directory)), false, directory); + } + succeeded(run(["--harness", "pi", "--skill", "meta-mode", "--artifact", "meta-mode-tools", "--replace"], env)); + const skillDirectory = join(root, ".agents/skills/meta-mode"); + assert.match(readFileSync(join(skillDirectory, "SKILL.md"), "utf8"), /\.\.\/\.\.\/tools\/meta-mode/); + const relativeTools = resolve(skillDirectory, "../../tools/meta-mode/package.json"); + assert.equal(relativeTools, join(root, ".agents/tools/meta-mode/package.json")); + assert.equal(readFileSync(relativeTools, "utf8"), expected); +}); + +test("shared migration leaves an unrecognized explicit pi install override untouched", (t) => { + const { root, env } = fixture(t); + env.HARNESS_SKILLS_PI_DIR = join(root, "custom-pi/skills"); + const custom = join(root, "custom-pi/skills/meta-mode"); + mkdirSync(custom, { recursive: true }); + const original = "---\nname: meta-mode\ndescription: My unrelated workflow\n---\nKeep this custom skill.\n"; + writeFileSync(join(custom, "SKILL.md"), original); + writeFileSync(join(custom, "notes.txt"), "Keep supporting files."); + succeeded(run(["--harness", "codex", "--skill", "meta-mode", "--migrate", "--replace"], env)); + assert.equal(readFileSync(join(custom, "SKILL.md"), "utf8"), original); + assert.equal(readFileSync(join(custom, "notes.txt"), "utf8"), "Keep supporting files."); + assert.deepEqual(readdirSync(custom).sort(), ["SKILL.md", "notes.txt"]); + assert.equal(installedText(root, ".agents/skills", "meta-mode"), readFileSync(resolve("skills/meta-mode/SKILL.md"), "utf8")); +}); diff --git a/scripts/smoke-harnesses.mjs b/scripts/smoke-harnesses.mjs index 40d56e8..955a76e 100644 --- a/scripts/smoke-harnesses.mjs +++ b/scripts/smoke-harnesses.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { processInvocation } from "./runtime-lib.mjs"; import { parseCliArgs } from "./cli-args.mjs"; +import { resolveHarnessRoots } from "./harness-targets.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const registry = JSON.parse(readFileSync(join(repoRoot, "profiles", "harnesses.json"), "utf8")); @@ -14,23 +14,6 @@ const options = parseCliArgs(process.argv.slice(2), [ "--harness", "--skill", "--file", "--model", "--parent-model", ], ["--execute", "--require-installed", "--json"]); -function expandHome(path) { - if (path === "~") return homedir(); - return path?.startsWith("~/") || path?.startsWith("~\\") ? join(homedir(), path.slice(2)) : path; -} - -function targetFor(harness) { - const config = registry[harness]; - const override = process.env[config.directoryVariable]; - if (override) return resolve(expandHome(override)); - if (config.fallback) return config.fallback.reduce((path, part) => join(path, part), homedir()); - const configuredRoot = process.env[config.configVariable]; - const configRoot = configuredRoot - ? resolve(expandHome(configuredRoot)) - : join(homedir(), config.configFallback); - return join(configRoot, ...(config.prefix ?? []), ...(config.suffix ?? [])); -} - function run(command, commandArgs) { const invocation = processInvocation(command, commandArgs); const result = spawnSync(invocation.command, invocation.args, { @@ -64,10 +47,11 @@ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(skill)) throw new Error(`Invalid skill const execute = options["--execute"]; const requireInstalled = options["--require-installed"]; const results = []; +const { skills: targets } = resolveHarnessRoots(registry); for (const harness of harnesses) { const config = registry[harness]; - const target = targetFor(harness); + const target = targets[harness]; const skillPath = join(target, skill, "SKILL.md"); const version = run(config.runtime.command, ["--version"]); const entry = { diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 9013768..c67a42f 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -95,6 +95,9 @@ for (const harness of harnesses) { const adapterPath = join(repoRoot, "adapters", `${harness}.json`); try { const adapter = JSON.parse(readFileSync(adapterPath, "utf8")); + if (adapter.directoryName !== undefined && (harness !== "claude" || adapter.directoryName !== true)) { + errors.push(`${harness} adapter cannot use directory-based skill identity`); + } for (const [skill, fields] of Object.entries(adapter.removeFrontmatter ?? {})) { if (!EXPECTED_SKILLS.includes(skill)) errors.push(`${harness} adapter removes fields from unknown skill ${skill}`); if (!Array.isArray(fields)) errors.push(`${harness} adapter removeFrontmatter for ${skill} must be an array`);