@builtbyberry/prymer-cli 0.2.0

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.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # `prymer` — Prymer executing-hook helper
2
+
3
+ A small, dependency-free CLI for the **executing-hook power mode**. The web
4
+ connectors install _instructional_ hooks that nudge the model to call the Prymer
5
+ MCP tools. This CLI instead runs the ritual itself, so the load step happens
6
+ deterministically — even when the model forgets.
7
+
8
+ It is built on Node's standard library only (no npm dependencies).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @builtbyberry/prymer-cli
14
+ ```
15
+
16
+ ## Authenticate
17
+
18
+ 1. In Prymer, open **Settings → Connected tools** and create a **hook credential**
19
+ for the channel you want this machine to use. The token (`swhk_…`) is shown
20
+ once.
21
+ 2. Store it:
22
+
23
+ ```bash
24
+ prymer login --url https://your-prymer-host --token swhk_xxx
25
+ ```
26
+
27
+ The token reaches exactly one channel and only the load + checkpoint verbs, so a
28
+ leak is contained to that channel — never your whole account. On macOS it is kept
29
+ in the login keychain; elsewhere in a `~/.prymer/*.json` file with `0600`
30
+ permissions. Use `--profile <name>` to keep tokens for several channels side by
31
+ side.
32
+
33
+ ## Use it in hooks
34
+
35
+ ```bash
36
+ prymer install-hooks --client codex # writes .codex/hooks.json
37
+ prymer install-hooks --client claude # writes .claude/settings.json
38
+ ```
39
+
40
+ Both write a `SessionStart` hook that runs `prymer inject`. Codex and Claude Code
41
+ both feed `SessionStart` stdout to the model as additional context, so the
42
+ channel's context document loads automatically at the start of every session.
43
+
44
+ ## Commands
45
+
46
+ | Command | What it does |
47
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | `prymer login` | Store a hook credential (verifies it before saving). |
49
+ | `prymer logout` | Remove a stored credential. |
50
+ | `prymer whoami` | Show the stored host + a masked token. |
51
+ | `prymer inject` | Print the channel's context document (for `SessionStart`). Fails quiet so it never breaks a session; use `--strict` to surface errors. |
52
+ | `prymer checkpoint --title <t> --summary <s>` | Append a checkpoint. Idempotent per session + state; only shares when the channel's capture mode is **Auto**. |
53
+ | `prymer install-hooks --client <codex\|claude>` | Write the executing `SessionStart` hook for a client. |
54
+
55
+ ### Checkpointing
56
+
57
+ `inject` is fully deterministic — no model needed. A checkpoint, by contrast,
58
+ needs a summary worth keeping, so it's a step the agent (or you) takes when there
59
+ is something to record rather than a blind Stop hook:
60
+
61
+ ```bash
62
+ prymer checkpoint --session "$SESSION_ID" \
63
+ --title "Finished the billing fix" \
64
+ --summary "Switched to Stripe; tests green."
65
+ ```
66
+
67
+ The idempotency key is derived from the session id + title + summary, so re-running
68
+ the same checkpoint for the same session is a no-op rather than a duplicate.
69
+
70
+ ## Test
71
+
72
+ ```bash
73
+ cd cli/prymer && npm test
74
+ ```
package/bin/prymer.js ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdirSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { parseArgs } from '../lib/args.js';
7
+ import { deriveIdempotencyKey } from '../lib/checkpoint.js';
8
+ import { hookFiles, SUPPORTED_CLIENTS } from '../lib/hooks.js';
9
+ import { HttpError, request } from '../lib/http.js';
10
+ import {
11
+ deleteCredentials,
12
+ loadCredentials,
13
+ saveCredentials,
14
+ } from '../lib/store.js';
15
+
16
+ const USAGE = `prymer — Prymer executing-hook helper
17
+
18
+ Usage:
19
+ prymer login [--url <base-url>] [--token <hook-token>] [--profile <name>]
20
+ prymer logout [--profile <name>]
21
+ prymer whoami [--profile <name>]
22
+ prymer inject [--profile <name>] [--strict]
23
+ prymer checkpoint --title <t> --summary <s> [--content <c>] [--session <id>] [--key <k>] [--profile <name>]
24
+ prymer install-hooks --client <codex|claude> [--dir <path>]
25
+
26
+ Create a hook token under Settings → Connected tools in Prymer, then run
27
+ \`prymer login\`. Each token reaches one channel and only the load + checkpoint
28
+ verbs. Profiles let you keep tokens for several channels side by side (default:
29
+ "default").`;
30
+
31
+ /**
32
+ * @param {string} baseUrl
33
+ * @param {string} path
34
+ * @returns {string}
35
+ */
36
+ function endpoint(baseUrl, path) {
37
+ return `${baseUrl.replace(/\/+$/, '')}${path}`;
38
+ }
39
+
40
+ /**
41
+ * @param {string} question
42
+ * @returns {Promise<string>}
43
+ */
44
+ async function prompt(question) {
45
+ const rl = createInterface({
46
+ input: process.stdin,
47
+ output: process.stderr,
48
+ });
49
+ try {
50
+ return (await rl.question(question)).trim();
51
+ } finally {
52
+ rl.close();
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {Record<string, string|boolean>} flags
58
+ * @returns {string}
59
+ */
60
+ function profileOf(flags) {
61
+ return typeof flags.profile === 'string' ? flags.profile : 'default';
62
+ }
63
+
64
+ /**
65
+ * @param {string} value
66
+ * @returns {string}
67
+ */
68
+ function mask(value) {
69
+ return value.length <= 8
70
+ ? '••••'
71
+ : `${value.slice(0, 6)}…${value.slice(-2)}`;
72
+ }
73
+
74
+ async function login(flags) {
75
+ const profile = profileOf(flags);
76
+
77
+ const url =
78
+ typeof flags.url === 'string'
79
+ ? flags.url
80
+ : await prompt('Prymer URL (e.g. https://prymer.example.com): ');
81
+ const token =
82
+ typeof flags.token === 'string'
83
+ ? flags.token
84
+ : await prompt('Hook token (swhk_…): ');
85
+
86
+ if (!url || !token) {
87
+ process.stderr.write('A URL and a token are both required.\n');
88
+ process.exitCode = 1;
89
+
90
+ return;
91
+ }
92
+
93
+ // Verify the credential before storing it, so a typo fails here, not later
94
+ // inside a hook where the error is invisible.
95
+ try {
96
+ await request('GET', endpoint(url, '/api/hooks/inject'), { token });
97
+ } catch (error) {
98
+ if (error instanceof HttpError && error.status === 401) {
99
+ process.stderr.write(
100
+ 'That token was rejected. Check it and try again.\n',
101
+ );
102
+ } else {
103
+ process.stderr.write(`Could not reach Prymer: ${error.message}\n`);
104
+ }
105
+ process.exitCode = 1;
106
+
107
+ return;
108
+ }
109
+
110
+ saveCredentials(profile, { base_url: url, token });
111
+ process.stderr.write(`Saved credentials for profile "${profile}".\n`);
112
+ }
113
+
114
+ function logout(flags) {
115
+ const profile = profileOf(flags);
116
+ deleteCredentials(profile);
117
+ process.stderr.write(`Removed credentials for profile "${profile}".\n`);
118
+ }
119
+
120
+ function whoami(flags) {
121
+ const profile = profileOf(flags);
122
+ const creds = loadCredentials(profile);
123
+
124
+ if (!creds) {
125
+ process.stderr.write(
126
+ `No credentials for profile "${profile}". Run \`prymer login\`.\n`,
127
+ );
128
+ process.exitCode = 1;
129
+
130
+ return;
131
+ }
132
+
133
+ process.stdout.write(`${creds.base_url} (token ${mask(creds.token)})\n`);
134
+ }
135
+
136
+ async function inject(flags) {
137
+ const profile = profileOf(flags);
138
+ const strict = flags.strict === true;
139
+ const creds = loadCredentials(profile);
140
+
141
+ // A SessionStart hook must never break the session: with no credential (or
142
+ // any failure), inject stays quiet and exits 0 unless --strict is set.
143
+ if (!creds) {
144
+ process.stderr.write(
145
+ `prymer: no credential for profile "${profile}" — skipping context load.\n`,
146
+ );
147
+ process.exitCode = strict ? 1 : 0;
148
+
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const { json } = await request(
154
+ 'GET',
155
+ endpoint(creds.base_url, '/api/hooks/inject'),
156
+ {
157
+ token: creds.token,
158
+ },
159
+ );
160
+
161
+ const text =
162
+ json && typeof json.formatted === 'string' ? json.formatted : '';
163
+ if (text) {
164
+ process.stdout.write(`${text}\n`);
165
+ }
166
+ } catch (error) {
167
+ process.stderr.write(
168
+ `prymer: could not load context (${error.message}).\n`,
169
+ );
170
+ process.exitCode = strict ? 1 : 0;
171
+ }
172
+ }
173
+
174
+ async function checkpoint(flags) {
175
+ const profile = profileOf(flags);
176
+ const creds = loadCredentials(profile);
177
+
178
+ if (!creds) {
179
+ process.stderr.write(
180
+ `No credential for profile "${profile}". Run \`prymer login\`.\n`,
181
+ );
182
+ process.exitCode = 1;
183
+
184
+ return;
185
+ }
186
+
187
+ const title = typeof flags.title === 'string' ? flags.title : '';
188
+ const summary = typeof flags.summary === 'string' ? flags.summary : '';
189
+
190
+ if (!title || !summary) {
191
+ process.stderr.write('checkpoint needs --title and --summary.\n');
192
+ process.exitCode = 1;
193
+
194
+ return;
195
+ }
196
+
197
+ const sessionId =
198
+ typeof flags.session === 'string'
199
+ ? flags.session
200
+ : process.env.SWARM_SESSION_ID;
201
+ const idempotencyKey =
202
+ typeof flags.key === 'string'
203
+ ? flags.key
204
+ : deriveIdempotencyKey({ sessionId, title, summary });
205
+
206
+ try {
207
+ const { json } = await request(
208
+ 'POST',
209
+ endpoint(creds.base_url, '/api/hooks/checkpoint'),
210
+ {
211
+ token: creds.token,
212
+ body: {
213
+ idempotency_key: idempotencyKey,
214
+ title,
215
+ summary,
216
+ ...(typeof flags.content === 'string'
217
+ ? { content: flags.content }
218
+ : {}),
219
+ },
220
+ },
221
+ );
222
+
223
+ if (json && json.shared === false) {
224
+ process.stderr.write(
225
+ `Not shared (${json.reason ?? 'unknown'}). The channel is not set to Auto capture.\n`,
226
+ );
227
+
228
+ return;
229
+ }
230
+
231
+ const idempotent = json && json.idempotent ? ' (already recorded)' : '';
232
+ process.stderr.write(`Checkpoint shared${idempotent}.\n`);
233
+ } catch (error) {
234
+ const detail =
235
+ error instanceof HttpError
236
+ ? JSON.stringify(error.body)
237
+ : error.message;
238
+ process.stderr.write(`Checkpoint failed: ${detail}\n`);
239
+ process.exitCode = 1;
240
+ }
241
+ }
242
+
243
+ function installHooks(flags) {
244
+ const client = typeof flags.client === 'string' ? flags.client : '';
245
+
246
+ if (!SUPPORTED_CLIENTS.includes(/** @type {any} */ (client))) {
247
+ process.stderr.write(
248
+ `--client must be one of: ${SUPPORTED_CLIENTS.join(', ')}.\n`,
249
+ );
250
+ process.exitCode = 1;
251
+
252
+ return;
253
+ }
254
+
255
+ const baseDir =
256
+ typeof flags.dir === 'string' ? resolve(flags.dir) : process.cwd();
257
+ const files = hookFiles(/** @type {'codex'|'claude'} */ (client));
258
+
259
+ for (const [relativePath, contents] of Object.entries(files)) {
260
+ const target = join(baseDir, relativePath);
261
+ mkdirSync(dirname(target), { recursive: true });
262
+ writeFileSync(target, contents);
263
+ process.stderr.write(`Wrote ${relativePath}\n`);
264
+ }
265
+
266
+ process.stderr.write(
267
+ 'Executing hooks installed. Run `prymer login` if you have not yet.\n',
268
+ );
269
+ }
270
+
271
+ async function main() {
272
+ const { command, flags } = parseArgs(process.argv.slice(2));
273
+
274
+ switch (command) {
275
+ case 'login':
276
+ return login(flags);
277
+ case 'logout':
278
+ return logout(flags);
279
+ case 'whoami':
280
+ return whoami(flags);
281
+ case 'inject':
282
+ return inject(flags);
283
+ case 'checkpoint':
284
+ return checkpoint(flags);
285
+ case 'install-hooks':
286
+ return installHooks(flags);
287
+ case undefined:
288
+ case 'help':
289
+ case '--help':
290
+ case '-h':
291
+ process.stdout.write(`${USAGE}\n`);
292
+
293
+ return;
294
+ default:
295
+ process.stderr.write(`Unknown command: ${command}\n\n${USAGE}\n`);
296
+ process.exitCode = 1;
297
+ }
298
+ }
299
+
300
+ main().catch((error) => {
301
+ process.stderr.write(`${error.message}\n`);
302
+ process.exitCode = 1;
303
+ });
package/lib/args.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A tiny, dependency-free argv parser. `prymer <command> [--flag value] [--bool]
3
+ * [positional]`. A flag with no following value (or followed by another `--flag`)
4
+ * is treated as a boolean `true`.
5
+ *
6
+ * @param {string[]} argv The args after the node binary + script (i.e. process.argv.slice(2)).
7
+ * @returns {{ command: string|undefined, flags: Record<string, string|boolean>, positionals: string[] }}
8
+ */
9
+ export function parseArgs(argv) {
10
+ const [command, ...rest] = argv;
11
+ /** @type {Record<string, string|boolean>} */
12
+ const flags = {};
13
+ const positionals = [];
14
+
15
+ for (let i = 0; i < rest.length; i++) {
16
+ const arg = rest[i];
17
+
18
+ if (arg.startsWith('--')) {
19
+ const key = arg.slice(2);
20
+ const next = rest[i + 1];
21
+
22
+ if (next === undefined || next.startsWith('--')) {
23
+ flags[key] = true;
24
+ } else {
25
+ flags[key] = next;
26
+ i++;
27
+ }
28
+ } else {
29
+ positionals.push(arg);
30
+ }
31
+ }
32
+
33
+ return { command, flags, positionals };
34
+ }
Binary file
package/lib/hooks.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Executing-hook templates. Unlike the *instructional* hooks the web connectors
3
+ * ship (which only print a nudge for the model to call an MCP tool), these run
4
+ * the `prymer` CLI directly, so the load step happens deterministically — even if
5
+ * the model forgets the ritual.
6
+ *
7
+ * SessionStart runs `prymer inject`, which prints the channel's context document
8
+ * to stdout; both Codex and Claude Code feed SessionStart stdout to the model as
9
+ * additional context. Checkpointing stays a step the agent takes when it has a
10
+ * summary to write (a blind Stop hook has nothing meaningful to checkpoint), so
11
+ * the templates document `prymer checkpoint` rather than firing it blindly.
12
+ */
13
+
14
+ /**
15
+ * @param {string} json
16
+ * @returns {string}
17
+ */
18
+ function pretty(json) {
19
+ return `${JSON.stringify(json, null, 2)}\n`;
20
+ }
21
+
22
+ /**
23
+ * The files to write for a given client, keyed by project-relative path.
24
+ *
25
+ * @param {'codex'|'claude'} client
26
+ * @returns {Record<string, string>}
27
+ */
28
+ export function hookFiles(client) {
29
+ if (client === 'codex') {
30
+ return {
31
+ '.codex/hooks.json': pretty({
32
+ hooks: {
33
+ SessionStart: [
34
+ {
35
+ matcher: 'startup|resume',
36
+ hooks: [
37
+ {
38
+ type: 'command',
39
+ command: 'prymer inject',
40
+ statusMessage: 'Loading Prymer context',
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ },
46
+ }),
47
+ };
48
+ }
49
+
50
+ if (client === 'claude') {
51
+ return {
52
+ '.claude/settings.json': pretty({
53
+ hooks: {
54
+ SessionStart: [
55
+ {
56
+ hooks: [
57
+ {
58
+ type: 'command',
59
+ command: 'prymer inject',
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ },
65
+ }),
66
+ };
67
+ }
68
+
69
+ throw new Error(`Unknown client: ${client} (expected codex or claude)`);
70
+ }
71
+
72
+ /** @type {ReadonlyArray<'codex'|'claude'>} */
73
+ export const SUPPORTED_CLIENTS = ['codex', 'claude'];
package/lib/http.js ADDED
@@ -0,0 +1,96 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+
4
+ /**
5
+ * An HTTP error carrying the parsed body, so callers can read the server's
6
+ * `reason` (e.g. `capture_mode_not_auto`) or message.
7
+ */
8
+ export class HttpError extends Error {
9
+ /**
10
+ * @param {number} status
11
+ * @param {any} body
12
+ */
13
+ constructor(status, body) {
14
+ super(`HTTP ${status}`);
15
+ this.name = 'HttpError';
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * @param {string} data
23
+ * @returns {any}
24
+ */
25
+ function safeJson(data) {
26
+ try {
27
+ return JSON.parse(data);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Make a JSON request with no third-party dependencies.
35
+ *
36
+ * @param {'GET'|'POST'} method
37
+ * @param {string} urlString
38
+ * @param {{ token?: string, body?: object }} [options]
39
+ * @returns {Promise<{ status: number, json: any }>}
40
+ */
41
+ export function request(method, urlString, { token, body } = {}) {
42
+ return new Promise((resolve, reject) => {
43
+ let url;
44
+ try {
45
+ url = new URL(urlString);
46
+ } catch {
47
+ reject(new Error(`Invalid URL: ${urlString}`));
48
+
49
+ return;
50
+ }
51
+
52
+ const lib = url.protocol === 'http:' ? http : https;
53
+ const payload = body ? JSON.stringify(body) : null;
54
+
55
+ const req = lib.request(
56
+ url,
57
+ {
58
+ method,
59
+ headers: {
60
+ Accept: 'application/json',
61
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
62
+ ...(payload
63
+ ? {
64
+ 'Content-Type': 'application/json',
65
+ 'Content-Length': Buffer.byteLength(payload),
66
+ }
67
+ : {}),
68
+ },
69
+ },
70
+ (res) => {
71
+ let data = '';
72
+ res.on('data', (chunk) => {
73
+ data += chunk;
74
+ });
75
+ res.on('end', () => {
76
+ const json = data ? safeJson(data) : null;
77
+ const status = res.statusCode ?? 0;
78
+
79
+ if (status >= 200 && status < 300) {
80
+ resolve({ status, json });
81
+ } else {
82
+ reject(new HttpError(status, json ?? data));
83
+ }
84
+ });
85
+ },
86
+ );
87
+
88
+ req.on('error', reject);
89
+
90
+ if (payload) {
91
+ req.write(payload);
92
+ }
93
+
94
+ req.end();
95
+ });
96
+ }
package/lib/store.js ADDED
@@ -0,0 +1,124 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { homedir, platform } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ /**
14
+ * Credential storage for the executing-hook power mode.
15
+ *
16
+ * A hook credential is a high-value secret (one channel, append-capable), so the
17
+ * gate's blast-radius decision is to keep it out of plaintext dotfiles where we
18
+ * can. On macOS we use the login keychain via the `security` CLI; everywhere else
19
+ * we fall back to a `~/.prymer` file with `0600` perms. Set `SWARM_NO_KEYCHAIN=1`
20
+ * to force the file path (used by the test suite, and by anyone who prefers it).
21
+ */
22
+
23
+ const SERVICE = 'prymer';
24
+ const FILE_DIR = join(homedir(), '.prymer');
25
+
26
+ /**
27
+ * @param {string} profile
28
+ * @returns {string}
29
+ */
30
+ function filePath(profile) {
31
+ return join(FILE_DIR, `credentials-${profile}.json`);
32
+ }
33
+
34
+ /**
35
+ * @returns {boolean}
36
+ */
37
+ function useKeychain() {
38
+ return platform() === 'darwin' && process.env.SWARM_NO_KEYCHAIN !== '1';
39
+ }
40
+
41
+ /**
42
+ * @param {string} profile
43
+ * @param {{ base_url: string, token: string }} data
44
+ */
45
+ export function saveCredentials(profile, data) {
46
+ const json = JSON.stringify(data);
47
+
48
+ if (useKeychain()) {
49
+ // -U updates the entry if it already exists rather than erroring.
50
+ execFileSync('security', [
51
+ 'add-generic-password',
52
+ '-U',
53
+ '-s',
54
+ SERVICE,
55
+ '-a',
56
+ profile,
57
+ '-w',
58
+ json,
59
+ ]);
60
+
61
+ return;
62
+ }
63
+
64
+ mkdirSync(FILE_DIR, { recursive: true });
65
+ writeFileSync(filePath(profile), json, { mode: 0o600 });
66
+ chmodSync(filePath(profile), 0o600);
67
+ }
68
+
69
+ /**
70
+ * @param {string} profile
71
+ * @returns {{ base_url: string, token: string } | null}
72
+ */
73
+ export function loadCredentials(profile) {
74
+ if (useKeychain()) {
75
+ try {
76
+ const out = execFileSync(
77
+ 'security',
78
+ ['find-generic-password', '-s', SERVICE, '-a', profile, '-w'],
79
+ { encoding: 'utf8' },
80
+ );
81
+
82
+ return JSON.parse(out.trim());
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ if (!existsSync(filePath(profile))) {
89
+ return null;
90
+ }
91
+
92
+ try {
93
+ return JSON.parse(readFileSync(filePath(profile), 'utf8'));
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} profile
101
+ */
102
+ export function deleteCredentials(profile) {
103
+ if (useKeychain()) {
104
+ try {
105
+ execFileSync('security', [
106
+ 'delete-generic-password',
107
+ '-s',
108
+ SERVICE,
109
+ '-a',
110
+ profile,
111
+ ]);
112
+ } catch {
113
+ // Nothing stored — deleting is a no-op.
114
+ }
115
+
116
+ return;
117
+ }
118
+
119
+ try {
120
+ rmSync(filePath(profile));
121
+ } catch {
122
+ // Nothing stored — deleting is a no-op.
123
+ }
124
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@builtbyberry/prymer-cli",
3
+ "version": "0.2.0",
4
+ "description": "The Prymer executing-hook helper: load context at session start and checkpoint at session stop, deterministically, from a scoped channel credential.",
5
+ "type": "module",
6
+ "bin": {
7
+ "prymer": "bin/prymer.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test",
19
+ "prepublishOnly": "node --test"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/builtbyberry/swarmcloud.git",
24
+ "directory": "cli/prymer"
25
+ },
26
+ "homepage": "https://github.com/builtbyberry/swarmcloud/tree/main/cli/prymer#readme",
27
+ "bugs": "https://github.com/builtbyberry/swarmcloud/issues",
28
+ "keywords": [
29
+ "prymer",
30
+ "mcp",
31
+ "context",
32
+ "hooks",
33
+ "cli"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "license": "MIT"
39
+ }