harness-alchemist 0.1.5 → 0.1.7

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.5",
5
+ "version": "0.1.7",
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.5",
3
+ "version": "0.1.7",
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
@@ -1,140 +1,144 @@
1
- # Harness Alchemist
2
-
3
- Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex/ChatGPT, OpenCode, Google Antigravity, and DeepSeek Harness/Cordis.
4
-
5
- Harness Alchemist is a zero-runtime-dependency CLI for Node.js and Bun. Its own repository follows the same universal plugin layout that it generates. The first frozen scaffold is `templates/v0.1.0`.
6
-
7
- ## Quick Start
8
-
9
- Run with Node.js:
1
+ <p align="center">
2
+ <a href="https://github.com/lunarmoon26/harness-alchemist">
3
+ <picture>
4
+ <source srcset="assets/harness-alchemist_dark.svg" media="(prefers-color-scheme: dark)">
5
+ <source srcset="assets/harness-alchemist_light.svg" media="(prefers-color-scheme: light)">
6
+ <img src="assets/harness-alchemist_light.svg" alt="Harness Alchemist logo" height="110">
7
+ </picture>
8
+ </a>
9
+ </p>
10
+
11
+ <p align="center">The universal scaffold for coding-agent plugins. One repository, five harnesses, skills that own their runtime.</p>
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/harness-alchemist" target="blank">
14
+ <img src="https://img.shields.io/npm/v/harness-alchemist?style=flat-square" alt="Npm package for Harness Alchemist">
15
+ </a>
16
+ <a href="./LICENSE">
17
+ <img alt="License: MIT" src="https://img.shields.io/github/license/lunarmoon26/harness-alchemist?style=flat-square" />
18
+ </a>
19
+ <a href="https://github.com/lunarmoon26/harness-alchemist/actions/workflows/ci.yml" target="blank">
20
+ <img src="https://img.shields.io/github/actions/workflow/status/lunarmoon26/harness-alchemist/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status for Harness Alchemist">
21
+ </a>
22
+ <br /><br />
23
+ </p>
24
+
25
+ <p align="center">
26
+ Follow <a href="https://x.com/haochuanzero">@haochuanzero on X</a> for updates · Start from the <a href="https://blog.haochuanz.net/harness-alchemist/">one-page tour</a>.
27
+ </p>
28
+
29
+ ---
30
+
31
+ Harness Alchemist scaffolds one TypeScript plugin repository that installs natively into **Claude Code**, **Codex/ChatGPT**, **OpenCode**, **Google Antigravity**, and **DeepSeek Harness/Cordis**. Product skills ship behavioral `.mjs`/`.py` script twins; every harness entrypoint is a thin adapter that delegates to them.
32
+
33
+ ## Install
10
34
 
11
35
  ```bash
12
- npx harness-alchemist create ./my-plugin \
13
- --description "What the plugin does" \
14
- --author "Example Team" \
15
- --repository example/my-plugin
36
+ npm install -g harness-alchemist
37
+ # or run it without installing:
38
+ npx harness-alchemist@latest create my-plugin --help
16
39
  ```
17
40
 
18
- Or with Bun:
41
+ Requires Node.js 22.20+ (Bun 1.2+ also supported). Zero runtime dependencies.
42
+
43
+ ## Quick start
19
44
 
20
45
  ```bash
21
- bunx harness-alchemist create ./my-plugin \
46
+ npx harness-alchemist@latest create my-plugin \
22
47
  --description "What the plugin does" \
23
48
  --author "Example Team" \
24
49
  --repository example/my-plugin
25
- ```
26
-
27
- The destination must be missing or empty. Harness Alchemist renders into a staging directory, validates the result, and moves it into place without replacing existing content.
28
-
29
- ## Commands
30
50
 
31
- ```text
32
- harness-alchemist create <directory> [options]
33
- harness-alchemist validate [directory] [--external] [--json]
34
- harness-alchemist templates
35
- harness-alchemist version
51
+ cd my-plugin && npm install && npm run verify
36
52
  ```
37
53
 
38
- `init` and `new` are aliases for `create`. Run `harness-alchemist create --help` for metadata and license options.
39
-
40
- New projects use the unreleased canonical `v0.1.0` template. Unknown versions
41
- fail instead of silently selecting a different layout.
42
-
43
- ## Generated Layout
44
-
45
- ```text
46
- .agents/
47
- plugins/ Codex repository marketplace
48
- skills/develop-<name>/ Project-local maintenance skill and scripts
49
- .claude-plugin/ Claude plugin and marketplace manifests
50
- .codex-plugin/ Codex/ChatGPT plugin manifest
51
- skills/<name>/SKILL.md Shared installable Agent Skill
52
- src/opencode.ts OpenCode npm plugin entrypoint
53
- src/deepseek.ts Cordis plugin entrypoint
54
- cordis.patch.yml DeepSeek Harness bundle layer
55
- plugin.json Antigravity plugin manifest
56
- package.json Canonical package metadata
57
- ```
58
-
59
- Generated repositories start at version `0.1.0` with inert OpenCode and Cordis entrypoints. Add only the runtime hooks or services the plugin actually needs.
60
-
61
- The recursive part is intentional: Harness Alchemist itself uses this layout, and every generated repository receives `.agents/skills/develop-<name>/` with its own sync, validation, package-payload, and compatibility workflow.
54
+ Creation only writes to a missing or empty destination. The generated project passes its own gate out of the box: TypeScript checks, runtime delegation tests, scaffold validation, and an npm-payload audit.
62
55
 
63
- ## Guidance Tiers
56
+ ## Existing monorepos and SDK packages
64
57
 
