@tenphi/harnesspm 0.0.0-snapshot.376310a

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -0
  4. package/dist/added.js +189 -0
  5. package/dist/check.js +160 -0
  6. package/dist/cli.js +139 -0
  7. package/dist/commands.js +414 -0
  8. package/dist/config.js +43 -0
  9. package/dist/exec-outputs.js +360 -0
  10. package/dist/exec-units.js +144 -0
  11. package/dist/fetch.js +93 -0
  12. package/dist/frontmatter.js +35 -0
  13. package/dist/homes.js +17 -0
  14. package/dist/lock.js +102 -0
  15. package/dist/manifest.js +154 -0
  16. package/dist/new.js +223 -0
  17. package/dist/outputs.js +453 -0
  18. package/dist/render.js +119 -0
  19. package/dist/resolve.js +396 -0
  20. package/dist/run.js +83 -0
  21. package/dist/sections.js +121 -0
  22. package/dist/user-outputs.js +195 -0
  23. package/dist/util.js +140 -0
  24. package/dist/workspaces.js +61 -0
  25. package/docs/spec/01-overview.md +130 -0
  26. package/docs/spec/02-package-format.md +238 -0
  27. package/docs/spec/03-resolution.md +104 -0
  28. package/docs/spec/04-outputs.md +109 -0
  29. package/docs/spec/05-executables.md +158 -0
  30. package/docs/spec/06-trust.md +72 -0
  31. package/docs/spec/07-added.md +104 -0
  32. package/docs/spec/08-improving.md +77 -0
  33. package/docs/spec/09-cloud.md +47 -0
  34. package/docs/spec/10-cli.md +60 -0
  35. package/docs/spec/11-standards.md +49 -0
  36. package/docs/spec/12-dogfooding.md +51 -0
  37. package/docs/spec/13-open-questions.md +85 -0
  38. package/docs/spec/README.md +33 -0
  39. package/harness/dev.harnesspm/rules/generated-files.md +4 -0
  40. package/harness/dev.harnesspm/rules/harnesspm-config.md +11 -0
  41. package/harness/plugin.json +13 -0
  42. package/harness/skills/harnesspm-export/SKILL.md +39 -0
  43. package/harness/skills/harnesspm-import/SKILL.md +35 -0
  44. package/harness/skills/harnesspm-improve/SKILL.md +38 -0
  45. package/package.json +60 -0
  46. package/schema/harnesspm.schema.json +31 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first release: a proof of concept that covers every scenario of the [specification](docs/spec/README.md) (version 0.7 draft).
