@heart-of-gold/toolkit 0.1.36 → 0.1.38

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.
@@ -15,19 +15,19 @@
15
15
  "name": "deep-thought",
16
16
  "source": "./plugins/deep-thought",
17
17
  "description": "The Answer Computer — reasoning tools for brainstorming, planning, and deep thinking",
18
- "version": "0.2.6"
18
+ "version": "0.2.8"
19
19
  },
20
20
  {
21
21
  "name": "marvin",
22
22
  "source": "./plugins/marvin",
23
23
  "description": "The Paranoid Android — quality tools for code review, knowledge compounding, and work execution",
24
- "version": "0.3.6"
24
+ "version": "0.3.8"
25
25
  },
26
26
  {
27
27
  "name": "babel-fish",
28
28
  "source": "./plugins/babel-fish",
29
29
  "description": "Universal Translator — media generation tools for audio, image, and video content",
30
- "version": "0.2.4"
30
+ "version": "0.2.6"
31
31
  },
32
32
  {
33
33
  "name": "quellis",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heart-of-gold/toolkit",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "description": "Cross-platform installer for Heart of Gold skills — works with Codex, OpenCode, Pi, Claude Code, and more",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babel-fish",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Universal Translator — media generation tools for audio, image, video, and visualization",
5
5
  "author": {
6
6
  "name": "ondrej-svec",
@@ -120,6 +120,26 @@ Apply these defaults unless the user asks for something else:
120
120
  See also:
121
121
  - `docs/architecture/visualize-design-rules.md`
122
122
 
123
+ ## Writing Tone
124
+
125
+ Write artifact copy like a strong product/design-systems editor:
126
+ - concise
127
+ - confident
128
+ - specific
129
+ - implementation-aware
130
+ - high signal-to-noise
131
+
132
+ Prefer lines like:
133
+ - `One code change, bounded migration risk`
134
+ - `Three priority tiers, one direction`
135
+ - `Deferred — tracked, not scheduled`
136
+
137
+ Avoid:
138
+ - fluffy marketing copy
139
+ - generic AI hype
140
+ - long throat-clearing intros
141
+ - repeating the source document verbatim
142
+
123
143
  ## Rules: Do
124
144
 
125
145
  - Decide the communication goal before choosing the renderer.
@@ -130,6 +150,7 @@ See also:
130
150
  - Use `roadmap` or richer execution-oriented views for plans when that improves understanding.
131
151
  - Use `architecture` views for system/design-heavy documents.
132
152
  - Keep raw source detail available, but secondary.
153
+ - Make section labels do real editorial work: `Scope at a glance`, `Why this shape`, `What could go wrong`, `Deferred — tracked, not scheduled`.
133
154
  - Briefly explain why you chose the visualization mode when sharing the result.
134
155
 
135
156
  ## Rules: Don't
@@ -141,6 +162,8 @@ See also:
141
162
  - Do not use flashy gradients, glass, shadows, or color noise unless they clearly improve hierarchy.
142
163
  - Do not silently guess when the visualization choice is materially ambiguous.
143
164
  - Do not create multiple competing artifacts unless the user explicitly asks for comparison.
165
+ - Do not preserve source heading order if a clearer narrative order exists.
166
+ - Do not expose every extracted detail at the same visual weight.
144
167
 
145
168
  ## Expected Behavior
146
169
 
@@ -233,14 +256,22 @@ fi
233
256
 
234
257
  When quality matters more than speed, start from the shared authored-artifact template, write the HTML directly, then publish it.
235
258
 
259
+ **Cross-platform preferred path:** use the Node helpers first. They work better across macOS, Linux, Windows, Codex, Claude Code, and Pi than shell-specific one-liners.
260
+
236
261
  ```bash
237
- HTML_OUT="$(bash "$(dirname "$SCRIPT")/new-authored-artifact.sh")"
238
- # edit the generated template at $HTML_OUT
239
- bash "$(dirname "$SCRIPT")/publish-authored-html.sh" --url-only "$HTML_OUT"
262
+ # 1. Print a fresh temp HTML path based on the shared template
263
+ node /absolute/path/to/new-authored-artifact.js
264
+
265
+ # 2. After editing the file, publish it
266
+ node /absolute/path/to/publish-authored-html.js --url-only /tmp/path/from-step-1/artifact.html
240
267
  ```
241
268
 
269
+ **Shell fallback:** if Node helper usage is awkward in a specific harness, the `.sh` helpers are still available.
270
+
242
271
  Template and helper files:
243
272
  - `scripts/agent-artifact-template.html`
273
+ - `scripts/new-authored-artifact.js`
274
+ - `scripts/publish-authored-html.js`
244
275
  - `scripts/new-authored-artifact.sh`
245
276
  - `scripts/publish-authored-html.sh`
246
277
 
@@ -266,18 +297,21 @@ node "$(dirname "$SCRIPT")/render-mindmap/index.js" --html /tmp/map.html path/to
266
297
 
267
298
  Use this when the artifact needs stronger design judgment than the fallback renderer can provide.
268
299
 
269
- 1. Create the scaffold:
300
+ 1. Create the scaffold with a standalone cross-platform command:
270
301
  ```bash
271
- HTML_OUT="$(bash "$(dirname "$SCRIPT")/new-authored-artifact.sh")"
302
+ node /absolute/path/to/new-authored-artifact.js
272
303
  ```
273
- 2. Author the HTML artifact directly into that file.
274
- 3. Publish it:
304
+ 2. Read the printed temp path from stdout.
305
+ 3. Author the HTML artifact directly into that file.
306
+ 4. Publish it with a standalone cross-platform command:
275
307
  ```bash
276
- bash "$(dirname "$SCRIPT")/publish-authored-html.sh" --url-only "$HTML_OUT"
308
+ node /absolute/path/to/publish-authored-html.js --url-only /tmp/your-artifact.html
277
309
  ```
278
- 4. Read the returned URL from stdout.
279
- 5. Return that URL to the user as the primary result.
280
- 6. Briefly explain what was published and why this visual form was chosen.
310
+ 5. Read the returned URL from stdout.
311
+ 6. Return that URL to the user as the primary result.
312
+ 7. Briefly explain what was published and why this visual form was chosen.
313
+
314
+ Shell helpers remain available as a fallback if a harness prefers `bash`.
281
315
 
282
316
  ### Fallback share flow: render then publish
283
317
 
@@ -328,16 +362,27 @@ When authoring HTML directly, follow this sequence:
328
362
 
329
363
  1. Read the source and decide the artifact family.
330
364
  2. Decide the audience and the first question the page should answer.
331
- 3. Create the scaffold with `new-authored-artifact.sh`.
332
- 4. Replace the template content with a real designed artifact.
333
- 5. Keep source detail secondary.
334
- 6. Publish with `publish-authored-html.sh`.
365
+ 3. Create the scaffold with `new-authored-artifact.js` or `new-authored-artifact.sh`.
366
+ 4. Draft the page in three layers:
367
+ - **summary layer** what matters first
368
+ - **decision / execution layer** — the main story
369
+ - **source layer** — appendix, evidence, or raw detail
370
+ 5. Replace the template content with a real designed artifact.
371
+ 6. Keep source detail secondary.
372
+ 7. Publish with `publish-authored-html.js` or `publish-authored-html.sh`.
373
+
374
+ Harness note:
375
+ - prefer separate commands over complex one-liners
376
+ - prefer the Node helpers for cross-platform behavior
377
+ - avoid assuming `mktemp /tmp/name-XXXXXX.html` works on every shell; use the provided helper instead
378
+ - if a harness only supports shell comfortably, use the `.sh` helpers as fallback
335
379
 
336
380
  For plans specifically:
337
381
  - do not dump the full task prose into the primary lanes
338
382
  - summarize workstreams into short cards or task tiles
339
383
  - show dependencies, risks, and acceptance separately
340
384
  - keep raw markdown only in an appendix or disclosure block
385
+ - compress long task descriptions into one-sentence operational summaries first
341
386
 
342
387
  ## Required Output Structure
343
388
 
@@ -369,6 +414,53 @@ For substantial HTML artifacts, prefer this structure:
369
414
  - what happens next
370
415
  - supporting source detail below
371
416
 
417
+ ## Expected Behavior by Artifact Family
418
+
419
+ ### Plan / roadmap artifacts
420
+ Must usually include:
421
+ - a top-line mission
422
+ - scope or execution stats
423
+ - grouped workstreams or phases
424
+ - key decisions / rationale
425
+ - explicit risks / acceptance posture
426
+ - deferred or out-of-scope work if relevant
427
+
428
+ ### Architecture artifacts
429
+ Must usually include:
430
+ - system framing
431
+ - major components / boundaries
432
+ - integration or dependency context
433
+ - key tradeoffs / decisions
434
+ - assumptions / risks
435
+
436
+ ### Explainers
437
+ Must usually include:
438
+ - what this is
439
+ - why it matters
440
+ - the recommendation or conclusion
441
+ - supporting evidence
442
+ - what happens next
443
+
444
+ ## Recommended Authoring Moves
445
+
446
+ When the artifact still feels too markdown-like, do one or more of these:
447
+ - replace raw section names with editorial section labels
448
+ - compress paragraphs into 1–3 sentence summary cards
449
+ - convert repeated prose into chips, bullets, metrics, or compact lists
450
+ - surface one key takeaway per section before the detail
451
+ - split `what / why / risk / next` into separate visual units
452
+ - move citations, raw notes, and source text into a secondary appendix
453
+
454
+ ## Guideline Authoring Workflow
455
+
456
+ 1. Restate the artifact purpose in one sentence.
457
+ 2. Decide the audience and primary question.
458
+ 3. Choose the artifact family and page shape.
459
+ 4. Extract only what is needed for the summary layer.
460
+ 5. Build the main body around grouped visual units, not source sections.
461
+ 6. Add source appendix or evidence only after the main page works.
462
+ 7. Run the quality gates before publishing.
463
+
372
464
  ## Quality Gates
373
465
 
374
466
  Before returning a shared HTML result, check mentally:
@@ -380,9 +472,37 @@ Before returning a shared HTML result, check mentally:
380
472
  - Is the chosen mode actually appropriate for the content?
381
473
  - If this is a plan, does it foreground execution rather than document order?
382
474
  - If this is a brainstorm, is it actually branch-shaped enough for a mind map?
475
+ - Do the section titles help scanning?
476
+ - Is secondary detail actually secondary?
383
477
 
384
478
  If the answer to several of these is no, reconsider the mode or ask the user.
385
479
 
480
+ ## Pattern Examples
481
+
482
+ ### Good plan framing
483
+ - `Scope at a glance`
484
+ - `Three priority tiers, one direction`
485
+ - `What could go wrong`
486
+ - `Deferred — tracked, not scheduled`
487
+
488
+ ### Good risk cards
489
+ - short risk title
490
+ - severity or posture badge
491
+ - one-sentence mitigation
492
+ - accepted vs mitigated distinction when relevant
493
+
494
+ ### Good summary stats
495
+ - workstreams
496
+ - tasks
497
+ - code edits
498
+ - new files
499
+ - doctrine docs touched
500
+
501
+ ### Good appendix behavior
502
+ - source plan path
503
+ - cited references
504
+ - raw plan or evidence hidden behind disclosure
505
+
386
506
  ## Input Formats
387
507
 
388
508
  ### Markdown (primary)
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { copyFileSync, mkdirSync, mkdtempSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const templatePath = join(__dirname, 'agent-artifact-template.html');
10
+ const providedPath = process.argv[2] ? resolve(process.argv[2]) : null;
11
+
12
+ const outPath = (() => {
13
+ if (providedPath) return providedPath;
14
+ const tempDir = mkdtempSync(join(tmpdir(), 'hog-visualize-artifact-'));
15
+ return join(tempDir, 'artifact.html');
16
+ })();
17
+
18
+ mkdirSync(dirname(outPath), { recursive: true });
19
+ copyFileSync(templatePath, outPath);
20
+ process.stdout.write(`${outPath}\n`);
@@ -6,7 +6,8 @@ TEMPLATE="$SCRIPT_DIR/agent-artifact-template.html"
6
6
  OUT_PATH="${1:-}"
7
7
 
8
8
  if [[ -z "$OUT_PATH" ]]; then
9
- OUT_PATH="$(mktemp /tmp/hog-visualize-artifact-XXXXXX.html)"
9
+ TEMP_BASE="$(mktemp /tmp/hog-visualize-artifact-XXXXXX)"
10
+ OUT_PATH="${TEMP_BASE}.html"
10
11
  fi
11
12
 
12
13
  cp "$TEMPLATE" "$OUT_PATH"
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { basename, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+
7
+ function fail(code, message, status = 1) {
8
+ process.stdout.write(`${JSON.stringify({ ok: false, error: { code, message } })}\n`);
9
+ process.exit(status);
10
+ }
11
+
12
+ function parseArgs(argv) {
13
+ const opts = {
14
+ htmlPath: null,
15
+ configPath: resolve(homedir(), '.agent-share', 'config.json'),
16
+ slug: '',
17
+ title: '',
18
+ alias: '',
19
+ urlOnly: false,
20
+ };
21
+
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const arg = argv[i];
24
+ if (arg === '--config' && i + 1 < argv.length) opts.configPath = resolve(argv[++i]);
25
+ else if (arg === '--slug' && i + 1 < argv.length) opts.slug = argv[++i];
26
+ else if (arg === '--title' && i + 1 < argv.length) opts.title = argv[++i];
27
+ else if (arg === '--alias' && i + 1 < argv.length) opts.alias = argv[++i];
28
+ else if (arg === '--url-only') opts.urlOnly = true;
29
+ else if (arg === '--help') {
30
+ process.stdout.write('Usage: publish-authored-html.js <html-file> [--config PATH] [--slug STEM] [--title TITLE] [--alias ALIAS] [--url-only]\n');
31
+ process.exit(0);
32
+ } else if (!arg.startsWith('-') && !opts.htmlPath) {
33
+ opts.htmlPath = resolve(arg);
34
+ } else {
35
+ fail('INVALID_ARGS', `Unknown or misplaced argument: ${arg}`);
36
+ }
37
+ }
38
+
39
+ return opts;
40
+ }
41
+
42
+ async function main() {
43
+ const opts = parseArgs(process.argv.slice(2));
44
+
45
+ if (!opts.htmlPath) fail('MISSING_PATH', 'HTML file path is required.');
46
+ if (!existsSync(opts.configPath)) fail('MISSING_CONFIG', 'Share server config not found. Run share-server-setup first.');
47
+ if (!existsSync(opts.htmlPath)) fail('MISSING_PATH', `HTML file does not exist: ${opts.htmlPath}`);
48
+ if (!opts.htmlPath.toLowerCase().endsWith('.html')) fail('UNSUPPORTED_ARTIFACT', 'Only .html files are supported for authored artifact publishing.');
49
+
50
+ const cfg = JSON.parse(readFileSync(opts.configPath, 'utf8'));
51
+ const apiUrl = String(cfg?.server?.apiUrl || '').replace(/\/$/, '');
52
+ if (!apiUrl) fail('INVALID_CONFIG', 'share server apiUrl missing from config.');
53
+
54
+ const htmlBuffer = readFileSync(opts.htmlPath);
55
+ const form = new FormData();
56
+ form.append('artifact', new Blob([htmlBuffer], { type: 'text/html' }), basename(opts.htmlPath));
57
+ form.append('artifactType', 'html-file');
58
+ if (opts.slug) form.append('slug', opts.slug);
59
+ if (opts.title) form.append('title', opts.title);
60
+ if (opts.alias) form.append('alias', opts.alias);
61
+
62
+ const response = await fetch(`${apiUrl}/publish`, { method: 'POST', body: form });
63
+ const text = await response.text();
64
+
65
+ let payload;
66
+ try {
67
+ payload = JSON.parse(text);
68
+ } catch {
69
+ fail('INVALID_RESPONSE', `Share server returned non-JSON response: ${text}`, 1);
70
+ }
71
+
72
+ if (!response.ok || payload?.ok === false) {
73
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
74
+ process.exit(1);
75
+ }
76
+
77
+ if (opts.urlOnly) {
78
+ process.stdout.write(`${payload.url || payload.viewerUrl || ''}\n`);
79
+ return;
80
+ }
81
+
82
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
83
+ }
84
+
85
+ main().catch((error) => {
86
+ fail('PUBLISH_FAILED', error instanceof Error ? error.message : String(error));
87
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deep-thought",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "The Answer Computer — reasoning tools for brainstorming, planning, architecture design, and deep thinking",
5
5
  "author": {
6
6
  "name": "ondrej-svec",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "marvin",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "The Paranoid Android — quality tools for code review, knowledge compounding, and work execution",
5
5
  "author": {
6
6
  "name": "ondrej-svec",
@@ -59,6 +59,16 @@ bash scripts/enable-viewer.sh --public-base-url "https://<machine>.<tailnet>.ts.
59
59
  bash scripts/disable-viewer.sh
60
60
  ```
61
61
 
62
+ ### Delete a published artifact by slug
63
+ ```bash
64
+ heart-of-gold share-server delete <slug>
65
+ ```
66
+
67
+ ### Delete only an alias pointer
68
+ ```bash
69
+ heart-of-gold share-server delete --alias <alias> --onlyAlias
70
+ ```
71
+
62
72
  ## Notes
63
73
 
64
74
  - In v1, lifecycle control assumes the reference server was installed as a macOS LaunchAgent.
@@ -73,6 +83,8 @@ bash scripts/disable-viewer.sh
73
83
  - `scripts/restart.sh` — restart the LaunchAgent cleanly
74
84
  - `scripts/enable-viewer.sh` — enable private Tailscale Serve exposure for the viewer listener
75
85
  - `scripts/disable-viewer.sh` — turn off private Tailscale Serve exposure
86
+ - `heart-of-gold share-server delete <slug>` — remove a published artifact, its aliases, and its metadata entries
87
+ - `heart-of-gold share-server delete --alias <alias> --onlyAlias` — remove only an alias pointer
76
88
 
77
89
  ## References
78
90
 
@@ -59,6 +59,18 @@ bun share-server/src/index.ts init
59
59
  bun share-server/src/index.ts health
60
60
  ```
61
61
 
62
+ ### Delete a published share
63
+
64
+ ```bash
65
+ heart-of-gold share-server delete <slug>
66
+ ```
67
+
68
+ ### Delete only an alias pointer
69
+
70
+ ```bash
71
+ heart-of-gold share-server delete --alias <alias> --onlyAlias
72
+ ```
73
+
62
74
  ### Install stable local server files
63
75
 
64
76
  ```bash
@@ -93,6 +105,19 @@ curl -fsS -X POST \
93
105
  http://127.0.0.1:4815/publish
94
106
  ```
95
107
 
108
+ ## Cleanup model
109
+
110
+ Artifacts are immutable when published, but the admin surface can remove them when cleanup is needed.
111
+
112
+ Supported cleanup operations:
113
+ - delete a share by slug
114
+ - delete an alias pointer without deleting the underlying artifact
115
+
116
+ Deleting a share removes:
117
+ - the artifact directory
118
+ - any aliases pointing at that slug
119
+ - matching metadata entries in `metadata/shares.jsonl`
120
+
96
121
  ## Security model
97
122
 
98
123
  - publish/admin API binds to localhost only
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { ensureConfigAndDataDirs, loadConfig, resolveConfigPath, writeConfig } from "./config";
6
6
  import { publishArtifact } from "./publish";
7
- import { ensureStorageLayout } from "./storage";
7
+ import { deleteAlias, deleteArtifact, ensureStorageLayout, readAlias, readMetadata, removeAliasesForSlug, rewriteMetadata } from "./storage";
8
8
  import { createViewerHandler } from "./viewer";
9
9
  import type { HealthResponse } from "./types";
10
10
 
@@ -92,14 +92,7 @@ async function startServer(flags: Record<string, string | boolean>): Promise<voi
92
92
  }
93
93
 
94
94
  if (request.method === "GET" && url.pathname === "/shares") {
95
- const sharesPath = join(dataRoot, "metadata", "shares.jsonl");
96
- const text = existsSync(sharesPath) ? await Bun.file(sharesPath).text() : "";
97
- const items = text
98
- .split(/\r?\n/)
99
- .map((line) => line.trim())
100
- .filter(Boolean)
101
- .map((line) => JSON.parse(line));
102
- return Response.json({ ok: true, items });
95
+ return Response.json({ ok: true, items: readMetadata(dataRoot) });
103
96
  }
104
97
 
105
98
  if (request.method === "POST" && url.pathname === "/publish") {
@@ -127,6 +120,39 @@ async function startServer(flags: Record<string, string | boolean>): Promise<voi
127
120
  }
128
121
  }
129
122
 
123
+ if (request.method === "DELETE" && url.pathname.startsWith("/shares/")) {
124
+ const slug = decodeURIComponent(url.pathname.replace(/^\/shares\//, "")).trim();
125
+ if (!slug) {
126
+ return Response.json({ ok: false, error: { code: "MISSING_SLUG", message: "Share slug is required." } }, { status: 400 });
127
+ }
128
+
129
+ const removedArtifact = deleteArtifact(dataRoot, slug);
130
+ const removedAliases = removeAliasesForSlug(dataRoot, slug);
131
+ const existing = readMetadata(dataRoot);
132
+ const filtered = existing.filter((record) => record.slug !== slug);
133
+ const metadataRemoved = filtered.length !== existing.length;
134
+ rewriteMetadata(dataRoot, filtered);
135
+
136
+ if (!removedArtifact && !metadataRemoved && removedAliases.length === 0) {
137
+ return Response.json({ ok: false, error: { code: "NOT_FOUND", message: `Share not found: ${slug}` } }, { status: 404 });
138
+ }
139
+
140
+ return Response.json({ ok: true, slug, removedArtifact, removedAliases, metadataRemoved });
141
+ }
142
+
143
+ if (request.method === "DELETE" && url.pathname.startsWith("/aliases/")) {
144
+ const alias = decodeURIComponent(url.pathname.replace(/^\/aliases\//, "")).trim();
145
+ if (!alias) {
146
+ return Response.json({ ok: false, error: { code: "MISSING_ALIAS", message: "Alias is required." } }, { status: 400 });
147
+ }
148
+ const slug = readAlias(dataRoot, alias);
149
+ const removed = deleteAlias(dataRoot, alias);
150
+ if (!removed) {
151
+ return Response.json({ ok: false, error: { code: "NOT_FOUND", message: `Alias not found: ${alias}` } }, { status: 404 });
152
+ }
153
+ return Response.json({ ok: true, alias, slug, removed: true });
154
+ }
155
+
130
156
  return Response.json({ ok: false, error: { code: "INVALID_REQUEST", message: "Unknown route" } }, { status: 404 });
131
157
  },
132
158
  });
@@ -39,23 +39,81 @@ export function artifactPath(dataRoot: string, slug: string): string {
39
39
  return join(artifactsDir(dataRoot), slug);
40
40
  }
41
41
 
42
+ export function metadataFilePath(dataRoot: string): string {
43
+ return join(metadataDir(dataRoot), "shares.jsonl");
44
+ }
45
+
42
46
  export function writeMetadata(dataRoot: string, record: ShareRecord): void {
43
- const filePath = join(metadataDir(dataRoot), "shares.jsonl");
47
+ const filePath = metadataFilePath(dataRoot);
44
48
  writeFileSync(filePath, `${JSON.stringify(record)}\n`, { flag: "a" });
45
49
  }
46
50
 
51
+ export function readMetadata(dataRoot: string): ShareRecord[] {
52
+ const filePath = metadataFilePath(dataRoot);
53
+ if (!existsSync(filePath)) return [];
54
+ return readFileSync(filePath, "utf-8")
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .filter(Boolean)
58
+ .map((line) => JSON.parse(line) as ShareRecord);
59
+ }
60
+
61
+ export function rewriteMetadata(dataRoot: string, records: ShareRecord[]): void {
62
+ const filePath = metadataFilePath(dataRoot);
63
+ const content = records.map((record) => JSON.stringify(record)).join("\n");
64
+ writeFileSync(filePath, `${content}${content ? "\n" : ""}`, "utf-8");
65
+ }
66
+
47
67
  export function writeAlias(dataRoot: string, alias: string, slug: string): void {
48
68
  const aliasPath = join(aliasesDir(dataRoot), `${slugify(alias)}.json`);
49
69
  writeFileSync(aliasPath, `${JSON.stringify({ alias: slugify(alias), slug }, null, 2)}\n`, "utf-8");
50
70
  }
51
71
 
72
+ export function aliasPath(dataRoot: string, alias: string): string {
73
+ return join(aliasesDir(dataRoot), `${slugify(alias)}.json`);
74
+ }
75
+
52
76
  export function readAlias(dataRoot: string, alias: string): string | null {
53
- const aliasPath = join(aliasesDir(dataRoot), `${slugify(alias)}.json`);
54
- if (!existsSync(aliasPath)) return null;
55
- const parsed = JSON.parse(readFileSync(aliasPath, "utf-8")) as { slug?: string };
77
+ const filePath = aliasPath(dataRoot, alias);
78
+ if (!existsSync(filePath)) return null;
79
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as { slug?: string };
56
80
  return parsed.slug ?? null;
57
81
  }
58
82
 
83
+ export function deleteAlias(dataRoot: string, alias: string): boolean {
84
+ const filePath = aliasPath(dataRoot, alias);
85
+ if (!existsSync(filePath)) return false;
86
+ rmSync(filePath, { force: true });
87
+ return true;
88
+ }
89
+
90
+ export function removeAliasesForSlug(dataRoot: string, slug: string): string[] {
91
+ const removed: string[] = [];
92
+ const root = aliasesDir(dataRoot);
93
+ if (!existsSync(root)) return removed;
94
+ for (const entry of readdirSync(root)) {
95
+ if (!entry.endsWith('.json')) continue;
96
+ const filePath = join(root, entry);
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as { alias?: string; slug?: string };
99
+ if (parsed.slug === slug) {
100
+ rmSync(filePath, { force: true });
101
+ removed.push(parsed.alias ?? entry.replace(/\.json$/, ''));
102
+ }
103
+ } catch {
104
+ // ignore malformed alias records during cleanup
105
+ }
106
+ }
107
+ return removed;
108
+ }
109
+
110
+ export function deleteArtifact(dataRoot: string, slug: string): boolean {
111
+ const path = artifactPath(dataRoot, slug);
112
+ if (!existsSync(path)) return false;
113
+ rmSync(path, { recursive: true, force: true });
114
+ return true;
115
+ }
116
+
59
117
  export function copyDirectoryContents(sourceDir: string, destinationDir: string): void {
60
118
  mkdirSync(destinationDir, { recursive: true });
61
119
  for (const entry of readdirSync(sourceDir)) {
@@ -2,6 +2,8 @@ import { defineCommand } from "citty";
2
2
  import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { dataRootForConfig } from "../../share-server/src/config";
6
+ import { deleteAlias, deleteArtifact, readAlias, readMetadata, removeAliasesForSlug, rewriteMetadata } from "../../share-server/src/storage";
5
7
 
6
8
  function packageRoot(): string {
7
9
  let dir = dirname(new URL(import.meta.url).pathname);
@@ -86,6 +88,48 @@ export const shareServerCommand = defineCommand({
86
88
  console.log(JSON.stringify({ ok: true, serverDir: targetDir }, null, 2));
87
89
  },
88
90
  }),
91
+ delete: defineCommand({
92
+ meta: { name: "delete", description: "Delete a published share by slug, or remove an alias" },
93
+ args: {
94
+ slug: { type: "positional", required: false },
95
+ alias: { type: "string", required: false },
96
+ config: { type: "string", required: false },
97
+ onlyAlias: { type: "boolean", required: false },
98
+ },
99
+ run({ args }) {
100
+ const dataRoot = dataRootForConfig(args.config ? String(args.config) : undefined);
101
+
102
+ if (args.alias && args.onlyAlias) {
103
+ const alias = String(args.alias);
104
+ const slug = readAlias(dataRoot, alias);
105
+ const removed = deleteAlias(dataRoot, alias);
106
+ console.log(JSON.stringify(removed
107
+ ? { ok: true, alias, slug, removed: true }
108
+ : { ok: false, error: { code: "NOT_FOUND", message: `Alias not found: ${alias}` } }
109
+ ));
110
+ process.exit(removed ? 0 : 1);
111
+ }
112
+
113
+ const slug = args.slug ? String(args.slug) : undefined;
114
+ if (!slug) {
115
+ console.error("share-server delete requires either <slug> or --alias <alias> --onlyAlias");
116
+ process.exit(1);
117
+ }
118
+
119
+ const removedArtifact = deleteArtifact(dataRoot, slug);
120
+ const removedAliases = removeAliasesForSlug(dataRoot, slug);
121
+ const existing = readMetadata(dataRoot);
122
+ const filtered = existing.filter((record) => record.slug !== slug);
123
+ const metadataRemoved = filtered.length !== existing.length;
124
+ rewriteMetadata(dataRoot, filtered);
125
+ const ok = removedArtifact || metadataRemoved || removedAliases.length > 0;
126
+ console.log(JSON.stringify(ok
127
+ ? { ok: true, slug, removedArtifact, removedAliases, metadataRemoved }
128
+ : { ok: false, error: { code: "NOT_FOUND", message: `Share not found: ${slug}` } }
129
+ ));
130
+ process.exit(ok ? 0 : 1);
131
+ },
132
+ }),
89
133
  "install-launch-agent": defineCommand({
90
134
  meta: { name: "install-launch-agent", description: "Write a macOS LaunchAgent for the installed reference server" },
91
135
  args: {