65
- The repository-maintainer skill, the published CLI skill, and a generated
66
- project's local maintenance skill have different audiences and scopes. See
67
- [docs/skill-tiers.md](docs/skill-tiers.md) before moving guidance between them.
58
+ `validate` also supports an adapted plugin package inside an existing repository.
59
+ Add `alchemy.json` at the repository root:
68
60
 
69
- ## Canonical Template
70
-
71
- `templates/v0.1.0/universal-typescript/` is the unreleased canonical scaffold. It includes the generated project's local maintenance skill, scoped to that project's ownership boundary. `lib/create.mjs` supplies the version-specific validator and license during the atomic render.
61
+ ```json
62
+ {
63
+ "$schema": "https://unpkg.com/harness-alchemist/alchemy.schema.json",
64
+ "pluginRoot": "packages/my-sdk",
65
+ "opencodeExport": "./server"
66
+ }
67
+ ```
72
68
 
73
- Future incompatible scaffold changes should create a new `templates/<version>/`
74
- snapshot instead of mutating `v0.1.0` after release.
69
+ The manifest is JSON-Schema-validated; the schema ships in the npm package and
70
+ is referenced through `$schema`, so editors autocomplete and check every field.
75
71
 
76
- ## Local Development
72
+ `pluginRoot` contains the canonical plugin manifests, shared skills, Cordis
73
+ patch, adapter sources, and publishable package metadata. Repository marketplace
74
+ manifests remain at the project root and point at that package directory.
75
+ `opencodeExport: "./server"` preserves an SDK at the package root while exposing
76
+ the OpenCode adapter through the modern server entrypoint. Omitting the file
77
+ retains the strict generated single-package layout.
77
78
 
78
- Requires Node.js 22.20 or newer for the full build and package checks. The scaffolding and validation CLI also runs directly with Bun 1.2 or newer.
79
+ The optional `runtime` field selects what the adapted package must contain:
79
80
 
80
- ```bash
81
- npm install
82
- npm run verify
83
- node ./bin/harness-alchemist.mjs templates
84
- bun ./bin/harness-alchemist.mjs templates
85
- ```
81
+ - `"npm"` (default) — the full generated contract: npm metadata, OpenCode and
82
+ Cordis adapters, Cordis patch, and `.mjs`/`.py` script twins.
83
+ - `"skills"` — skills and harness manifests only. No npm package, adapters, or
84
+ Cordis patch are required, and single-language scripts are allowed, so
85
+ Python, Go, Rust, Java, C#, or Swift repositories can expose their workflows
86
+ to Claude Code, Codex, Antigravity, and DeepSeek's filesystem skill roots
87
+ without adopting a JavaScript runtime.
86
88
 
87
- After changing package metadata:
88
-
89
- ```bash
90
- npm run sync
91
- npm run verify
92
- ```
89
+ Generated projects include a `alchemy.json` manifest recording their
90
+ `runtime`, canonical `template` version, `generator`, `generatorVersion`, and
91
+ `createdAt`; `npm run sync` keeps `generatorVersion` aligned with the package
92
+ version.
93
93
 
94
- Inspect the publish payload before release:
94
+ ## What you get
95
95
 
96
- ```bash
97
- npm pack --dry-run
98
- ```
96
+ | Surface | Purpose |
97
+ | --- | --- |
98
+ | `skills/<name>/` | Agent Skills spec skill with `.mjs`/`.py` script twins under `scripts/` and a tool-contract reference |
99
+ | `src/opencode.ts` | OpenCode plugin registering tools that spawn the skill scripts |
100
+ | `src/deepseek.ts` + `cordis.patch.yml` | Cordis function plugin providing a service over the same scripts |
101
+ | `.claude-plugin/`, `.codex-plugin/`, `.agents/plugins/`, `plugin.json` | Native manifests for Claude Code, Codex, and Antigravity marketplaces |
102
+ | `.agents/skills/develop-<name>/` | Repository-maintenance skill so agents can develop the project recursively |
103
+ | `.github/workflows/` | CI plus tag-triggered publish (`vX.Y.Z` → verify → npm provenance) |
99
104
 
100
- ## Package Surfaces
105
+ ## Supported harnesses
101
106
 
102
- - npm binary: `harness-alchemist`
103
- - Shared Agent Skill: `skills/harness-alchemist/SKILL.md`
104
- - OpenCode package-root export: `harness-alchemist`
105
- - Cordis export: `harness-alchemist/deepseek`
106
- - DeepSeek bundle patch: `harness-alchemist/cordis.patch.yml`
107
+ Install paths below are verified against real CLIs before shipping in the template.
107
108
 
108
- The runtime entrypoints are intentionally inert in v0.1.0. The CLI and Agent Skill are the implemented product surfaces.
109
+ | Harness | Skills | Runtime | Install |
110
+ | --- | --- | --- | --- |
111
+ | Claude Code | bundled natively | — | `claude plugin install <name>@<marketplace>` |
112
+ | Codex/ChatGPT | bundled natively | — | `codex plugin add <name>@<marketplace>` |
113
+ | OpenCode | via `~/.agents/skills/` | npm package hooks | `"plugin": ["<package>"]` in `opencode.json` |
114
+ | Google Antigravity | nested bundle | — | `agy plugin validate . && agy plugin install .` |
115
+ | DeepSeek Harness | profile filesystem roots | Cordis service plugin | `dsh plugin --profile demo add <package-or-path>` |
109
116
 
110
- ## Harness Installation
117
+ ## Skill script contract
111
118
 
112
- Claude Code:
119
+ Product skills own their logic; adapters never do.
113
120
 