6
+
7
+ - **Team harness:** `init`, `sync`, `sync --check`, `status`, `why` and `check`. Discovery from dependencies, `extends`, one version per package, `exclude`, `replace` and `patch`, the managed block in `AGENTS.md`, skill and rule links, a docs index and `harnesspm.lock.json`.
8
+ - **Added harnesses:** `add`, `remove` and `update`, for a team's or a person's own harness on top of a repository, with user and local scope, project entries and a `SessionStart` hook.
9
+ - **Executables and trust:** scripts, hooks, MCP servers, permissions and sub-agents for Claude Code and Codex; `trust` with `true` or `"pin"`; `run`, `mcp` and `approve`. Nothing that widens an agent's permissions is written when the agent's model runs harnesspm.
10
+ - **Monorepos, `fetch` and `new`:** a block per workspace; downloading the locked harness packages for jobs without an install; templates for every unit kind and for patches.
11
+ - **Package:** published as `@tenphi/harnesspm`; the command is `harnesspm`. Commands that may run without an install use `npx -y @tenphi/harnesspm`, and allow rules for an added harness's scripts match those commands.
12
+ - **Docs index:** built from `.md` and `.mdx` pages, titled by frontmatter `title`, else the first heading. Found by the first pilot, `@tenphi/glaze`.
13
+ - **`check`:** `files` may list entries inside the docs folder, such as `docs/components` or `docs/*.md`, so a package can leave contributor docs out. Found by the `@cube-dev/ui-kit` pilot.
14
+
15
+ Known limits: `npm install <pkg>` and `pnpm add` do not run the install hook, so run `npx harnesspm sync` after them; Codex outputs are checked against its source and docs only; Yarn Plug'n'Play and Windows are not tested.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tenphi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # harnesspm — the harness package manager
2
+
3
+ > **Status: 0.1.0, a working proof of concept of the [specification](docs/spec/README.md) (0.7 draft).** It covers every scenario. Expect changes before 1.0.
4
+
5
+ harnesspm moves an **AI harness** between projects through npm packages. A harness is everything an AI coding agent needs to work well with a codebase: rules, docs, skills, sub-agents, scripts, hooks, MCP servers and permissions.
6
+
7
+ ## How it works
8
+
9
+ | Flow | Who | What they do |
10
+ | --- | --- | --- |
11
+ | 📤 **Share** | A library | Adds a `harness/` folder to its npm package. It can pass on the harness of the libraries it builds on (`extends`). |
12
+ | 📥 **Use** | A project | `npm i -D @tenphi/harnesspm` and `npx harnesspm init`. After every install, Claude Code and Codex see the harness of the installed versions. |
13
+ | ➕ **Add on top** | A person | `npx harnesspm add github:me/harness`. A team's or their own workflow, context and tools apply on top, without changing the project's repository. |
14
+ | 🛠️ **Improve** | An agent | `npx harnesspm why <unit>` finds the source. The agent edits it and saves the change the way the owner's rules say, by default with a pull request. |
15
+
16
+ ```
17
+ dependencies (harness/ in node_modules, through extends)
18
+ ▼
19
+ project (harnesspm.json, the project's own harness/) → AGENTS.md block + harnesspm.lock.json, committed
20
+ ▼
21
+ added (harnesses you add: your team's, your own) → per-machine files, never committed
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ In a project:
27
+
28
+ ```bash
29
+ npm i -D @tenphi/harnesspm
30
+ npx harnesspm init
31
+ ```
32
+
33
+ `init` creates `harnesspm.json` and runs `harnesspm sync` after every full install. Commit `AGENTS.md`, `CLAUDE.md`, `harnesspm.json` and `harnesspm.lock.json`.
34
+
35
+ In a library, to share its harness:
36
+
37
+ ```bash
38
+ npx harnesspm new rule usage
39
+ npx harnesspm check
40
+ ```
41
+
42
+ Add `"harness"` to the `files` field of `package.json`. To add your own or your team's harness on top of every repository on your machine:
43
+
44
+ ```bash
45
+ npx harnesspm add github:<owner>/<repo>
46
+ ```
47
+
48
+ Node.js 20 or later. `npx harnesspm help` lists every command. [CHANGELOG.md](CHANGELOG.md) lists the changes.
49
+
50
+ ## First targets
51
+
52
+ - **Agents:** Claude Code and Codex. Other agents that read `AGENTS.md` and `.agents/skills` get the portable parts.
53
+ - **Package managers:** npm, pnpm, Yarn 1 and Yarn 4 (node-modules linker).
54
+
55
+ ## Proof of concept
56
+
57
+ | Milestone | Scenarios | Status |
58
+ | --- | --- | --- |
59
+ | 1. Team harness: `init`, `sync`, `sync --check`, `status`, `why`, `check` | S1–S6 | ✅ |
60
+ | 2. Added harnesses: `add`, `remove`, `update`, project entries, the `SessionStart` hook | S9, S10, S12 | ✅ |
61
+ | 3. Executables and trust: `run`, hooks, MCP servers, permissions, `approve` | S8 | ✅ |
62
+ | 4. Agents and the rest: Claude Code reading the outputs, monorepos, `fetch`, `new` | S7, S11, S13 | ✅ |
63
+
64
+ ## Read the specification
65
+
66
+ Start with [docs/spec/README.md](docs/spec/README.md). Every chapter opens with a short summary.
67
+
68
+ | Chapter | What it covers |
69
+ | --- | --- |
70
+ | [1. Overview](docs/spec/01-overview.md) | How it works, goals, terms, base scenarios |
71
+ | [2. Package format](docs/spec/02-package-format.md) | The `harness/` folder: an Agent Plugins plugin plus the harnesspm extension |
72
+ | [3. Resolution](docs/spec/03-resolution.md) | Layers, `extends`, overrides, versions, monorepos |
73
+ | [4. Outputs](docs/spec/04-outputs.md) | What harnesspm writes for Claude Code and Codex |
74
+ | [5. Executables](docs/spec/05-executables.md) | Scripts, hooks, MCP servers, permissions |
75
+ | [6. Trust and lock](docs/spec/06-trust.md) | What may run, and how changes are reviewed |
76
+ | [7. Added harnesses](docs/spec/07-added.md) | A team's or a person's workflow, context and tools, added on top of a repository |
77
+ | [8. Improving a harness](docs/spec/08-improving.md) | How agents change any harness under its owner's rules |
78
+ | [9. Cloud sessions](docs/spec/09-cloud.md) | Claude Code cloud environments (Codex Cloud is not in v1) |
79
+ | [10. CLI](docs/spec/10-cli.md) | Commands |
80
+ | [11. Standards](docs/spec/11-standards.md) | Which open standards harnesspm uses and where it goes beyond them |
81
+ | [12. Dogfooding](docs/spec/12-dogfooding.md) | How this repository uses and publishes its own harness |
82
+ | [13. Open questions](docs/spec/13-open-questions.md) | Undecided points, and options removed in review |
83
+
84
+ Background research lives in [docs/research/](docs/research/).
85
+
86
+ ## Name
87
+
88
+ **harnesspm** stands for *harness package manager*, in the line of npm and pnpm. It is written in lowercase. The npm package and the command are both `harnesspm`, and the extension namespace is `dev.harnesspm`, based on the domain `harnesspm.dev`.
package/dist/added.js ADDED
@@ -0,0 +1,189 @@
1
+ // Added harnesses: sources, scopes and project entries (spec chapter 7).
2
+ import { spawnSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { loadHarnessPackage, NS, parseExtension } from './manifest.js';
7
+ import { HarnessError, NPX_ANY, ensureDir, isDir, isFile, isRecord, readJson, readJsonIfExists } from './util.js';
8
+ export const LOCAL_FILE = 'harnesspm.local.json';
9
+ /** harnesspm's own folder for working copies, caches and state. */
10
+ export function harnesspmHome() {
11
+ return process.env['HARNESSPM_HOME'] ?? path.join(os.homedir(), '.harnesspm');
12
+ }
13
+ export function userConfigFile() {
14
+ return path.join(harnesspmHome(), 'config.json');
15
+ }
16
+ export function configFileFor(scope, projectDir) {
17
+ return scope === 'user' ? userConfigFile() : path.join(projectDir, LOCAL_FILE);
18
+ }
19
+ export function readList(file) {
20
+ const raw = readJsonIfExists(file);
21
+ if (raw === undefined)
22
+ return [];
23
+ if (!isRecord(raw) || (raw['harnesses'] !== undefined && !Array.isArray(raw['harnesses']))) {
24
+ throw new HarnessError(`${file}: "harnesses" must be an array of sources`);
25
+ }
26
+ return (raw['harnesses'] ?? []).filter((s) => typeof s === 'string');
27
+ }
28
+ export function writeList(file, list) {
29
+ const raw = readJsonIfExists(file);
30
+ const data = isRecord(raw) ? raw : {};
31
+ data['harnesses'] = list;
32
+ ensureDir(path.dirname(file));
33
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
34
+ }
35
+ function expandHome(p) {
36
+ return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p;
37
+ }
38
+ function slug(text) {
39
+ return text.replace(/^@/, '').replace(/[^A-Za-z0-9._-]+/g, '-');
40
+ }
41
+ export function parseSource(spec, cwd = process.cwd()) {
42
+ if (spec.startsWith('github:')) {
43
+ const repo = spec.slice('github:'.length).replace(/\.git$/, '');
44
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo))
45
+ throw new HarnessError(`Use github:<owner>/<repo>, not ${spec}`);
46
+ return { spec, kind: 'git', location: `https://github.com/${repo}.git`, dir: path.join(harnesspmHome(), 'harnesses', slug(repo)), editable: true };
47
+ }
48
+ if (spec.startsWith('git:')) {
49
+ const url = spec.slice('git:'.length);
50
+ const parts = url.replace(/\.git$/, '').split(/[/:]/).filter(Boolean).slice(-2);
51
+ return { spec, kind: 'git', location: url, dir: path.join(harnesspmHome(), 'harnesses', slug(parts.join('/'))), editable: true };
52
+ }
53
+ if (spec.startsWith('npm:')) {
54
+ const name = spec.slice('npm:'.length);
55
+ return { spec, kind: 'npm', location: name, dir: path.join(harnesspmHome(), 'cache', slug(name.replace(/@[^@/]*$/, '')), 'package'), editable: false };
56
+ }
57
+ const dir = path.resolve(cwd, expandHome(spec));
58
+ return { spec: dir, kind: 'folder', location: dir, dir, editable: true };
59
+ }
60
+ function run(cmd, args, cwd) {
61
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
62
+ return { ok: result.status === 0, out: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
63
+ }
64
+ /** Puts the source on this machine. Returns a note for the person. */
65
+ export function fetchSource(source) {
66
+ if (source.kind === 'folder') {
67
+ if (!isDir(source.dir))
68
+ throw new HarnessError(`Folder not found: ${source.dir}`);
69
+ return undefined;
70
+ }
71
+ if (source.kind === 'git') {
72
+ if (isDir(path.join(source.dir, '.git')))
73
+ return undefined;
74
+ ensureDir(path.dirname(source.dir));
75
+ const result = run('git', ['clone', '--quiet', source.location, source.dir]);
76
+ if (!result.ok)
77
+ throw new HarnessError(`git clone ${source.location} failed: ${result.out}`);
78
+ return `cloned ${source.location} into ${source.dir}`;
79
+ }
80
+ return fetchNpm(source);
81
+ }
82
+ function fetchNpm(source) {
83
+ const cache = path.dirname(source.dir);
84
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'harnesspm-npm-'));
85
+ const packed = run('npm', ['pack', source.location, '--silent', '--pack-destination', tmp]);
86
+ if (!packed.ok)
87
+ throw new HarnessError(`npm pack ${source.location} failed: ${packed.out}`);
88
+ const tarball = path.join(tmp, packed.out.split('\n').pop().trim());
89
+ fs.rmSync(cache, { recursive: true, force: true });
90
+ ensureDir(cache);
91
+ const extracted = run('tar', ['-xzf', tarball, '-C', cache]);
92
+ fs.rmSync(tmp, { recursive: true, force: true });
93
+ if (!extracted.ok)
94
+ throw new HarnessError(`Cannot unpack ${source.location}: ${extracted.out}`);
95
+ return `fetched ${source.location} into ${cache}`;
96
+ }
97
+ /** Brings a source up to date. Never changes a working copy with local work (spec 8.7). */
98
+ export function updateSource(source) {
99
+ if (source.kind === 'folder')
100
+ return `${source.spec}: a folder, used as it is`;
101
+ if (source.kind === 'npm')
102
+ return fetchNpm(source);
103
+ if (!isDir(path.join(source.dir, '.git'))) {
104
+ fetchSource(source);
105
+ return `${source.spec}: cloned`;
106
+ }
107
+ const dirty = run('git', ['status', '--porcelain'], source.dir);
108
+ if (dirty.out !== '')
109
+ return `${source.spec}: skipped, the working copy has uncommitted changes`;
110
+ const ahead = run('git', ['rev-list', '--count', '@{upstream}..HEAD'], source.dir);
111
+ if (ahead.ok && ahead.out !== '0')
112
+ return `${source.spec}: skipped, the working copy has ${ahead.out} unpushed commits`;
113
+ const pulled = run('git', ['pull', '--quiet', '--ff-only'], source.dir);
114
+ return pulled.ok ? `${source.spec}: up to date` : `${source.spec}: pull failed: ${pulled.out}`;
115
+ }
116
+ /** `<host>/<path>` of a repository's `origin` remote, without `.git`, for SSH and HTTPS URLs. */
117
+ export function remoteKey(projectDir) {
118
+ const result = run('git', ['remote', 'get-url', 'origin'], projectDir);
119
+ if (!result.ok || result.out === '')
120
+ return undefined;
121
+ return normalizeRemote(result.out);
122
+ }
123
+ export function normalizeRemote(url) {
124
+ let rest = url.trim().replace(/\.git$/, '').replace(/\/$/, '');
125
+ const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(rest);
126
+ if (scp)
127
+ return `${scp[1]}/${scp[2].replace(/^\/+/, '')}`;
128
+ rest = rest.replace(/^[a-z+]+:\/\//i, '').replace(/^[^@/]+@/, '');
129
+ const [hostPort, ...segments] = rest.split('/');
130
+ return [hostPort.replace(/:\d+$/, ''), ...segments].join('/');
131
+ }
132
+ function loadEntry(pkg, key) {
133
+ if (key === undefined)
134
+ return undefined;
135
+ const rel = pkg.ext.projects[key];
136
+ if (rel === undefined)
137
+ return undefined;
138
+ const dir = path.resolve(pkg.harnessDir, rel);
139
+ if (!isDir(dir))
140
+ return undefined;
141
+ // An entry has the layout of harness/. Its optional plugin.json uses the harness's name and holds overrides.
142
+ const manifest = path.join(dir, 'plugin.json');
143
+ const raw = isFile(manifest) ? readJson(manifest) : {};
144
+ const data = isRecord(raw) ? raw : {};
145
+ if (data['name'] !== undefined && data['name'] !== pkg.plugin) {
146
+ throw new HarnessError(`${manifest}: "name" must be "${pkg.plugin}", the name of its harness`);
147
+ }
148
+ const problems = [];
149
+ const ext = parseExtension(data, problems);
150
+ return { ...pkg, name: `${pkg.name} (entry ${key})`, dir, harnessDir: dir, ext: { ...ext, projects: {} }, problems };
151
+ }
152
+ /** Added harnesses in order: user scope first, then local scope, each as listed. */
153
+ export function loadAdded(projectDir, warnings) {
154
+ const result = [];
155
+ const key = projectDir !== undefined ? remoteKey(projectDir) : undefined;
156
+ const scopes = projectDir !== undefined ? ['user', 'local'] : ['user'];
157
+ for (const scope of scopes) {
158
+ for (const spec of readList(configFileFor(scope, projectDir ?? ''))) {
159
+ const source = parseSource(spec);
160
+ if (!isFile(path.join(source.dir, 'harness', 'plugin.json'))) {
161
+ warnings.push(`${spec} is not on this machine yet. Run \`${NPX_ANY} update\`.`);
162
+ continue;
163
+ }
164
+ const pkg = loadHarnessPackage(source.dir, 'added');
165
+ if (pkg.name === path.basename(source.dir))
166
+ pkg.name = spec;
167
+ const entry = loadEntry(pkg, key);
168
+ result.push({ source, scope, pkg, ...(entry ? { entry } : {}) });
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+ /**
174
+ * Who runs harnesspm (spec 6.2, rule 4). Claude Code marks model commands (`AI_AGENT=…_agent`) and hooks
175
+ * (`…_harness`). Codex sets `CODEX_CI=1` and `CODEX_TOOL_CALL_ID` only in commands the model runs.
176
+ */
177
+ export function caller(env = process.env) {
178
+ const agent = env['AI_AGENT'] ?? '';
179
+ if (agent.endsWith('_agent'))
180
+ return 'model';
181
+ if (agent.endsWith('_harness'))
182
+ return 'hook';
183
+ if (agent === '' && env['CLAUDECODE'] === '1')
184
+ return 'model';
185
+ if (env['CODEX_CI'] === '1' || (env['CODEX_TOOL_CALL_ID'] ?? '') !== '')
186
+ return 'model';
187
+ return 'person';
188
+ }
189
+ export const RULES_DIR = path.join(NS, 'rules');
package/dist/check.js ADDED
@@ -0,0 +1,160 @@
1
+ // harnesspm check: validate a harness before it is published (spec 2.10).
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { parseFrontmatter, stringList } from './frontmatter.js';
5
+ import { discoverExecUnits } from './exec-units.js';
6
+ import { discoverUnits, loadHarnessPackage, NS, ruleMeta } from './manifest.js';
7
+ import { HarnessError, isDir, isFile, isRecord, readJsonIfExists, readText, relPosix } from './util.js';
8
+ const SECRET_PATTERNS = [
9
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'a private key'],
10
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'a GitHub token'],
11
+ [/\bsk-[A-Za-z0-9_-]{20,}\b/, 'an API key'],
12
+ [/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key'],
13
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'a Slack token'],
14
+ ];
15
+ function inside(dir, file) {
16
+ const rel = path.relative(dir, file);
17
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
18
+ }
19
+ function walkFiles(dir) {
20
+ const files = [];
21
+ const walk = (current) => {
22
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
23
+ const full = path.join(current, entry.name);
24
+ if (entry.isDirectory())
25
+ walk(full);
26
+ else if (entry.isFile())
27
+ files.push(full);
28
+ }
29
+ };
30
+ if (isDir(dir))
31
+ walk(dir);
32
+ return files.sort();
33
+ }
34
+ /** `target` is a package root, or a harness/ folder, or an added harness folder. */
35
+ export function checkHarness(target) {
36
+ let root = path.resolve(target);
37
+ if (path.basename(root) === 'harness' && isFile(path.join(root, 'plugin.json')))
38
+ root = path.dirname(root);
39
+ const pkg = loadHarnessPackage(root, 'project');
40
+ if (!pkg)
41
+ throw new HarnessError(`No harness found: ${relPosix(process.cwd(), path.join(root, 'harness', 'plugin.json'))} does not exist`);
42
+ const errors = [...pkg.problems];
43
+ const warnings = [];
44
+ const show = (file) => relPosix(root, file);
45
+ const pkgJson = readJsonIfExists(path.join(root, 'package.json'));
46
+ if (isRecord(pkgJson) && Array.isArray(pkgJson['files'])) {
47
+ const files = pkgJson['files'].filter((f) => typeof f === 'string').map((f) => f.replace(/^\.\//, '').replace(/\/$/, ''));
48
+ if (!files.includes('harness'))
49
+ errors.push('package.json: "files" must include "harness"');
50
+ if (pkg.ext.docs !== undefined) {
51
+ const docsRel = relPosix(root, path.resolve(pkg.harnessDir, pkg.ext.docs));
52
+ // The folder itself, a parent of it, or entries inside it, such as `docs/components` or `docs/*.md`.
53
+ const covers = (f) => docsRel === f || docsRel.startsWith(`${f}/`) || f.startsWith(`${docsRel}/`);
54
+ if (!files.some(covers))
55
+ errors.push(`package.json: "files" must include the docs folder "${docsRel}", or entries inside it`);
56
+ }
57
+ }
58
+ const checkPath = (label, rel, mustExist) => {
59
+ const full = path.resolve(pkg.harnessDir, rel);
60
+ if (!inside(root, full))
61
+ errors.push(`${label}: ${rel} points outside the package`);
62
+ else if (mustExist === 'file' ? !isFile(full) : !isDir(full))
63
+ errors.push(`${label}: ${rel} does not exist`);
64
+ };
65
+ for (const [id, file] of Object.entries(pkg.ext.replace))
66
+ checkPath(`replace ${id}`, file, 'file');
67
+ for (const [id, file] of Object.entries(pkg.ext.patch)) {
68
+ checkPath(`patch ${id}`, file, 'file');
69
+ if (!id.includes(':rules/'))
70
+ errors.push(`patch ${id}: patches work on rules only`);
71
+ }
72
+ if (pkg.ext.docs !== undefined)
73
+ checkPath('docs', pkg.ext.docs, 'dir');
74
+ for (const [name, def] of Object.entries(pkg.ext.scripts)) {
75
+ if (!def.file.startsWith('./'))
76
+ errors.push(`script ${name}: "file" must start with "./"`);
77
+ checkPath(`script ${name}`, def.file, 'file');
78
+ }
79
+ for (const [remote, dir] of Object.entries(pkg.ext.projects))
80
+ checkPath(`project entry ${remote}`, dir, 'dir');
81
+ const overrideFiles = new Set([...Object.values(pkg.ext.replace), ...Object.values(pkg.ext.patch)].map((f) => path.resolve(pkg.harnessDir, f)));
82
+ const units = discoverUnits(pkg, overrideFiles);
83
+ let alwaysOnBytes = 0;
84
+ for (const rule of units.rules) {
85
+ try {
86
+ const text = readText(rule.file);
87
+ const meta = ruleMeta(text, rule.name, rule.file);
88
+ const { data } = parseFrontmatter(text, rule.file);
89
+ if (data['paths'] !== undefined && !stringList(data['paths']))
90
+ errors.push(`${show(rule.file)}: "paths" must be a string or a list of strings`);
91
+ if (data['description'] !== undefined && typeof data['description'] !== 'string')
92
+ errors.push(`${show(rule.file)}: "description" must be a string`);
93
+ if (meta.activation === 'always')
94
+ alwaysOnBytes += Buffer.byteLength(meta.body);
95
+ }
96
+ catch (error) {
97
+ errors.push(error.message);
98
+ }
99
+ }
100
+ for (const skill of units.skills) {
101
+ const file = path.join(skill.dir, 'SKILL.md');
102
+ try {
103
+ const { data, hasFrontmatter } = parseFrontmatter(readText(file), file);
104
+ if (!hasFrontmatter)
105
+ errors.push(`${show(file)}: needs frontmatter with "name" and "description"`);
106
+ if (data['name'] !== skill.name)
107
+ errors.push(`${show(file)}: "name" must be "${skill.name}", the folder name`);
108
+ if (typeof data['description'] !== 'string' || data['description'].trim() === '')
109
+ errors.push(`${show(file)}: needs a "description"`);
110
+ if (!skill.name.startsWith(`${pkg.plugin}-`) && skill.name !== pkg.plugin) {
111
+ warnings.push(`skill "${skill.name}" should start with the plugin name, such as "${pkg.plugin}-${skill.name}"`);
112
+ }
113
+ }
114
+ catch (error) {
115
+ errors.push(error.message);
116
+ }
117
+ }
118
+ let jsonOk = true;
119
+ for (const json of ['mcp.json', path.join(NS, 'hooks.json'), path.join(NS, 'permissions.json')]) {
120
+ const file = path.join(pkg.harnessDir, json);
121
+ if (isFile(file)) {
122
+ try {
123
+ JSON.parse(readText(file));
124
+ }
125
+ catch (error) {
126
+ jsonOk = false;
127
+ errors.push(`${show(file)}: ${error.message}`);
128
+ }
129
+ }
130
+ }
131
+ if (jsonOk) {
132
+ try {
133
+ discoverExecUnits(pkg, errors);
134
+ }
135
+ catch (error) {
136
+ errors.push(error.message);
137
+ }
138
+ }
139
+ // Links in Markdown and secrets in any file.
140
+ const scanned = [...walkFiles(pkg.harnessDir), ...(units.docs ? walkFiles(units.docs.dir) : [])];
141
+ for (const file of [...new Set(scanned)]) {
142
+ const text = fs.readFileSync(file, 'utf8');
143
+ for (const [pattern, what] of SECRET_PATTERNS)
144
+ if (pattern.test(text))
145
+ warnings.push(`${show(file)} looks like it contains ${what}`);
146
+ if (!file.endsWith('.md'))
147
+ continue;
148
+ for (const match of text.matchAll(/\]\(([^)\s#]+)(?:#[^)]*)?\)/g)) {
149
+ const link = match[1];
150
+ if (/^[a-z][a-z0-9+.-]*:/i.test(link) || link.startsWith('/'))
151
+ continue;
152
+ const target = path.resolve(path.dirname(file), decodeURI(link));
153
+ if (!inside(root, target))
154
+ errors.push(`${show(file)}: the link ${link} points outside the package`);
155
+ else if (!fs.existsSync(target))
156
+ errors.push(`${show(file)}: the link ${link} points at a missing file`);
157
+ }
158
+ }
159
+ return { errors, warnings, alwaysOnBytes, plugin: pkg.plugin };
160
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ import { add, approve, check, fetch, init, mcp, newUnit, remove, run, status, sync, update, why } from './commands.js';
3
+ import { Refused } from './run.js';
4
+ import { HarnessError, readJson } from './util.js';
5
+ const HELP = `harnesspm — the harness package manager
6
+
7
+ Projects
8
+ harnesspm init set up harnesspm in this project
9
+ harnesspm sync [--check] write the agent files (--check: compare only, for CI)
10
+ harnesspm status layers, packages and always-on size
11
+ harnesspm why <ID> where a unit comes from and how to change it
12
+ harnesspm run <plugin>:<script> [-- args]
13
+ run a script of a trusted package
14
+ harnesspm approve [<package>] approve a "pin" package's new version, and write what an agent could not
15
+ harnesspm fetch download the harness packages in the lock, for jobs without an install
16
+
17
+ Harnesses on top of the repository's (your team's, your own)
18
+ harnesspm add <source> [--local] [--no-hook]
19
+ add a harness: github:<owner>/<repo>, git:<url>, npm:<name> or a folder
20
+ harnesspm remove <source> [--local]
21
+ harnesspm update fetch new versions of added harnesses
22
+
23
+ Authors
24
+ harnesspm check [<folder>] validate a harness before publishing
25
+ harnesspm new <kind> <name> [--to <plugin>] [--entry]
26
+ a rule, skill, agent, script, hook or mcp from a template
27
+ harnesspm new patch <rule ID> [--to <plugin>] [--entry]
28
+ a patch that changes some sections of a rule
29
+ `;
30
+ const out = {
31
+ log: (line) => console.log(line),
32
+ warn: (line) => console.error(line),
33
+ };
34
+ function main(argv) {
35
+ const [command, ...rest] = argv;
36
+ if (command !== 'run' && command !== 'mcp' && (rest.includes('--help') || rest.includes('-h'))) {
37
+ console.log(HELP);
38
+ return 0;
39
+ }
40
+ if (command === 'run' || command === 'mcp') {
41
+ // Everything after the script name goes to the script, untouched.
42
+ let agent;
43
+ let i = 0;
44
+ if (rest[0] === '--agent') {
45
+ agent = rest[1];
46
+ i = 2;
47
+ }
48
+ const ref = rest[i];
49
+ if (ref === undefined)
50
+ throw new HarnessError(`Usage: harnesspm ${command} [--agent <claude|codex>] <plugin>:<name>${command === 'run' ? ' [-- args]' : ''}`);
51
+ const args = rest.slice(i + 1);
52
+ if (args[0] === '--')
53
+ args.shift();
54
+ return command === 'run' ? run(process.cwd(), ref, args, agent) : mcp(process.cwd(), ref, agent);
55
+ }
56
+ const flags = new Set(rest.filter((a) => a.startsWith('--')));
57
+ const args = rest.filter((a) => !a.startsWith('--'));
58
+ const cwd = process.cwd();
59
+ const unknown = (allowed) => {
60
+ for (const flag of flags)
61
+ if (!allowed.includes(flag))
62
+ throw new HarnessError(`Unknown option ${flag} for ${command}`);
63
+ };
64
+ switch (command) {
65
+ case 'init':
66
+ unknown([]);
67
+ return init(cwd, out);
68
+ case 'sync':
69
+ unknown(['--check']);
70
+ return sync(cwd, out, { check: flags.has('--check') });
71
+ case 'status':
72
+ unknown([]);
73
+ return status(cwd, out);
74
+ case 'why':
75
+ unknown([]);
76
+ if (args[0] === undefined)
77
+ throw new HarnessError('Usage: harnesspm why <ID>');
78
+ return why(cwd, args[0], out);
79
+ case 'add':
80
+ case 'remove': {
81
+ unknown(command === 'add' ? ['--local', '--no-hook'] : ['--local']);
82
+ if (args[0] === undefined)
83
+ throw new HarnessError(`Usage: harnesspm ${command} <source>${command === 'add' ? ' [--local] [--no-hook]' : ' [--local]'}`);
84
+ if (command === 'add')
85
+ return add(cwd, args[0], out, { local: flags.has('--local'), hook: !flags.has('--no-hook') });
86
+ return remove(cwd, args[0], out, { local: flags.has('--local') });
87
+ }
88
+ case 'update':
89
+ unknown([]);
90
+ return update(cwd, out);
91
+ case 'new': {
92
+ const flagArgs = rest.filter((a, i) => a === '--to' || rest[i - 1] === '--to' || a === '--entry');
93
+ const positional = rest.filter((a) => !flagArgs.includes(a) && !a.startsWith('--'));
94
+ const toIndex = rest.indexOf('--to');
95
+ const to = toIndex >= 0 ? rest[toIndex + 1] : undefined;
96
+ for (const a of rest)
97
+ if (a.startsWith('--') && a !== '--to' && a !== '--entry')
98
+ throw new HarnessError(`Unknown option ${a} for new`);
99
+ if (positional.length !== 2)
100
+ throw new HarnessError('Usage: harnesspm new <kind> <name> [--to <plugin>] [--entry], or harnesspm new patch <rule ID>');
101
+ return newUnit(cwd, positional[0], positional[1], { ...(to !== undefined ? { to } : {}), entry: rest.includes('--entry') }, out);
102
+ }
103
+ case 'fetch':
104
+ unknown([]);
105
+ return fetch(cwd, out);
106
+ case 'approve':
107
+ unknown([]);
108
+ return approve(cwd, args[0], out);
109
+ case 'check':
110
+ unknown([]);
111
+ return check(args[0] ?? cwd, out);
112
+ case '--version':
113
+ case '-v': {
114
+ const pkg = readJson(new URL('../package.json', import.meta.url).pathname);
115
+ console.log(pkg.version);
116
+ return 0;
117
+ }
118
+ case undefined:
119
+ case 'help':
120
+ case '--help':
121
+ case '-h':
122
+ console.log(HELP);
123
+ return 0;
124
+ default:
125
+ throw new HarnessError(`Unknown command "${command}". Run \`harnesspm help\`.`);
126
+ }
127
+ }
128
+ try {
129
+ process.exitCode = main(process.argv.slice(2));
130
+ }
131
+ catch (error) {
132
+ if (error instanceof HarnessError) {
133
+ console.error(`harnesspm: ${error.message}`);
134
+ process.exitCode = error instanceof Refused ? 2 : 1;
135
+ }
136
+ else {
137
+ throw error;
138
+ }
139
+ }