harness-alchemist 0.1.9 → 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "harness-alchemist",
4
4
  "displayName": "Harness Alchemist",
5
- "version": "0.1.9",
5
+ "version": "0.2.0-alpha.1",
6
6
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
7
7
  "author": {
8
8
  "name": "Haochuan Zhang"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-alchemist",
3
- "version": "0.1.9",
3
+ "version": "0.2.0-alpha.1",
4
4
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
5
5
  "author": {
6
6
  "name": "Haochuan Zhang",
package/README.md CHANGED
@@ -38,7 +38,11 @@ npm install -g harness-alchemist
38
38
  npx harness-alchemist@latest create my-plugin --help
39
39
  ```
40
40
 
41
- Requires Node.js 22.20+ (Bun 1.2+ also supported).
41
+ The CLI implementation is a native Rust binary. npm and Bun installations use a
42
+ small launcher to select a prebuilt macOS, Linux, or Windows binary for x64 or
43
+ arm64. No Rust toolchain or install script is required by CLI users. Repository
44
+ development requires the pinned Rust toolchain in `rust-toolchain.toml`. The npm
45
+ launcher requires Node.js 22.20+; invoking it with Bun 1.2+ is also supported.
42
46
 
43
47
  ## Quick start
44
48
 
@@ -157,14 +161,46 @@ and the DeepSeek Cordis check require npm mode with built adapters
157
161
 
158
162
  ## Validation tiers
159
163
 
160
- `npm run validate` always enforces Agent Skills frontmatter compliance, SKILL.md reference resolution, runtime-manifest validation, fixed contained entrypoints, and npm-mode twin parity. With the optional `pyodide` devDependency installed, Python twins are additionally compiled and smoke-executed inside a WebAssembly CPython sandbox; Pyodide is validation-only, not a version 1 execution engine.
164
+ `npm run validate` always enforces Agent Skills frontmatter compliance, SKILL.md
165
+ reference resolution, validation of every present runtime manifest, fixed
166
+ contained entrypoints, npm-mode twin parity, and Python syntax through the native
167
+ Rust parser. Generated npm plugins require a runtime manifest for every product
168
+ skill; adapted skills-only repositories may also contain additional prose-only
169
+ skills while retaining a manifest for the primary MCP-targeted skill. The validator
170
+ does not execute project Python during structural validation; JavaScript/Python
171
+ behavioral parity is exercised by the runtime tests.
161
172
 
162
173
  ## Release automation
163
174
 
175
+ The main package and six platform packages use GitHub Actions workflow
176
+ `npm-publish.yml` in `lunarmoon26/harness-alchemist` as their Trusted Publisher.
177
+ The workflow builds each native target on its matching hosted runner, publishes
178
+ platform packages first, then publishes the main package. It uses OIDC instead
179
+ of an `NPM_TOKEN`, requires the tag to match both `package.json` and
180
+ `Cargo.toml`, and accepts only commits contained in `main`.
181
+
182
+ Trusted Publisher configuration is package-scoped. Before the first native
183
+ release, create each new platform package once using an interactive npm session
184
+ with 2FA (a reserved `0.0.0` bootstrap version keeps real releases on OIDC), then
185
+ configure its publisher with npm 11.5.1 or newer:
186
+
187
+ ```bash
188
+ npm trust github @lunarmoon26/harness-alchemist-darwin-arm64 \
189
+ --repo lunarmoon26/harness-alchemist --file npm-publish.yml \
190
+ --allow-publish --yes
191
+ ```
192
+
193
+ Repeat that command for all six platform package names, then set each package's
194
+ publishing access to **Require two-factor authentication and disallow tokens**.
195
+ The existing main package must retain the same workflow identity and policy.
196
+ Prerelease versions publish under the `next` dist-tag; stable versions publish
197
+ under `latest`.
198
+
164
199
  ```bash
165
200
  # bump package.json version, then:
166
201
  npm run sync && npm run verify && git commit -am "Release vX.Y.Z" && git push
167
- git tag vX.Y.Z && git push origin vX.Y.Z # publishes automatically
202
+ # after the release commit is merged to main:
203
+ git tag vX.Y.Z && git push origin vX.Y.Z # publishes through npm Trusted Publishing
168
204
  ```
169
205
 
170
206
  ## License
package/alchemy.json CHANGED
@@ -3,6 +3,6 @@
3
3
  "runtime": "npm",
4
4
  "template": "v0.1.0",
5
5
  "generator": "harness-alchemist",
6
- "generatorVersion": "0.1.9",
6
+ "generatorVersion": "0.2.0-alpha.1",
7
7
  "createdAt": "2026-08-24T00:00:00.000Z"
8
8
  }
@@ -13,7 +13,7 @@
13
13
  "runtime": {
14
14
  "enum": ["npm", "skills"],
15
15
  "default": "npm",
16
- "description": "'npm' requires the full generated contract (npm package, portable runtime manifests, OpenCode and Cordis adapters, and Cordis patch). 'skills' requires skills, portable runtime manifests, and harness manifests only and allows single-language scripts."
16
+ "description": "'npm' requires the full generated contract (npm package, a portable runtime manifest for every product skill, OpenCode and Cordis adapters, and Cordis patch). 'skills' requires portable skills and harness manifests, validates runtime manifests when present, and allows additional prose-only skills and single-language scripts while retaining a manifest for the primary MCP-targeted skill."
17
17
  },
18
18
  "pluginRoot": {
19
19
  "type": "string",
@@ -1,48 +1,74 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile } from "node:fs/promises"
3
+ import { existsSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { createRequire } from "node:module"
7
+ import { spawnSync } from "node:child_process"
4
8
 
5
- import { listTemplates, runCreate } from "../lib/create.mjs"
6
- import { runInstallCheck } from "../lib/install-check.mjs"
7
- import { runValidate } from "../lib/validate.mjs"
9
+ const require = createRequire(import.meta.url)
10
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
11
+ const extension = process.platform === "win32" ? ".exe" : ""
8
12
 
9
- const packageJson = JSON.parse(
10
- await readFile(new URL("../package.json", import.meta.url), "utf8"),
11
- )
13
+ const platformPackages = {
14
+ darwin: {
15
+ arm64: "@lunarmoon26/harness-alchemist-darwin-arm64",
16
+ x64: "@lunarmoon26/harness-alchemist-darwin-x64",
17
+ },
18
+ linux: {
19
+ arm64: "@lunarmoon26/harness-alchemist-linux-arm64",
20
+ x64: "@lunarmoon26/harness-alchemist-linux-x64",
21
+ },
22
+ win32: {
23
+ arm64: "@lunarmoon26/harness-alchemist-win32-arm64",
24
+ x64: "@lunarmoon26/harness-alchemist-win32-x64",
25
+ },
26
+ }
12
27
 
13
- function usage() {
14
- return `Harness Alchemist ${packageJson.version}
28
+ function installedBinary() {
29
+ const packageName = platformPackages[process.platform]?.[process.arch]
30
+ if (!packageName) return undefined
31
+ try {
32
+ return join(dirname(require.resolve(`${packageName}/package.json`)), `harness-alchemist${extension}`)
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
15
37
 
16
- Usage: harness-alchemist <command> [options]
38
+ function localBinary() {
39
+ for (const profile of ["release", "debug"]) {
40
+ const candidate = join(packageRoot, "target", profile, `harness-alchemist${extension}`)
41
+ if (existsSync(candidate)) return candidate
42
+ }
43
+ return undefined
44
+ }
17
45
 
18
- Commands:
19
- create <directory> Create a universal coding-agent plugin repository.
20
- validate [directory] Validate a generated repository.
21
- install-check Install the plugin into local harness CLIs and verify
22
- discovery (claude, codex, agy, opencode, dsh).
23
- templates List bundled canonical templates.
24
- version Print the CLI version.
25
- help Show this help.
46
+ const binary = process.env.HARNESS_ALCHEMIST_BINARY || installedBinary() || localBinary()
26
47
 
27
- Aliases: init and new are aliases for create.`
48
+ if (!binary) {
49
+ const supported = platformPackages[process.platform]?.[process.arch]
50
+ const reason = supported
51
+ ? `The optional package '${supported}' is not installed.`
52
+ : `The platform '${process.platform}-${process.arch}' is not supported.`
53
+ console.error(`Harness Alchemist could not locate its native binary. ${reason}`)
54
+ console.error("Reinstall without --no-optional, or install a supported binary from GitHub Releases.")
55
+ process.exit(1)
28
56
  }
29
57
 
30
- const [command, ...args] = process.argv.slice(2)
31
-
32
- if (!command || command === "help" || command === "--help" || command === "-h") {
33
- console.log(usage())
34
- } else if (command === "version" || command === "--version" || command === "-v") {
35
- console.log(packageJson.version)
36
- } else if (command === "templates") {
37
- console.log(listTemplates())
38
- } else if (["create", "init", "new"].includes(command)) {
39
- process.exitCode = await runCreate(args)
40
- } else if (command === "validate") {
41
- process.exitCode = await runValidate(args)
42
- } else if (command === "install-check") {
43
- process.exitCode = await runInstallCheck(args)
58
+ const result = spawnSync(binary, process.argv.slice(2), {
59
+ stdio: "inherit",
60
+ env: {
61
+ ...process.env,
62
+ HARNESS_ALCHEMIST_PACKAGE_ROOT: packageRoot,
63
+ },
64
+ })
65
+
66
+ if (result.error) {
67
+ console.error(`Failed to start Harness Alchemist: ${result.error.message}`)
68
+ process.exit(1)
69
+ }
70
+ if (result.signal) {
71
+ process.kill(process.pid, result.signal)
44
72
  } else {
45
- console.error(`Unknown command: ${command}\n`)
46
- console.error(usage())
47
- process.exitCode = 2
73
+ process.exit(result.status ?? 1)
48
74
  }
package/mcp.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "command": "npx",
7
7
  "args": [
8
8
  "-y",
9
- "@lunarmoon26/agent-skill-runtime@0.1.0",
9
+ "@lunarmoon26/agent-skill-runtime@0.1.1",
10
10
  "mcp",
11
11
  "--root",
12
12
  "${PLUGIN_ROOT}",
package/package.json CHANGED
@@ -1,28 +1,35 @@
1
1
  {
2
- "name": "harness-alchemist",
3
- "version": "0.1.9",
4
- "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
5
- "type": "module",
2
+ "author": "Haochuan Zhang",
6
3
  "bin": {
7
4
  "harness-alchemist": "bin/harness-alchemist.mjs"
8
5
  },
9
- "main": "./dist/opencode.js",
10
- "types": "./dist/opencode.d.ts",
6
+ "dependencies": {
7
+ "@lunarmoon26/agent-skill-runtime": "0.1.1"
8
+ },
9
+ "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
10
+ "dsh": {
11
+ "bundle": {
12
+ "patch": "./cordis.patch.yml"
13
+ }
14
+ },
15
+ "engines": {
16
+ "bun": ">=1.2.0",
17
+ "node": ">=22.20.0"
18
+ },
11
19
  "exports": {
12
20
  ".": {
13
- "types": "./dist/opencode.d.ts",
14
- "import": "./dist/opencode.js"
21
+ "import": "./dist/opencode.js",
22
+ "types": "./dist/opencode.d.ts"
15
23
  },
24
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
25
  "./deepseek": {
17
- "types": "./dist/deepseek.d.ts",
18
- "import": "./dist/deepseek.js"
19
- },
20
- "./cordis.patch.yml": "./cordis.patch.yml"
26
+ "import": "./dist/deepseek.js",
27
+ "types": "./dist/deepseek.d.ts"
28
+ }
21
29
  },
22
30
  "files": [
23
31
  "dist",
24
32
  "bin",
25
- "lib",
26
33
  "templates",
27
34
  "skills",
28
35
  "alchemy.json",
@@ -35,20 +42,6 @@
35
42
  "README.md",
36
43
  "LICENSE"
37
44
  ],
38
- "scripts": {
39
- "build": "tsc -p tsconfig.json",
40
- "check": "tsc -p tsconfig.json --noEmit",
41
- "test": "npm run build && node --test tests/*.test.mjs",
42
- "sync": "node .agents/skills/develop-harness-alchemist/scripts/sync-metadata.mjs",
43
- "validate": "node .agents/skills/develop-harness-alchemist/scripts/validate.mjs",
44
- "pack:check": "node .agents/skills/develop-harness-alchemist/scripts/check-package.mjs",
45
- "verify": "npm run check && npm test && npm run validate && npm run pack:check",
46
- "prepack": "npm run verify"
47
- },
48
- "engines": {
49
- "node": ">=22.20.0",
50
- "bun": ">=1.2.0"
51
- },
52
45
  "keywords": [
53
46
  "agent-skill",
54
47
  "antigravity",
@@ -58,8 +51,16 @@
58
51
  "opencode",
59
52
  "scaffolding"
60
53
  ],
61
- "dependencies": {
62
- "@lunarmoon26/agent-skill-runtime": "0.1.0"
54
+ "license": "MIT",
55
+ "main": "./dist/opencode.js",
56
+ "name": "harness-alchemist",
57
+ "optionalDependencies": {
58
+ "@lunarmoon26/harness-alchemist-darwin-arm64": "0.2.0-alpha.1",
59
+ "@lunarmoon26/harness-alchemist-darwin-x64": "0.2.0-alpha.1",
60
+ "@lunarmoon26/harness-alchemist-linux-arm64": "0.2.0-alpha.1",
61
+ "@lunarmoon26/harness-alchemist-linux-x64": "0.2.0-alpha.1",
62
+ "@lunarmoon26/harness-alchemist-win32-arm64": "0.2.0-alpha.1",
63
+ "@lunarmoon26/harness-alchemist-win32-x64": "0.2.0-alpha.1"
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@deepseek-ai/cordis": "^4.0.1",
@@ -73,22 +74,15 @@
73
74
  "optional": true
74
75
  }
75
76
  },
76
- "devDependencies": {
77
- "@deepseek-ai/cordis": "^4.0.1",
78
- "@opencode-ai/plugin": "^1.18.21",
79
- "@types/node": "^26.2.0",
80
- "pyodide": "^314.0.5",
81
- "typescript": "^5.9.3"
77
+ "preferUnplugged": true,
78
+ "publishConfig": {
79
+ "access": "public"
82
80
  },
83
- "author": "Haochuan Zhang",
84
- "license": "MIT",
85
81
  "repository": {
86
82
  "type": "git",
87
83
  "url": "git+https://github.com/lunarmoon26/harness-alchemist.git"
88
84
  },
89
- "dsh": {
90
- "bundle": {
91
- "patch": "./cordis.patch.yml"
92
- }
93
- }
85
+ "type": "module",
86
+ "types": "./dist/opencode.d.ts",
87
+ "version": "0.2.0-alpha.1"
94
88
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-alchemist",
4
- "version": "0.1.9",
4
+ "version": "0.2.0-alpha.1",
5
5
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
6
6
  "author": {
7
7
  "name": "Haochuan Zhang"
@@ -57,13 +57,17 @@ The package root exports OpenCode. The `./deepseek` subpath exports Cordis. Keep
57
57
  ## GitHub Release Publishing
58
58
 
59
59
  `.github/workflows/npm-publish.yml` publishes when a `vX.Y.Z` tag is pushed
60
- (or a GitHub release with a semver tag such as `vX.Y.Z-rc.1` is published);
61
- the workflow applies that version to `package.json`, runs
62
- `npm run sync`, verifies the package, and publishes it with npm provenance.
63
-
64
- Configure the repository `NPM_TOKEN` secret with an npm publish token before
65
- pushing the tag. The workflow never publishes from pull requests or branch
66
- pushes.
60
+ (including a prerelease tag such as `vX.Y.Z-rc.1`). The workflow requires the
61
+ tag to match the committed `package.json` version, checks that the tagged
62
+ commit is contained in `main`, verifies synchronized metadata and the package,
63
+ then publishes through npm Trusted Publishing with provenance.
64
+ Prerelease versions publish under npm's `next` dist-tag; stable versions publish
65
+ under `latest`.
66
+
67
+ Before the first release, configure the npm package's Trusted Publisher for
68
+ GitHub Actions with the repository owner, repository name, workflow filename
69
+ `npm-publish.yml`, and no environment. Do not create an `NPM_TOKEN` secret.
70
+ The workflow never publishes from pull requests or branch pushes.
67
71
 
68
72
  ## Public Catalog Metadata
69
73
 
@@ -4,6 +4,6 @@ Each published versioned directory is a complete, immutable scaffold contract.
4
4
 
5
5
  - `v0.1.0/universal-typescript/` is the unreleased canonical universal plugin layout and may be updated in place.
6
6
  - `v0.1.0/licenses/` contains license source text used during rendering.
7
- - `lib/create.mjs` maps template directory names, renders metadata tokens, injects the validator and license, validates in staging, and atomically installs the result.
7
+ - The native Rust generator maps template directory names, renders metadata tokens, injects `generated/validate.mjs` and the selected license, validates in staging, and atomically installs the result.
8
8
 
9
9
  After a version is published, make incompatible changes in a new `v<version>/` directory and require callers to select that version explicitly. Do not silently redirect old template identifiers.
@@ -11,7 +11,7 @@ import { discoverSkillTools } from "@lunarmoon26/agent-skill-runtime"
11
11
  const AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
12
12
  const AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
13
13
  const SKILL_RUNTIME_PACKAGE = "@lunarmoon26/agent-skill-runtime"
14
- const SKILL_RUNTIME_VERSION = "0.1.0"
14
+ const SKILL_RUNTIME_VERSION = "0.1.1"
15
15
 
16
16
  const PROJECT_REQUIRED_FILES = [
17
17
  ".agents/plugins/marketplace.json",
@@ -46,9 +46,9 @@ Validates a universal Claude, Codex, OpenCode, Antigravity, and DeepSeek
46
46
  plugin scaffold. By default the project is resolved from the current working
47
47
  directory or from the script's containing generated project.
48
48
 
49
- Product skills are checked against the Agent Skills specification and the
50
- portable skill runtime manifest. When the optional 'pyodide' devDependency is
51
- installed, Python twin entrypoints are also syntax-checked and smoke-executed
49
+ Product skills are checked against the Agent Skills specification, and every
50
+ present runtime manifest is validated. When the optional 'pyodide' devDependency
51
+ is installed, Python entrypoints are also syntax-checked and smoke-executed
52
52
  in a WebAssembly CPython sandbox.
53
53
 
54
54
  Options:
@@ -668,9 +668,9 @@ export async function validateProject(projectRoot, options = {}) {
668
668
  }
669
669
  if (productSkillFiles.includes(skillFile)) {
670
670
  const manifestPath = join(dirname(skillFile), "skill-runtime.json")
671
- if (!existsSync(manifestPath)) {
671
+ if (!existsSync(manifestPath) && (runtime === "npm" || directoryName === pluginName)) {
672
672
  errors.push(`${skillFile}: missing skill-runtime.json`)
673
- } else {
673
+ } else if (existsSync(manifestPath)) {
674
674
  runtimeManifestPaths.push(relative(pluginRoot, manifestPath))
675
675
  }
676
676
  await checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts, runtime)
@@ -678,7 +678,7 @@ export async function validateProject(projectRoot, options = {}) {
678
678
  }
679
679
  }
680
680
 
681
- if (runtimeManifestPaths.length === productSkillFiles.length && runtimeManifestPaths.length > 0) {
681
+ if (runtimeManifestPaths.length > 0) {
682
682
  try {
683
683
  const tools = await discoverSkillTools({ pluginRoot, manifestPaths: runtimeManifestPaths })
684
684
  for (const tool of tools) {
@@ -7,43 +7,68 @@ on:
7
7
 
8
8
  permissions:
9
9
  contents: read
10
- id-token: write
10
+
11
+ concurrency:
12
+ group: npm-publish-${{ github.ref }}
13
+ cancel-in-progress: false
11
14
 
12
15
  jobs:
13
16
  publish:
17
+ name: Publish package
14
18
  if: github.event.created && !github.event.deleted
15
- runs-on: ubuntu-latest
19
+ runs-on: ubuntu-24.04
16
20
  timeout-minutes: 15
21
+ permissions:
22
+ contents: read
23
+ id-token: write # Required for npm Trusted Publishing OIDC.
17
24
  steps:
18
- - uses: actions/checkout@v5
19
- - uses: actions/setup-node@v5
25
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
26
+ with:
27
+ fetch-depth: 0
28
+ persist-credentials: false
29
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
20
30
  with:
21
- node-version: 22.20.0
31
+ node-version: "24.19.0"
22
32
  registry-url: https://registry.npmjs.org
23
- - uses: oven-sh/setup-bun@v2
33
+ package-manager-cache: false
34
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
24
35
  with:
25
- bun-version: 1.2.0
36
+ bun-version: 1.4.0
37
+ no-cache: true
26
38
 
27
- - name: Resolve release version
28
- id: version
39
+ - name: Verify release tag
29
40
  shell: bash
30
41
  run: |
31
- TAG="${{ github.ref_name }}"
32
- VERSION="${TAG#v}"
33
- if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
34
- echo "::error::Tag '$TAG' is not a valid semver version"
42
+ PACKAGE_VERSION="$(node -p "require('./package.json').version")"
43
+ EXPECTED_TAG="v${PACKAGE_VERSION}"
44
+ if [[ "$GITHUB_REF_NAME" != "$EXPECTED_TAG" ]]; then
45
+ echo "::error::Tag '$GITHUB_REF_NAME' must match package version '$EXPECTED_TAG'"
46
+ exit 1
47
+ fi
48
+
49
+ git fetch --no-tags origin main:refs/remotes/origin/main
50
+ TAG_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
51
+ if ! git merge-base --is-ancestor "$TAG_COMMIT" refs/remotes/origin/main; then
52
+ echo "::error::Release tag '$GITHUB_REF_NAME' must point to a commit contained in main"
35
53
  exit 1
36
54
  fi
37
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
38
55
 
39
56
  - run: npm ci
40
- - name: Set package version from tag
41
- run: npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version
42
- - run: npm run sync
57
+ - name: Verify synchronized metadata
58
+ run: |
59
+ npm run sync
60
+ git diff --exit-code
43
61
  - run: npm run verify
44
62
  - name: Verify npm package contents
45
63
  run: npm pack --dry-run
64
+ - name: Select npm dist-tag
65
+ shell: bash
66
+ run: |
67
+ VERSION="$(node -p "require('./package.json').version")"
68
+ if [[ "$VERSION" == *-* ]]; then
69
+ echo "NPM_DIST_TAG=next" >> "$GITHUB_ENV"
70
+ else
71
+ echo "NPM_DIST_TAG=latest" >> "$GITHUB_ENV"
72
+ fi
46
73
  - name: Publish npm package
47
- run: npm publish --provenance --access public
48
- env:
49
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
74
+ run: npm publish --access public --tag "$NPM_DIST_TAG"
@@ -57,12 +57,14 @@ npm pack --dry-run
57
57
  ## GitHub Release Publishing
58
58
 
59
59
  `.github/workflows/npm-publish.yml` publishes when a `vX.Y.Z` tag is pushed
60
- (or a GitHub release with a semver tag such as `vX.Y.Z-rc.1` is published);
61
- the workflow applies that version, synchronizes manifests,
62
- verifies the package, and publishes with npm provenance.
63
-
64
- Configure the repository `NPM_TOKEN` secret with an npm publish token before
65
- pushing the tag.
60
+ (including a prerelease tag such as `vX.Y.Z-rc.1`). The workflow requires the
61
+ tag to match the committed `package.json` version, checks that the tagged
62
+ commit is contained in `main`, verifies synchronized metadata and the package,
63
+ then publishes through npm Trusted Publishing with provenance.
64
+
65
+ Before the first release, configure the npm package's Trusted Publisher for
66
+ GitHub Actions with the repository owner, repository name, workflow filename
67
+ `npm-publish.yml`, and no environment. Do not create an `NPM_TOKEN` secret.
66
68
 
67
69
  ## Claude Code
68
70
 
@@ -6,7 +6,7 @@
6
6
  "command": "npx",
7
7
  "args": [
8
8
  "-y",
9
- "@lunarmoon26/agent-skill-runtime@0.1.0",
9
+ "@lunarmoon26/agent-skill-runtime@0.1.1",
10
10
  "mcp",
11
11
  "--root",
12
12
  "${PLUGIN_ROOT}",
@@ -42,7 +42,7 @@
42
42
  "node": ">=22.20.0"
43
43
  },
44
44
  "dependencies": {
45
- "@lunarmoon26/agent-skill-runtime": "0.1.0"
45
+ "@lunarmoon26/agent-skill-runtime": "0.1.1"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@deepseek-ai/cordis": "^4.0.1",
package/lib/create.mjs DELETED
@@ -1,331 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { randomUUID } from "node:crypto"
4
- import { existsSync } from "node:fs"
5
- import { chmod, cp, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises"
6
- import { basename, dirname, join, resolve } from "node:path"
7
- import { fileURLToPath } from "node:url"
8
- import { spawnSync } from "node:child_process"
9
-
10
- const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
11
- const PACKAGE_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/
12
- const LICENSES = new Set(["MIT", "Apache-2.0", "UNLICENSED"])
13
- const CANONICAL_VERSION = "v0.1.0"
14
-
15
- export function listTemplates() {
16
- return `${CANONICAL_VERSION} (canonical)`
17
- }
18
-
19
- function usage() {
20
- return `Usage: harness-alchemist create <output-directory> [options]
21
-
22
- Create one TypeScript repository for Claude Code, Codex/ChatGPT, OpenCode,
23
- Google Antigravity, and DeepSeek Harness/Cordis.
24
-
25
- Required:
26
- --description <text> One-sentence plugin description.
27
- --author <name> Author or team name.
28
- --repository <source> GitHub owner/repo or a full Git URL.
29
-
30
- Options:
31
- --name <name> Plugin name; defaults to output directory name.
32
- --package <name> npm package; defaults to plugin name. Its basename
33
- must equal the plugin name.
34
- --display-name <text> Human-readable name; defaults from plugin name.
35
- --marketplace <name> Claude/Codex marketplace name; defaults to
36
- <plugin-name>-plugins.
37
- --template <version> Canonical template version (default: v0.1.0).
38
- --license <id> MIT (default), Apache-2.0, or UNLICENSED.
39
- --dry-run Validate inputs and print the files without writing.
40
- --help Show this help.
41
-
42
- The destination must be missing or empty. Existing content is never replaced.`
43
- }
44
-
45
- function parseArgs(argv) {
46
- const options = { license: "MIT", templateVersion: CANONICAL_VERSION, dryRun: false }
47
- const valueOptions = new Map([
48
- ["--name", "name"],
49
- ["--description", "description"],
50
- ["--package", "packageName"],
51
- ["--author", "author"],
52
- ["--repository", "repository"],
53
- ["--display-name", "displayName"],
54
- ["--marketplace", "marketplace"],
55
- ["--template", "templateVersion"],
56
- ["--license", "license"],
57
- ])
58
-
59
- for (let index = 0; index < argv.length; index += 1) {
60
- const arg = argv[index]
61
- if (arg === "--help" || arg === "-h") return { help: true }
62
- if (arg === "--dry-run") {
63
- options.dryRun = true
64
- continue
65
- }
66
- if (valueOptions.has(arg)) {
67
- const value = argv[index + 1]
68
- if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`)
69
- options[valueOptions.get(arg)] = value
70
- index += 1
71
- continue
72
- }
73
- if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`)
74
- if (options.output) throw new Error("Only one output directory may be supplied")
75
- options.output = arg
76
- }
77
-
78
- return { ...options, help: false }
79
- }
80
-
81
- function titleCase(name) {
82
- return name
83
- .split("-")
84
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
85
- .join(" ")
86
- }
87
-
88
- function normalizeRepository(source) {
89
- if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source)) {
90
- return { installSource: source, url: `https://github.com/${source}` }
91
- }
92
- try {
93
- const url = new URL(source)
94
- const githubMatch = url.hostname === "github.com" && url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
95
- return {
96
- installSource: githubMatch ? `${githubMatch[1]}/${githubMatch[2]}` : source,
97
- url: source.replace(/\.git$/, ""),
98
- }
99
- } catch {
100
- if (/^git@[^:]+:.+/.test(source)) return { installSource: source, url: source }
101
- throw new Error("--repository must be GitHub owner/repo or a full Git URL")
102
- }
103
- }
104
-
105
- function validateOptions(options) {
106
- if (!options.output) throw new Error("An output directory is required")
107
- const name = options.name ?? basename(resolve(options.output))
108
- if (!NAME_PATTERN.test(name) || name.length > 56) {
109
- throw new Error("Plugin name must be <=56 characters using lowercase letters, digits, and single hyphens")
110
- }
111
- const packageName = options.packageName ?? name
112
- if (!PACKAGE_PATTERN.test(packageName) || packageName.split("/").at(-1) !== name) {
113
- throw new Error("npm package must be unscoped or @scoped and have the plugin name as its basename")
114
- }
115
- if (!options.description?.trim()) throw new Error("--description is required")
116
- if (options.description.includes("\n")) throw new Error("--description must be one line")
117
- if (!options.author?.trim()) throw new Error("--author is required")
118
- if (!options.repository?.trim()) throw new Error("--repository is required")
119
- if (!LICENSES.has(options.license)) {
120
- throw new Error("--license must be MIT, Apache-2.0, or UNLICENSED")
121
- }
122
- if (options.templateVersion !== CANONICAL_VERSION) {
123
- throw new Error(`Unknown template version '${options.templateVersion}'. Available: ${CANONICAL_VERSION}`)
124
- }
125
- const marketplace = options.marketplace ?? `${name}-plugins`
126
- if (!NAME_PATTERN.test(marketplace) || marketplace.length > 64) {
127
- throw new Error("Marketplace name must be <=64 characters in lowercase kebab-case")
128
- }
129
-
130
- return {
131
- ...options,
132
- output: resolve(options.output),
133
- name,
134
- packageName,
135
- marketplace,
136
- displayName: options.displayName?.trim() || titleCase(name),
137
- description: options.description.trim(),
138
- author: options.author.trim(),
139
- repository: normalizeRepository(options.repository.trim()),
140
- }
141
- }
142
-
143
- async function destinationState(path) {
144
- if (!existsSync(path)) return "missing"
145
- const info = await stat(path)
146
- if (!info.isDirectory()) return "not-directory"
147
- return (await readdir(path)).length === 0 ? "empty" : "non-empty"
148
- }
149
-
150
- function replaceTokens(content, tokens) {
151
- return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
152
- if (!(key in tokens)) throw new Error(`Unknown template token ${match}`)
153
- return tokens[key]
154
- })
155
- }
156
-
157
- function mapSegment(segment, metadata) {
158
- if (segment === "shared-skill") return metadata.name
159
- if (segment === "develop-template") return `develop-${metadata.name}`
160
- return segment.endsWith(".tpl") ? segment.slice(0, -4) : segment
161
- }
162
-
163
- async function renderTemplates(source, destination, metadata, tokens, relative = []) {
164
- for (const entry of await readdir(source, { withFileTypes: true })) {
165
- const nextRelative = [...relative, mapSegment(entry.name, metadata)]
166
- const target = join(destination, ...nextRelative)
167
- const input = join(source, entry.name)
168
- if (entry.isDirectory()) {
169
- await mkdir(target, { recursive: true })
170
- await renderTemplates(input, destination, metadata, tokens, nextRelative)
171
- } else if (entry.isFile()) {
172
- const content = replaceTokens(await readFile(input, "utf8"), tokens)
173
- await mkdir(dirname(target), { recursive: true })
174
- await writeFile(target, content)
175
- }
176
- }
177
- }
178
-
179
- async function licenseText(skillRoot, metadata) {
180
- if (metadata.license === "UNLICENSED") {
181
- return `Copyright (c) ${new Date().getFullYear()} ${metadata.author}\n\nAll rights reserved. This project is not licensed for redistribution.\n`
182
- }
183
- if (metadata.license === "Apache-2.0") {
184
- return readFile(join(skillRoot, "licenses/Apache-2.0.txt"), "utf8")
185
- }
186
- return `MIT License
187
-
188
- Copyright (c) ${new Date().getFullYear()} ${metadata.author}
189
-
190
- Permission is hereby granted, free of charge, to any person obtaining a copy
191
- of this software and associated documentation files (the "Software"), to deal
192
- in the Software without restriction, including without limitation the rights
193
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
194
- copies of the Software, and to permit persons to whom the Software is
195
- furnished to do so, subject to the following conditions:
196
-
197
- The above copyright notice and this permission notice shall be included in all
198
- copies or substantial portions of the Software.
199
-
200
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
201
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
202
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
203
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
204
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
205
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
206
- SOFTWARE.
207
- `
208
- }
209
-
210
- async function listTemplateFiles(root, metadata, relative = []) {
211
- const files = []
212
- for (const entry of await readdir(root, { withFileTypes: true })) {
213
- const next = [...relative, mapSegment(entry.name, metadata)]
214
- if (entry.isDirectory()) files.push(...(await listTemplateFiles(join(root, entry.name), metadata, next)))
215
- else if (entry.isFile()) files.push(next.join("/"))
216
- }
217
- files.push(`.agents/skills/develop-${metadata.name}/scripts/validate.mjs`, "LICENSE")
218
- return files.sort()
219
- }
220
-
221
- export async function runCreate(argv) {
222
- let options
223
- try {
224
- const parsed = parseArgs(argv)
225
- if (parsed.help) {
226
- console.log(usage())
227
- return 0
228
- }
229
- options = validateOptions(parsed)
230
- } catch (error) {
231
- console.error(error.message)
232
- console.error(usage())
233
- return 2
234
- }
235
-
236
- const scriptPath = fileURLToPath(import.meta.url)
237
- const projectRoot = dirname(dirname(scriptPath))
238
- const canonicalRoot = join(projectRoot, "templates", options.templateVersion)
239
- const templateRoot = join(canonicalRoot, "universal-typescript")
240
- const generatorVersion = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf8")).version
241
- const state = await destinationState(options.output)
242
- if (state === "not-directory" || state === "non-empty") {
243
- console.error(`Destination must be missing or empty: ${options.output}`)
244
- return 1
245
- }
246
-
247
- const tokens = {
248
- NAME: options.name,
249
- NAME_JSON: JSON.stringify(options.name),
250
- TOOL_NAME: `${options.name.replaceAll("-", "_")}_run`,
251
- TOOL_NAME_JSON: JSON.stringify(`${options.name.replaceAll("-", "_")}_run`),
252
- DISPLAY_NAME: options.displayName,
253
- DISPLAY_NAME_JSON: JSON.stringify(options.displayName),
254
- DESCRIPTION: options.description,
255
- DESCRIPTION_JSON: JSON.stringify(options.description),
256
- TEMPLATE_VERSION: options.templateVersion,
257
- GENERATOR_VERSION: generatorVersion,
258
- CREATED_AT: new Date().toISOString(),
259
- SHARED_SKILL_DESCRIPTION_JSON: JSON.stringify(
260
- `${options.description} Use when the user requests ${options.displayName} workflows or explicitly asks to use the ${options.name} plugin.`,
261
- ),
262
- DEVELOPMENT_SKILL_DESCRIPTION_JSON: JSON.stringify(
263
- `Develop, validate, and publish the ${options.displayName} universal coding-agent plugin. Use when modifying its shared skills, Claude or Codex manifests, OpenCode npm entrypoint, Antigravity bundle, or DeepSeek Cordis integration.`,
264
- ),
265
- PACKAGE_NAME: options.packageName,
266
- PACKAGE_NAME_JSON: JSON.stringify(options.packageName),
267
- AUTHOR: options.author,
268
- AUTHOR_JSON: JSON.stringify(options.author),
269
- REPOSITORY_SOURCE: options.repository.installSource,
270
- REPOSITORY_SOURCE_JSON: JSON.stringify(options.repository.installSource),
271
- REPOSITORY_URL: options.repository.url,
272
- REPOSITORY_URL_JSON: JSON.stringify(options.repository.url),
273
- MARKETPLACE: options.marketplace,
274
- MARKETPLACE_JSON: JSON.stringify(options.marketplace),
275
- LICENSE: options.license,
276
- LICENSE_JSON: JSON.stringify(options.license),
277
- YEAR: String(new Date().getFullYear()),
278
- }
279
-
280
- if (options.dryRun) {
281
- console.log(JSON.stringify({
282
- output: options.output,
283
- template: options.templateVersion,
284
- plugin: options.name,
285
- package: options.packageName,
286
- files: await listTemplateFiles(templateRoot, options),
287
- }, null, 2))
288
- return 0
289
- }
290
-
291
- await mkdir(dirname(options.output), { recursive: true })
292
- const staging = join(dirname(options.output), `.${basename(options.output)}.scaffold-${randomUUID()}`)
293
-
294
- try {
295
- await mkdir(staging)
296
- await renderTemplates(templateRoot, staging, options, tokens)
297
- const localScripts = join(staging, ".agents/skills", `develop-${options.name}`, "scripts")
298
- await mkdir(localScripts, { recursive: true })
299
- await cp(join(projectRoot, "lib/validate.mjs"), join(localScripts, "validate.mjs"))
300
- const skillScripts = join(staging, "skills", options.name, "scripts")
301
- if (existsSync(skillScripts)) {
302
- for (const entry of await readdir(skillScripts)) {
303
- await chmod(join(skillScripts, entry), 0o755)
304
- }
305
- }
306
- await writeFile(join(staging, "LICENSE"), await licenseText(canonicalRoot, options))
307
-
308
- const validation = spawnSync(process.execPath, [join(projectRoot, "lib/validate.mjs"), staging], {
309
- encoding: "utf8",
310
- })
311
- if (validation.status !== 0) {
312
- throw new Error(`Generated project failed validation:\n${validation.stderr || validation.stdout}`)
313
- }
314
-
315
- if (state === "empty") await rmdir(options.output)
316
- await rename(staging, options.output)
317
-
318
- console.log(`Created universal plugin scaffold at ${options.output}`)
319
- console.log(`Next: cd ${JSON.stringify(options.output)} && npm install && npm run verify`)
320
- console.log(`Bun: cd ${JSON.stringify(options.output)} && bun install && bun run verify`)
321
- return 0
322
- } catch (error) {
323
- await rm(staging, { recursive: true, force: true })
324
- console.error(error.message)
325
- return 1
326
- }
327
- }
328
-
329
- if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
330
- process.exitCode = await runCreate(process.argv.slice(2))
331
- }
@@ -1,363 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { existsSync, readdirSync } from "node:fs"
4
- import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
5
- import { tmpdir } from "node:os"
6
- import { dirname, join, resolve } from "node:path"
7
- import { fileURLToPath } from "node:url"
8
- import { spawnSync } from "node:child_process"
9
-
10
- const TIMEOUT_MS = 300_000
11
-
12
- function findProjectRoot(start) {
13
- let current = resolve(start)
14
- let conventionalRoot
15
- while (true) {
16
- if (existsSync(join(current, "alchemy.json"))) return current
17
- if (
18
- !conventionalRoot &&
19
- existsSync(join(current, "package.json")) &&
20
- existsSync(join(current, ".claude-plugin")) &&
21
- existsSync(join(current, ".codex-plugin"))
22
- ) {
23
- conventionalRoot = current
24
- }
25
- const parent = dirname(current)
26
- if (parent === current) return conventionalRoot
27
- current = parent
28
- }
29
- }
30
-
31
- function run(command, args, options = {}) {
32
- const result = spawnSync(command, args, {
33
- encoding: "utf8",
34
- timeout: TIMEOUT_MS,
35
- ...options,
36
- })
37
- if (result.error?.code === "ENOENT") return { missing: true }
38
- return {
39
- ok: result.status === 0,
40
- status: result.status,
41
- stdout: result.stdout ?? "",
42
- stderr: result.stderr ?? "",
43
- }
44
- }
45
-
46
- function firstLine(text) {
47
- return text.trim().split("\n")[0] ?? ""
48
- }
49
-
50
- function usage() {
51
- return `Usage: harness-alchemist install-check [project-directory] [options]
52
-
53
- Install-level verification: drives the local harness CLIs (claude, codex,
54
- agy, opencode, dsh) against the project's plugin package and asserts each
55
- harness can discover it. Static validation is a prerequisite; run
56
- 'harness-alchemist validate' first.
57
-
58
- Options:
59
- --harness <id> Limit to one harness (claude, codex, agy, opencode, dsh).
60
- Repeatable.
61
- --keep Keep installed plugins and marketplaces after the check.
62
- --json Print a machine-readable result.
63
- --help Show this help.`
64
- }
65
-
66
- function parseArgs(argv) {
67
- const options = { harnesses: [], keep: false, json: false }
68
- for (let index = 0; index < argv.length; index += 1) {
69
- const arg = argv[index]
70
- if (arg === "--help" || arg === "-h") return { help: true }
71
- if (arg === "--keep") options.keep = true
72
- else if (arg === "--json") options.json = true
73
- else if (arg === "--harness") {
74
- const value = argv[index + 1]
75
- if (!value || value.startsWith("--")) throw new Error("--harness requires a value")
76
- if (!["claude", "codex", "agy", "opencode", "dsh"].includes(value)) {
77
- throw new Error(`Unknown harness '${value}'. Choose from claude, codex, agy, opencode, dsh`)
78
- }
79
- options.harnesses.push(value)
80
- index += 1
81
- } else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`)
82
- else if (options.project) throw new Error("Only one project directory may be supplied")
83
- else options.project = arg
84
- }
85
- return options
86
- }
87
-
88
- async function readJson(path, problems) {
89
- try {
90
- return JSON.parse(await readFile(path, "utf8"))
91
- } catch (error) {
92
- problems.push(`${path}: invalid JSON (${error.message})`)
93
- return undefined
94
- }
95
- }
96
-
97
- function skillNames(skillsDirectory) {
98
- if (!existsSync(skillsDirectory)) return []
99
- return readdirSync(skillsDirectory)
100
- .filter((entry) => existsSync(join(skillsDirectory, entry, "SKILL.md")))
101
- }
102
-
103
- export async function runInstallCheck(argv) {
104
- let options
105
- try {
106
- options = parseArgs(argv)
107
- } catch (error) {
108
- console.error(error.message)
109
- console.error(usage())
110
- return 2
111
- }
112
- if (options.help) {
113
- console.log(usage())
114
- return 0
115
- }
116
-
117
- const scriptDirectory = dirname(fileURLToPath(import.meta.url))
118
- const root = options.project
119
- ? resolve(options.project)
120
- : findProjectRoot(process.cwd()) ?? findProjectRoot(scriptDirectory)
121
- if (!root) {
122
- console.error("Could not find a universal plugin project. Pass its directory explicitly.")
123
- return 2
124
- }
125
-
126
- const problems = []
127
- const layoutPath = join(root, "alchemy.json")
128
- const layout = existsSync(layoutPath) ? (await readJson(layoutPath, problems)) ?? {} : {}
129
- const pluginRoot = resolve(root, layout.pluginRoot ?? ".")
130
- const runtime = layout.runtime ?? "npm"
131
-
132
- const claudePlugin = await readJson(join(pluginRoot, ".claude-plugin/plugin.json"), problems)
133
- const claudeMarketplace = await readJson(join(root, ".claude-plugin/marketplace.json"), problems)
134
- if (problems.length > 0) {
135
- for (const problem of problems) console.error(problem)
136
- return 1
137
- }
138
-
139
- const pluginName = claudePlugin.name
140
- const marketplaceName = claudeMarketplace.name
141
- const skills = skillNames(join(pluginRoot, "skills"))
142
- const selected = options.harnesses.length > 0
143
- ? options.harnesses
144
- : ["claude", "codex", "agy", "opencode", "dsh"]
145
-
146
- const results = []
147
- const record = (harness, status, details) => {
148
- results.push({ harness, status, details })
149
- if (options.json) return
150
- const marker = status === "pass" ? "✔" : status === "skip" ? "○" : "✖"
151
- console.log(`${marker} ${harness}: ${status}`)
152
- for (const detail of details) console.log(` ${detail}`)
153
- }
154
-
155
- const adapterPath = join(pluginRoot, "dist/opencode.js")
156
-
157
- for (const harness of selected) {
158
- if (harness === "claude") {
159
- const details = []
160
- let status = "pass"
161
- const steps = [
162
- ["validate", pluginRoot, "--strict"],
163
- ["marketplace", "add", root],
164
- ["install", `${pluginName}@${marketplaceName}`],
165
- ["details", pluginName],
166
- ]
167
- for (const args of steps) {
168
- const result = run("claude", ["plugin", ...args])
169
- if (result.missing) { status = "skip"; details.push("claude CLI not found"); break }
170
- if (!result.ok) {
171
- status = "fail"
172
- details.push(`\`${command} ${args.join(" ")}\` failed: ${firstLine(result.stderr || result.stdout)}`)
173
- break
174
- }
175
- if (args[0] === "details") {
176
- for (const skill of skills) {
177
- if (!result.stdout.includes(skill)) {
178
- status = "fail"
179
- details.push(`skill '${skill}' missing from plugin details`)
180
- }
181
- }
182
- details.push(`${skills.length} skills registered`)
183
- }
184
- }
185
- if (!options.keep) {
186
- run("claude", ["plugin", "uninstall", `${pluginName}@${marketplaceName}`])
187
- run("claude", ["plugin", "marketplace", "remove", marketplaceName])
188
- }
189
- record(harness, status, details)
190
- } else if (harness === "codex") {
191
- const details = []
192
- let status = "pass"
193
- const add = run("codex", ["plugin", "marketplace", "add", root])
194
- if (add.missing) {
195
- record(harness, "skip", ["codex CLI not found"])
196
- continue
197
- }
198
- if (!add.ok) {
199
- record(harness, "fail", [`marketplace add failed: ${firstLine(add.stderr || add.stdout)}`])
200
- continue
201
- }
202
- const install = run("codex", ["plugin", "add", `${pluginName}@${marketplaceName}`])
203
- if (!install.ok) {
204
- status = "fail"
205
- details.push(`plugin add failed: ${firstLine(install.stderr || install.stdout)}`)
206
- } else {
207
- const list = run("codex", ["plugin", "list"])
208
- if (!list.ok || !list.stdout.includes(`${pluginName}@${marketplaceName}`)) {
209
- status = "fail"
210
- details.push("plugin not listed as installed")
211
- } else if (!list.stdout.includes("installed, enabled")) {
212
- status = "fail"
213
- details.push("plugin installed but not enabled")
214
- } else {
215
- details.push("installed and enabled")
216
- }
217
- }
218
- if (!options.keep) {
219
- run("codex", ["plugin", "remove", `${pluginName}@${marketplaceName}`])
220
- run("codex", ["plugin", "marketplace", "remove", marketplaceName])
221
- }
222
- record(harness, status, details)
223
- } else if (harness === "agy") {
224
- const details = []
225
- const validate = run("agy", ["plugin", "validate", pluginRoot])
226
- if (validate.missing) {
227
- record(harness, "skip", ["agy CLI not found"])
228
- continue
229
- }
230
- if (!validate.ok) {
231
- record(harness, "fail", [`validate failed: ${firstLine(validate.stderr || validate.stdout)}`])
232
- continue
233
- }
234
- const install = run("agy", ["plugin", "install", pluginRoot])
235
- if (!install.ok) {
236
- record(harness, "fail", [`install failed: ${firstLine(install.stderr || install.stdout)}`])
237
- continue
238
- }
239
- const list = run("agy", ["plugin", "list"])
240
- if (!list.ok || !list.stdout.includes(pluginName)) {
241
- record(harness, "fail", ["plugin missing from agy plugin list"])
242
- continue
243
- }
244
- details.push("validated, installed, and listed")
245
- if (!options.keep) run("agy", ["plugin", "uninstall", pluginName])
246
- record(harness, "pass", details)
247
- } else if (harness === "opencode") {
248
- if (runtime === "npm" && !existsSync(adapterPath)) {
249
- record(harness, "fail", [
250
- `npm runtime requires a built adapter; missing ${adapterPath}`,
251
- "run `npm run build` in the plugin package first",
252
- ])
253
- continue
254
- }
255
- const isolation = await mkdtemp(join(tmpdir(), "ha-opencode-"))
256
- const configDirectory = join(isolation, "opencode")
257
- const skillsDirectory = join(configDirectory, "skills")
258
- await cp(join(pluginRoot, "skills"), skillsDirectory, { recursive: true })
259
- const config = { $schema: "https://opencode.ai/config.json" }
260
- if (runtime === "npm") {
261
- config.plugin = [`file://${adapterPath}`]
262
- }
263
- await writeFile(join(configDirectory, "opencode.json"), `${JSON.stringify(config, null, 2)}\n`)
264
- const environment = {
265
- ...process.env,
266
- XDG_CONFIG_HOME: isolation,
267
- HOME: isolation,
268
- }
269
- const skillCheck = run("opencode", ["debug", "skill"], { env: environment })
270
- if (skillCheck.missing) {
271
- await rm(isolation, { recursive: true, force: true })
272
- record(harness, "skip", ["opencode CLI not found"])
273
- continue
274
- }
275
- const details = []
276
- let status = "pass"
277
- const missing = skills.filter((skill) => !skillCheck.stdout.includes(`"name": "${skill}"`))
278
- if (missing.length > 0) {
279
- status = "fail"
280
- details.push(`skills not discovered: ${missing.join(", ")}`)
281
- } else {
282
- details.push(`${skills.length} skills discovered`)
283
- }
284
- if (runtime === "npm" && status === "pass") {
285
- const startup = run("opencode", ["debug", "startup"], { env: environment })
286
- if (!startup.ok) {
287
- status = "fail"
288
- details.push(`startup failed: ${firstLine(startup.stderr || startup.stdout)}`)
289
- } else {
290
- details.push("plugin loaded at startup")
291
- }
292
- }
293
- await rm(isolation, { recursive: true, force: true })
294
- record(harness, status, details)
295
- } else if (harness === "dsh") {
296
- if (runtime !== "npm") {
297
- record(harness, "skip", ["skills runtime has no Cordis bundle; install skills via filesystem roots instead"])
298
- continue
299
- }
300
- if (!existsSync(adapterPath)) {
301
- record(harness, "fail", [
302
- `npm runtime requires built adapters; missing ${join(pluginRoot, "dist/deepseek.js")}`,
303
- "run `npm run build` in the plugin package first",
304
- ])
305
- continue
306
- }
307
- const dshHome = await mkdtemp(join(tmpdir(), "ha-dsh-"))
308
- const environment = { ...process.env, DSH_HOME: dshHome }
309
- let dshCommand = "dsh"
310
- let dshPrefix = []
311
- if (spawnSync("dsh", ["--version"], { encoding: "utf8" }).error?.code === "ENOENT") {
312
- dshCommand = "npx"
313
- dshPrefix = ["-y", "@deepseek-ai/dsh"]
314
- }
315
- const add = run(dshCommand, [...dshPrefix, "plugin", "--profile", "install-check", "add", pluginRoot], { env: environment })
316
- if (add.missing) {
317
- await rm(dshHome, { recursive: true, force: true })
318
- record(harness, "skip", ["dsh CLI not found and npx unavailable"])
319
- continue
320
- }
321
- if (!add.ok) {
322
- await rm(dshHome, { recursive: true, force: true })
323
- record(harness, "fail", [`profile add failed: ${firstLine(add.stderr || add.stdout)}`])
324
- continue
325
- }
326
- const dump = run(dshCommand, [...dshPrefix, "--profile", "install-check", "--dump-config"], { env: environment })
327
- const details = []
328
- let status = "pass"
329
- if (!dump.ok || !dump.stdout.includes(`- id: ${pluginName}`)) {
330
- status = "fail"
331
- details.push("cordis insert entry missing from composed profile")
332
- } else {
333
- details.push("cordis bundle composed into isolated profile")
334
- }
335
- await rm(dshHome, { recursive: true, force: true })
336
- record(harness, status, details)
337
- }
338
- }
339
-
340
- const failed = results.filter((entry) => entry.status === "fail")
341
- const skipped = results.filter((entry) => entry.status === "skip")
342
- if (options.json) {
343
- console.log(JSON.stringify({
344
- project: root,
345
- plugin: pluginName,
346
- runtime,
347
- results,
348
- summary: {
349
- pass: results.length - failed.length - skipped.length,
350
- fail: failed.length,
351
- skip: skipped.length,
352
- },
353
- }, null, 2))
354
- } else {
355
- console.log(
356
- failed.length === 0
357
- ? `Install check passed for ${pluginName} (${results.length - skipped.length} verified, ${skipped.length} skipped)`
358
- : `Install check failed for ${pluginName}: ${failed.length} of ${results.length} harnesses failed`,
359
- )
360
- }
361
-
362
- return failed.length > 0 ? 1 : 0
363
- }