114
121
  ```bash
115
- claude plugin marketplace add lunarmoon26/harness-alchemist
116
- claude plugin install harness-alchemist@harness-alchemist-plugins
122
+ echo '{"request": "hello"}' | node skills/<name>/scripts/main.mjs
123
+ # {"ok":true,"plugin":"my-plugin","echo":{"request":"hello"}}
117
124
  ```
118
125
 
119
- Codex and ChatGPT:
126
+ - One JSON object on stdin, one JSON result plus newline on stdout.
127
+ - Non-zero exit with a stderr diagnostic on failure.
128
+ - `scripts/main.py` is a stdlib-only behavioral twin of `scripts/main.mjs`.
120
129
 
121
- ```bash
122
- codex plugin marketplace add lunarmoon26/harness-alchemist
123
- ```
130
+ ## Validation tiers
124
131
 
125
- OpenCode shared skill:
126
-
127
- ```bash
128
- npx skills add lunarmoon26/harness-alchemist --agent opencode
129
- ```
132
+ `npm run validate` always enforces Agent Skills frontmatter compliance, SKILL.md reference resolution, and twin parity. With the optional `pyodide` devDependency installed, Python entrypoints are additionally compiled and smoke-executed inside a WebAssembly CPython sandbox — no native Python required.
130
133
 
131
- DeepSeek Harness after npm publication:
134
+ ## Release automation
132
135
 
133
136
  ```bash
134
- dsh plugin --profile demo add harness-alchemist
135
- dsh --profile demo --dump-config
137
+ # bump package.json version, then:
138
+ npm run sync && npm run verify && git commit -am "Release vX.Y.Z" && git push
139
+ git tag vX.Y.Z && git push origin vX.Y.Z # publishes automatically
136
140
  ```
137
141
 
138
142
  ## License
139
143
 
140
- MIT
144
+ [MIT](./LICENSE)
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://unpkg.com/harness-alchemist/alchemy.schema.json",
4
+ "title": "Harness Alchemist layout manifest",
5
+ "description": "Declares how a repository adapts the Harness Alchemist universal plugin layout. Omitting the file selects the strict generated single-package layout.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "URL of this schema."
12
+ },
13
+ "runtime": {
14
+ "enum": ["npm", "skills"],
15
+ "default": "npm",
16
+ "description": "'npm' requires the full generated contract (npm package, OpenCode and Cordis adapters, Cordis patch, .mjs/.py script twins). 'skills' requires skills and harness manifests only and allows single-language scripts."
17
+ },
18
+ "pluginRoot": {
19
+ "type": "string",
20
+ "default": ".",
21
+ "pattern": "^(?!\\.{2}(/|\\\\))[^\\u0000]+",
22
+ "description": "Repository-relative directory containing the plugin manifests, product skills, runtime sources, Cordis patch, and npm metadata. Defaults to the repository root."
23
+ },
24
+ "opencodeExport": {
25
+ "enum": [".", "./server"],
26
+ "default": ".",
27
+ "description": "Package export key that exposes the OpenCode adapter. Only meaningful when runtime is 'npm'."
28
+ },
29
+ "template": {
30
+ "type": "string",
31
+ "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$",
32
+ "description": "Canonical template version the project was generated from."
33
+ },
34
+ "generator": {
35
+ "const": "harness-alchemist",
36
+ "description": "Tool that generated the project."
37
+ },
38
+ "generatorVersion": {
39
+ "type": "string",
40
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$",
41
+ "description": "Version of the generating harness-alchemist CLI."
42
+ },
43
+ "createdAt": {
44
+ "type": "string",
45
+ "format": "date-time",
46
+ "description": "ISO 8601 creation timestamp written by the generator."
47
+ }
48
+ },
49
+ "if": {
50
+ "properties": { "opencodeExport": { "const": "./server" } },
51
+ "required": ["opencodeExport"]
52
+ },
53
+ "then": { "properties": { "runtime": { "const": "npm" } } }
54
+ }
@@ -35,7 +35,7 @@ if (!command || command === "help" || command === "--help" || command === "-h")
35
35
  } else if (["create", "init", "new"].includes(command)) {
36
36
  process.exitCode = await runCreate(args)
37
37
  } else if (command === "validate") {
38
- process.exitCode = await runValidate(args.length > 0 ? args : [process.cwd()])
38
+ process.exitCode = await runValidate(args)
39
39
  } else {
40
40
  console.error(`Unknown command: ${command}\n`)
41
41
  console.error(usage())
package/lib/create.mjs CHANGED
@@ -237,6 +237,7 @@ export async function runCreate(argv) {
237
237
  const projectRoot = dirname(dirname(scriptPath))
238
238
  const canonicalRoot = join(projectRoot, "templates", options.templateVersion)
239
239
  const templateRoot = join(canonicalRoot, "universal-typescript")
240
+ const generatorVersion = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf8")).version
240
241
  const state = await destinationState(options.output)
241
242
  if (state === "not-directory" || state === "non-empty") {
242
243
  console.error(`Destination must be missing or empty: ${options.output}`)
@@ -250,6 +251,9 @@ export async function runCreate(argv) {
250
251
  DISPLAY_NAME_JSON: JSON.stringify(options.displayName),
251
252
  DESCRIPTION: options.description,
252
253
  DESCRIPTION_JSON: JSON.stringify(options.description),
254
+ TEMPLATE_VERSION: options.templateVersion,
255
+ GENERATOR_VERSION: generatorVersion,
256
+ CREATED_AT: new Date().toISOString(),
253
257
  SHARED_SKILL_DESCRIPTION_JSON: JSON.stringify(
254
258
  `${options.description} Use when the user requests ${options.displayName} workflows or explicitly asks to use the ${options.name} plugin.`,
255
259
  ),
package/lib/validate.mjs CHANGED
@@ -6,24 +6,31 @@ import { basename, dirname, join, resolve, sep } from "node:path"
6
6
  import { fileURLToPath } from "node:url"
7
7
  import { spawnSync } from "node:child_process"
8
8
 
9
- const REQUIRED_FILES = [
9
+ const PROJECT_REQUIRED_FILES = [
10
10
  ".agents/plugins/marketplace.json",
11
11
  ".claude-plugin/marketplace.json",
12
- ".claude-plugin/plugin.json",
13
- ".codex-plugin/plugin.json",
14
- ".github/workflows/npm-publish.yml",
15
12
  ".gitignore",
16
13
  "AGENTS.md",
17
14
  "LICENSE",
18
15
  "README.md",
16
+ ]
17
+
18
+ const PLUGIN_REQUIRED_FILES = [
19
+ ".claude-plugin/plugin.json",
20
+ ".codex-plugin/plugin.json",
21
+ "plugin.json",
22
+ ]
23
+
24
+ const NPM_PLUGIN_REQUIRED_FILES = [
19
25
  "cordis.patch.yml",
20
26
  "package.json",
21
- "plugin.json",
22
27
  "src/deepseek.ts",
23
28
  "src/opencode.ts",
24
29
  "tsconfig.json",
25
30
  ]
26
31
 
32
+ const RUNTIME_MODES = ["npm", "skills"]
33
+
27
34
  function usage() {
28
35
  return `Usage: harness-alchemist validate [project-directory] [--external] [--json]
29
36
 
@@ -44,16 +51,19 @@ Options:
44
51
 
45
52
  function findProjectRoot(start) {
46
53
  let current = resolve(start)
54
+ let conventionalRoot
47
55
  while (true) {
56
+ if (existsSync(join(current, "alchemy.json"))) return current
48
57
  if (
58
+ !conventionalRoot &&
49
59
  existsSync(join(current, "package.json")) &&
50
60
  existsSync(join(current, ".claude-plugin")) &&
51
61
  existsSync(join(current, ".codex-plugin"))
52
62
  ) {
53
- return current
63
+ conventionalRoot = current
54
64
  }
55
65
  const parent = dirname(current)
56
- if (parent === current) return undefined
66
+ if (parent === current) return conventionalRoot
57
67
  current = parent
58
68
  }
59
69
  }
@@ -252,7 +262,7 @@ except SystemExit as exit_code:
252
262
  _buffer.getvalue()
253
263
  `
254
264
 
255
- async function smokePythonScripts(scripts, errors, warnings) {
265
+ async function smokePythonScripts(scripts, errors, warnings, execSmoke = true) {
256
266
  let loadPyodide
257
267
  try {
258
268
  ;({ loadPyodide } = await import("pyodide"))
@@ -269,17 +279,26 @@ async function smokePythonScripts(scripts, errors, warnings) {
269
279
  return
270
280
  }
271
281
 
282
+ if (!execSmoke) {
283
+ warnings.push("skills runtime: Python entrypoints are syntax-checked without execution")
284
+ }
285
+
272
286
  for (const script of scripts) {
273
287
  const source = await readFile(script, "utf8")
274
288
  instance.globals.set("__ha_source__", source)
275
- instance.globals.set("__ha_payload__", "{}")
276
289
  let output
277
290
  try {
278
- output = instance.runPython(PYTHON_SMOKE_PROGRAM)
291
+ if (execSmoke) {
292
+ instance.globals.set("__ha_payload__", "{}")
293
+ output = instance.runPython(PYTHON_SMOKE_PROGRAM)
294
+ } else {
295
+ instance.runPython('compile(__ha_source__, "<skill>", "exec")\nNone')
296
+ }
279
297
  } catch (error) {
280
298
  errors.push(`${script}: WebAssembly Python check failed (${String(error).split("\n").at(-2) ?? String(error)})`)
281
299
  continue
282
300
  }
301
+ if (!execSmoke) continue
283
302
  try {
284
303
  const parsed = JSON.parse(output)
285
304
  if (parsed?.ok !== true || typeof parsed.plugin !== "string") {
@@ -290,11 +309,16 @@ async function smokePythonScripts(scripts, errors, warnings) {
290
309
  }
291
310
  }
292
311
 
293
- instance.globals.delete("__ha_source__")
294
- instance.globals.delete("__ha_payload__")
312
+ for (const key of ["__ha_source__", "__ha_payload__"]) {
313
+ try {
314
+ instance.globals.delete(key)
315
+ } catch {
316
+ // Key was never set in this session.
317
+ }
318
+ }
295
319
  }
296
320
 
297
- async function checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts) {
321
+ async function checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts, runtime) {
298
322
  if ((frontmatter.description?.length ?? 0) > 1024) {
299
323
  errors.push(`${skillFile}: description must be at most 1024 characters`)
300
324
  }
@@ -325,12 +349,12 @@ async function checkProductSkillRuntime(skillFile, content, frontmatter, errors,
325
349
  entries.filter((entry) => entry.endsWith(".mjs")).map((entry) => entry.slice(0, -4)),
326
350
  )
327
351
  for (const base of pythonBases) {
328
- if (!mjsBases.has(base)) {
352
+ if (runtime === "npm" && !mjsBases.has(base)) {
329
353
  errors.push(`${skillFile}: scripts/${base}.py is missing its scripts/${base}.mjs twin`)
330
354
  }
331
355
  }
332
356
  for (const base of mjsBases) {
333
- if (!pythonBases.has(base)) {
357
+ if (runtime === "npm" && !pythonBases.has(base)) {
334
358
  errors.push(`${skillFile}: scripts/${base}.mjs is missing its scripts/${base}.py twin`)
335
359
  }
336
360
  }
@@ -344,25 +368,108 @@ export async function validateProject(projectRoot, options = {}) {
344
368
  const errors = []
345
369
  const warnings = []
346
370
 
347
- for (const file of REQUIRED_FILES) requirePath(root, file, errors)
371
+ const layoutPath = join(root, "alchemy.json")
372
+ const parsedLayout = existsSync(layoutPath) ? await readJson(layoutPath, errors) : {}
373
+ if (parsedLayout === undefined) return { root, errors, warnings }
374
+ if (
375
+ typeof parsedLayout !== "object" ||
376
+ parsedLayout === null ||
377
+ Array.isArray(parsedLayout)
378
+ ) {
379
+ errors.push("alchemy.json must contain a JSON object")
380
+ return { root, errors, warnings }
381
+ }
382
+ const layout = parsedLayout
383
+ const knownLayoutFields = [
384
+ "pluginRoot",
385
+ "opencodeExport",
386
+ "runtime",
387
+ "$schema",
388
+ "template",
389
+ "generator",
390
+ "generatorVersion",
391
+ "createdAt",
392
+ ]
393
+ const unknownLayoutFields = Object.keys(layout).filter(
394
+ (key) => !knownLayoutFields.includes(key),
395
+ )
396
+ if (unknownLayoutFields.length) {
397
+ errors.push(`alchemy.json has unknown fields: ${unknownLayoutFields.join(", ")}`)
398
+ }
399
+ if (layout.$schema !== undefined && typeof layout.$schema !== "string") {
400
+ errors.push("alchemy.json $schema must be a string")
401
+ }
402
+ if (layout.template !== undefined && !/^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(layout.template)) {
403
+ errors.push("alchemy.json template must be a 'vX.Y.Z' version")
404
+ }
405
+ if (layout.generator !== undefined && layout.generator !== "harness-alchemist") {
406
+ errors.push("alchemy.json generator must be 'harness-alchemist'")
407
+ }
408
+ if (
409
+ layout.generatorVersion !== undefined &&
410
+ !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(layout.generatorVersion)
411
+ ) {
412
+ errors.push("alchemy.json generatorVersion must be an 'X.Y.Z' version")
413
+ }
414
+ if (layout.createdAt !== undefined) {
415
+ const createdAt = new Date(layout.createdAt)
416
+ if (typeof layout.createdAt !== "string" || Number.isNaN(createdAt.getTime())) {
417
+ errors.push("alchemy.json createdAt must be an ISO 8601 timestamp")
418
+ }
419
+ }
420
+ const runtime = layout.runtime ?? "npm"
421
+ if (!RUNTIME_MODES.includes(runtime)) {
422
+ errors.push(`alchemy.json runtime must be one of: ${RUNTIME_MODES.join(", ")}`)
423
+ }
424
+ const pluginRootRelative = layout.pluginRoot ?? "."
425
+ if (typeof pluginRootRelative !== "string" || !pluginRootRelative.trim()) {
426
+ errors.push("alchemy.json pluginRoot must be a non-empty relative path")
427
+ }
428
+ const pluginRoot = resolve(root, typeof pluginRootRelative === "string" ? pluginRootRelative : ".")
429
+ if (!(pluginRoot === root || pluginRoot.startsWith(`${root}${sep}`))) {
430
+ errors.push("alchemy.json pluginRoot escapes the project root")
431
+ } else if (existsSync(pluginRoot)) {
432
+ const [canonicalRoot, canonicalPluginRoot] = await Promise.all([
433
+ realpath(root),
434
+ realpath(pluginRoot),
435
+ ])
436
+ if (!(canonicalPluginRoot === canonicalRoot || canonicalPluginRoot.startsWith(`${canonicalRoot}${sep}`))) {
437
+ errors.push("alchemy.json pluginRoot resolves through a symlink outside the project root")
438
+ }
439
+ }
440
+ const opencodeExport = layout.opencodeExport ?? "."
441
+ if (![".", "./server"].includes(opencodeExport)) {
442
+ errors.push("alchemy.json opencodeExport must be '.' or './server'")
443
+ }
444
+ if (opencodeExport !== "." && runtime !== "npm") {
445
+ errors.push("alchemy.json opencodeExport requires runtime 'npm'")
446
+ }
447
+ if (errors.length > 0) return { root, errors, warnings }
448
+
449
+ for (const file of PROJECT_REQUIRED_FILES) requirePath(root, file, errors)
450
+ if (runtime === "npm") requirePath(root, ".github/workflows/npm-publish.yml", errors)
451
+ const pluginRequiredFiles = runtime === "npm"
452
+ ? [...PLUGIN_REQUIRED_FILES, ...NPM_PLUGIN_REQUIRED_FILES]
453
+ : PLUGIN_REQUIRED_FILES
454
+ for (const file of pluginRequiredFiles) {
455
+ requirePath(root, join(pluginRootRelative, file), errors)
456
+ }
348
457
  if (errors.length > 0) return { root, errors, warnings }
349
458
 
350
- const packageJson = await readJson(join(root, "package.json"), errors)
351
- const claudePlugin = await readJson(join(root, ".claude-plugin/plugin.json"), errors)
459
+ const claudePlugin = await readJson(join(pluginRoot, ".claude-plugin/plugin.json"), errors)
352
460
  const claudeMarketplace = await readJson(
353
461
  join(root, ".claude-plugin/marketplace.json"),
354
462
  errors,
355
463
  )
356
- const codexPlugin = await readJson(join(root, ".codex-plugin/plugin.json"), errors)
464
+ const codexPlugin = await readJson(join(pluginRoot, ".codex-plugin/plugin.json"), errors)
357
465
  const codexMarketplace = await readJson(
358
466
  join(root, ".agents/plugins/marketplace.json"),
359
467
  errors,
360
468
  )
361
- const antigravityPlugin = await readJson(join(root, "plugin.json"), errors)
469
+ const antigravityPlugin = await readJson(join(pluginRoot, "plugin.json"), errors)
362
470
 
363
471
  if (errors.length > 0) return { root, errors, warnings }
364
472
 
365
- const packageBase = packageJson.name?.split("/").at(-1)
366
473
  const pluginName = claudePlugin.name
367
474
  const validName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
368
475
 
@@ -372,9 +479,6 @@ export async function validateProject(projectRoot, options = {}) {
372
479
  if ((pluginName?.length ?? 0) > 56) {
373
480
  errors.push("Plugin name must be at most 56 characters")
374
481
  }
375
- if (packageBase !== pluginName) {
376
- errors.push(`npm package basename '${packageBase}' must match plugin name '${pluginName}'`)
377
- }
378
482
 
379
483
  for (const [label, value] of [
380
484
  ["Codex plugin", codexPlugin.name],
@@ -383,64 +487,104 @@ export async function validateProject(projectRoot, options = {}) {
383
487
  if (value !== pluginName) errors.push(`${label} name '${value}' does not match '${pluginName}'`)
384
488
  }
385
489
 
386
- for (const [label, manifest] of [
387
- ["Claude plugin", claudePlugin],
388
- ["Codex plugin", codexPlugin],
389
- ]) {
390
- if (manifest.version !== packageJson.version) {
391
- errors.push(`${label} version does not match package.json`)
490
+ if (runtime === "skills") {
491
+ for (const [label, manifest] of [["Codex plugin", codexPlugin]]) {
492
+ if (manifest.version !== claudePlugin.version) {
493
+ errors.push(`${label} version does not match the Claude plugin`)
494
+ }
495
+ if (manifest.description !== claudePlugin.description) {
496
+ errors.push(`${label} description does not match the Claude plugin`)
497
+ }
392
498
  }
393
- if (manifest.description !== packageJson.description) {
394
- errors.push(`${label} description does not match package.json`)
499
+ if (antigravityPlugin.description !== claudePlugin.description) {
500
+ errors.push("Antigravity description does not match the Claude plugin")
395
501
  }
396
- }
397
- if (antigravityPlugin.description !== packageJson.description) {
398
- errors.push("Antigravity description does not match package.json")
399
- }
502
+ } else {
503
+ const packageJson = await readJson(join(pluginRoot, "package.json"), errors)
504
+ if (errors.length > 0) return { root, errors, warnings }
400
505
 
401
- if (packageJson.type !== "module") errors.push("package.json type must be 'module'")
402
- if (packageJson.engines?.node !== ">=22.20.0") {
403
- errors.push("package.json engines.node must be '>=22.20.0'")
404
- }
405
- const requiredExports = [
406
- [".", "import", "./dist/opencode.js"],
407
- [".", "types", "./dist/opencode.d.ts"],
408
- ["./deepseek", "import", "./dist/deepseek.js"],
409
- ["./deepseek", "types", "./dist/deepseek.d.ts"],
410
- ["./cordis.patch.yml", "import", "./cordis.patch.yml"],
411
- ]
412
- for (const [key, field, expected] of requiredExports) {
413
- const target = exportTarget(packageJson.exports, key, field)
414
- if (target !== expected) {
415
- errors.push(`package.json export '${key}' ${field} target must be '${expected}'`)
506
+ const packageBase = packageJson.name?.split("/").at(-1)
507
+ if (packageBase !== pluginName) {
508
+ errors.push(`npm package basename '${packageBase}' must match plugin name '${pluginName}'`)
509
+ }
510
+
511
+ for (const [label, manifest] of [
512
+ ["Claude plugin", claudePlugin],
513
+ ["Codex plugin", codexPlugin],
514
+ ]) {
515
+ if (manifest.version !== packageJson.version) {
516
+ errors.push(`${label} version does not match package.json`)
517
+ }
518
+ if (manifest.description !== packageJson.description) {
519
+ errors.push(`${label} description does not match package.json`)
520
+ }
521
+ }
522
+ if (antigravityPlugin.description !== packageJson.description) {
523
+ errors.push("Antigravity description does not match package.json")
524
+ }
525
+
526
+ if (packageJson.type !== "module") errors.push("package.json type must be 'module'")
527
+ if (opencodeExport === "." && packageJson.engines?.node !== ">=22.20.0") {
528
+ errors.push("package.json engines.node must be '>=22.20.0'")
529
+ }
530
+ const requiredExports = [
531
+ [opencodeExport, "import", "./dist/opencode.js"],
532
+ [opencodeExport, "types", "./dist/opencode.d.ts"],
533
+ ["./deepseek", "import", "./dist/deepseek.js"],
534
+ ["./deepseek", "types", "./dist/deepseek.d.ts"],
535
+ ["./cordis.patch.yml", "import", "./cordis.patch.yml"],
536
+ ]
537
+ for (const [key, field, expected] of requiredExports) {
538
+ const target = exportTarget(packageJson.exports, key, field)
539
+ if (target !== expected) {
540
+ errors.push(`package.json export '${key}' ${field} target must be '${expected}'`)
541
+ }
542
+ }
543
+ for (const entry of [
544
+ "dist",
545
+ "skills",
546
+ "alchemy.json",
547
+ "cordis.patch.yml",
548
+ ".claude-plugin/plugin.json",
549
+ ".codex-plugin/plugin.json",
550
+ "plugin.json",
551
+ ]) {
552
+ if (!packageJson.files?.includes(entry)) errors.push(`package.json files is missing '${entry}'`)
553
+ }
554
+ if (packageJson.dsh?.bundle?.patch !== "./cordis.patch.yml") {
555
+ errors.push("package.json dsh.bundle.patch must be './cordis.patch.yml'")
556
+ }
557
+
558
+ const patch = await readFile(join(pluginRoot, "cordis.patch.yml"), "utf8")
559
+ const expectedModule = `${packageJson.name}/deepseek`
560
+ const cordisEntries = parseCordisInsertEntries(patch)
561
+ .filter((entry) => entry.id === pluginName)
562
+ const cordisEntry = cordisEntries[0]
563
+ if (cordisEntries.length !== 1 || !cordisEntry?.valid) {
564
+ errors.push(`cordis.patch.yml must contain a valid insert entry for '${pluginName}'`)
565
+ } else if (cordisEntry.name !== expectedModule) {
566
+ errors.push(`cordis.patch.yml entry '${pluginName}' must load '${expectedModule}'`)
416
567
  }
417
568
  }
418
- for (const entry of [
419
- "dist",
420
- "skills",
421
- "cordis.patch.yml",
422
- ".claude-plugin/plugin.json",
423
- ".codex-plugin/plugin.json",
424
- "plugin.json",
425
- ]) {
426
- if (!packageJson.files?.includes(entry)) errors.push(`package.json files is missing '${entry}'`)
427
- }
428
- if (packageJson.dsh?.bundle?.patch !== "./cordis.patch.yml") {
429
- errors.push("package.json dsh.bundle.patch must be './cordis.patch.yml'")
430
- }
431
569
 
432
- await requireManifestPath(root, claudePlugin.skills, "Claude skills", errors)
433
- await requireManifestPath(root, codexPlugin.skills, "Codex skills", errors)
570
+ await requireManifestPath(pluginRoot, claudePlugin.skills, "Claude skills", errors)
571
+ await requireManifestPath(pluginRoot, codexPlugin.skills, "Codex skills", errors)
572
+
573
+ const marketplaceSource = pluginRoot === root
574
+ ? "./"
575
+ : `./${pluginRootRelative.replaceAll("\\", "/").replace(/^\.\//, "")}`
434
576
 
435
577
  const claudeEntry = claudeMarketplace.plugins?.find((entry) => entry.name === pluginName)
436
578
  if (!claudeEntry) errors.push("Claude marketplace is missing the plugin entry")
437
- else if (claudeEntry.source !== "./") errors.push("Claude marketplace source must be './'")
579
+ else if (claudeEntry.source !== marketplaceSource) {
580
+ errors.push(`Claude marketplace source must be '${marketplaceSource}'`)
581
+ }
438
582
 
439
583
  const codexEntry = codexMarketplace.plugins?.find((entry) => entry.name === pluginName)
440
584
  if (!codexEntry) errors.push("Codex marketplace is missing the plugin entry")
441
585
  else {
442
- if (codexEntry.source?.source !== "local" || codexEntry.source?.path !== "./") {
443
- errors.push("Codex marketplace source must be a local './' path")
586
+ if (codexEntry.source?.source !== "local" || codexEntry.source?.path !== marketplaceSource) {
587
+ errors.push(`Codex marketplace source must be a local '${marketplaceSource}' path`)
444
588
  }
445
589
  if (!codexEntry.policy?.installation || !codexEntry.policy?.authentication) {
446
590
  errors.push("Codex marketplace entry requires installation and authentication policies")
@@ -448,19 +592,9 @@ export async function validateProject(projectRoot, options = {}) {
448
592
  if (!codexEntry.category) errors.push("Codex marketplace entry requires a category")
449
593
  }
450
594
 
451
- const patch = await readFile(join(root, "cordis.patch.yml"), "utf8")
452
- const expectedModule = `${packageJson.name}/deepseek`
453
- const cordisEntries = parseCordisInsertEntries(patch)
454
- .filter((entry) => entry.id === pluginName)
455
- const cordisEntry = cordisEntries[0]
456
- if (cordisEntries.length !== 1 || !cordisEntry?.valid) {
457
- errors.push(`cordis.patch.yml must contain a valid insert entry for '${pluginName}'`)
458
- } else if (cordisEntry.name !== expectedModule) {
459
- errors.push(`cordis.patch.yml entry '${pluginName}' must load '${expectedModule}'`)
460
- }
461
595
 
462
596
  const maintenanceSkillFiles = await collectSkillFiles(root, ".agents/skills")
463
- const productSkillFiles = await collectSkillFiles(root, "skills")
597
+ const productSkillFiles = await collectSkillFiles(pluginRoot, "skills")
464
598
  const skillFiles = [...productSkillFiles, ...maintenanceSkillFiles]
465
599
  if (skillFiles.length < 2) errors.push("Expected a shared skill and a project development skill")
466
600
 
@@ -479,13 +613,13 @@ export async function validateProject(projectRoot, options = {}) {
479
613
  errors.push(`${skillFile}: invalid Agent Skill name`)
480
614
  }
481
615
  if (productSkillFiles.includes(skillFile)) {
482
- await checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts)
616
+ await checkProductSkillRuntime(skillFile, content, frontmatter, errors, pythonScripts, runtime)
483
617
  }
484
618
  }
485
619
  }
486
620
 
487
621
  if (pythonScripts.size > 0) {
488
- await smokePythonScripts([...pythonScripts], errors, warnings)
622
+ await smokePythonScripts([...pythonScripts], errors, warnings, runtime === "npm")
489
623
  }
490
624
 
491
625
  for (const path of await scanForTokens(root)) {
@@ -493,7 +627,7 @@ export async function validateProject(projectRoot, options = {}) {
493
627
  }
494
628
 
495
629
  if (options.external) {
496
- const result = spawnSync("claude", ["plugin", "validate", root, "--strict"], {
630
+ const result = spawnSync("claude", ["plugin", "validate", pluginRoot, "--strict"], {
497
631
  encoding: "utf8",
498
632
  })
499
633
  if (result.error?.code === "ENOENT") warnings.push("Claude CLI not found; skipped external validation")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-alchemist",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Scaffold, validate, and publish portable coding-agent plugins across Claude Code, Codex, OpenCode, Antigravity, and DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  "lib",
26
26
  "templates",
27
27
  "skills",
28
+ "alchemy.schema.json",
28
29
  "cordis.patch.yml",
29
30
  ".claude-plugin/plugin.json",
30
31
  ".codex-plugin/plugin.json",
@@ -16,7 +16,11 @@ replace the generated repository's local maintenance skill.
16
16
  2. Read [references/compatibility.md](references/compatibility.md) before choosing paths or manifests.
17
17
  3. For a new project, run `harness-alchemist create --help` and use the CLI instead of recreating the structure manually.
18
18
  4. After creation, load `.agents/skills/develop-<name>/` in the generated project before maintaining its files.
19
- 5. Run `harness-alchemist validate` and the generated package's `verify` script before reporting completion.
19
+ 5. For an existing monorepo or SDK package, use the explicit
20
+ `alchemy.json` adaptation described in
21
+ [references/compatibility.md](references/compatibility.md); do not replace the
22
+ product package root merely to match the generated layout.
23
+ 6. Run `harness-alchemist validate` and the generated package's `verify` script before reporting completion.
20
24
 
21
25
  ## Create A Project
22
26
 
@@ -28,6 +28,20 @@ stdout), and `src/opencode.ts` and `src/deepseek.ts` stay thin adapters that
28
28
  spawn those scripts. See the generated project's tool contract reference for
29
29
  details.
30
30
 
31
+ ## Existing repositories
32
+
33
+ Existing monorepos may declare `pluginRoot` and an optional `./server` OpenCode
34
+ export in root `alchemy.json`. The repository root keeps marketplaces
35
+ and maintenance guidance; the plugin package keeps product skills, host
36
+ manifests, adapter sources, Cordis patch, and npm metadata. Generated repositories
37
+ remain single-package and need no configuration file.
38
+
39
+ The optional `runtime` field adapts the contract to the repository's language:
40
+ `"npm"` (default) requires the full generated package; `"skills"` accepts
41
+ skills and harness manifests alone — no npm package, adapters, Cordis patch,
42
+ or `.mjs`/`.py` twins — so polyglot repositories can publish workflows to
43
+ Claude Code, Codex, Antigravity, and DeepSeek without a JavaScript runtime.
44
+
31
45
  ## Capability Matrix
32
46
 
33
47
  | Artifact | Claude | Codex | OpenCode | Antigravity | DeepSeek |
@@ -6,6 +6,16 @@
6
6
  - `.agents/skills/develop-{{NAME}}/` is repository-local development guidance and is discoverable by Vercel Skills.
7
7
  - OpenCode and DeepSeek users install shared skills separately from the npm runtime plugin.
8
8
 
9
+ ## Layout Manifest
10
+
11
+ `alchemy.json` records this repository's layout: `runtime: "npm"`,
12
+ the canonical `template` version, `generator`, `generatorVersion`, and
13
+ `createdAt`. It is validated against the published JSON Schema referenced by
14
+ `$schema`. `npm run sync` refreshes `generatorVersion` from `package.json`.
15
+ If this project ever moves into a monorepo, add `pluginRoot` (and optionally
16
+ `opencodeExport: "./server"` for SDK packages); `runtime: "skills"` adapts
17
+ non-JavaScript repositories to skills-only validation.
18
+
9
19
  ## Harness-Specific Contract
10
20
 
11
21
  | Harness | Files | Rule |
@@ -53,6 +53,7 @@ for (const required of [
53
53
  "cordis.patch.yml",
54
54
  "dist/deepseek.js",
55
55
  "dist/opencode.js",
56
+ "alchemy.json",
56
57
  "plugin.json",
57
58
  ]) {
58
59
  if (!files.has(required)) throw new Error(`npm package is missing ${required}`)
@@ -121,4 +121,13 @@ for (let index = 0; index < cordisLines.length; index += 1) {
121
121
  if (!updatedCordisEntry) throw new Error(`Could not find Cordis entry '${pluginName}' to update`)
122
122
  await writeFile(cordisPath, cordisLines.join("\n"))
123
123
 
124
+ const layoutPath = join(root, "alchemy.json")
125
+ if (existsSync(layoutPath)) {
126
+ const layout = await readJson(layoutPath)
127
+ if (layout.generatorVersion !== packageJson.version) {
128
+ layout.generatorVersion = packageJson.version
129
+ await writeJson(layoutPath, layout)
130
+ }
131
+ }
132
+
124
133
  console.log(`Synchronized plugin manifests from ${packagePath}`)
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://unpkg.com/harness-alchemist/alchemy.schema.json",
3
+ "runtime": "npm",
4
+ "template": "{{TEMPLATE_VERSION}}",
5
+ "generator": "harness-alchemist",
6
+ "generatorVersion": "{{GENERATOR_VERSION}}",
7
+ "createdAt": "{{CREATED_AT}}"
8
+ }
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "skills",
22
+ "alchemy.json",
22
23
  "cordis.patch.yml",
23
24
  ".claude-plugin/plugin.json",
24
25
  ".codex-plugin/plugin.json",