codiedev 0.9.84 → 0.9.85

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,6 +2,7 @@ import { CodiedevConfig } from "../utils";
2
2
  import { InstallOptions, PackInstallResult, PackUninstallResult } from "../packs/install";
3
3
  import { HarnessName } from "../packs/harnesses";
4
4
  import { PackManifest } from "../packs/manifest";
5
+ import { PackBundleFile } from "../packs/files";
5
6
  export declare const DEFAULT_BACKEND_URL = "https://cli.codiedev.com";
6
7
  /** CODIEDEV_TOKEN alone is enough: no ~/.codiedev/config.json needed (CI).
7
8
  * CODIEDEV_URL defaults to production. Otherwise the connected config. */
@@ -22,6 +23,8 @@ export interface PackArgs {
22
23
  manifest?: boolean;
23
24
  }
24
25
  export declare function parsePackArgs(args: string[]): PackArgs;
26
+ /** Download a pack's files (SIG-127) into the blob cache, verified by sha256. */
27
+ export declare function downloadPackFiles(config: CodiedevConfig, slug: string, files: PackBundleFile[], dir: string): Promise<number>;
25
28
  export declare function runList(config: CodiedevConfig, json: boolean, overrides?: Partial<InstallOptions>): Promise<void>;
26
29
  export interface InstallRun {
27
30
  installed: PackInstallResult[];
@@ -45,10 +48,10 @@ export declare function commitExport(dir: string, message: string): string;
45
48
  /** Write a CI-committable copy of the pack: `<out>/plugins/<slug>/…` plus
46
49
  * `<out>/.claude-plugin/marketplace.json` (existing entries for other packs
47
50
  * in the same folder are kept). */
48
- export declare function exportPluginFolder(out: string, manifest: PackManifest): string;
51
+ export declare function exportPluginFolder(out: string, manifest: PackManifest, blobDir?: string): string;
49
52
  export declare function runExport(config: CodiedevConfig, args: PackArgs): Promise<{
50
53
  written: string;
51
54
  commit?: string;
52
55
  }>;
53
56
  export declare function runApply(config: CodiedevConfig, args: PackArgs): Promise<Record<string, unknown>>;
54
- export declare const PACK_HELP = "Usage:\n codiedev pack list Packs assigned to your team\n codiedev pack install [slug] [--harness claude,codex,copilot,cursor,codie,pi] [--dry-run] [--json] [--quiet]\n Install one pack, or every pack that applies\n codiedev pack status [--json] Installed packs and whether they're up to date\n codiedev pack export <slug> --out <dir> [--git] [--manifest]\n Write the pack as a Claude-format plugin folder plus\n .claude-plugin/marketplace.json (commit it for CI).\n --git inits the folder and commits; --manifest writes\n an editable <slug>.pack.md instead\n codiedev pack apply <pack.md> Create or update a pack from a pack.md\n\nCursor: packs are copied to ~/.cursor/plugins/local/codiedev-<slug>/. A Cursor\nteam admin can disable \"user local plugin imports\"; then Cursor ignores them.\n\nAuto-update: session starts run `codiedev pack install --quiet` in the background\nabout once an hour. Opt out with CODIEDEV_PACK_AUTOSYNC=0.\n\nHooks for specific skills only: codiedev hook-gate --skills a,b --cmd-b64 <base64url of the command>\n(the pack editor writes this; the command runs via /bin/sh, or cmd.exe on Windows)\n\nCI: set CODIEDEV_TOKEN to a pack CI key (cdvci_\u2026, from the pack's page in the portal).\nNo `codiedev connect` or config file is needed. CI mode (a cdvci_ key, or CI=true)\nnever auto-syncs, reports or prunes, installs the one pack the key can see, defaults\n--harness to claude when it's on PATH, and exits non-zero unless every requested tool\ngot the pack. Skills from a pack are /<pack-slug>:<skill> in Claude Code.\n\n GitLab CI (.gitlab-ci.yml; add CODIEDEV_TOKEN and ANTHROPIC_API_KEY as masked\n CI/CD variables):\n pack-scan:\n image: node:22\n script:\n - npm i -g codiedev @anthropic-ai/claude-code\n - codiedev pack install --harness claude\n - claude -p \"/platform-starter:healthcare-scan\" --output-format text\n\n GitHub Actions (repository secrets CODIEDEV_TOKEN and ANTHROPIC_API_KEY):\n - uses: actions/setup-node@v4\n with: { node-version: 22 }\n - run: npm i -g codiedev @anthropic-ai/claude-code\n - run: codiedev pack install --harness claude\n env:\n CODIEDEV_TOKEN: ${{ secrets.CODIEDEV_TOKEN }}\n - run: claude -p \"/platform-starter:healthcare-scan\" --output-format text\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n\n Or vendor the plugin into the repo instead of installing at build time:\n codiedev pack export <slug> --out .codiedev/packs";
57
+ export declare const PACK_HELP = "Usage:\n codiedev pack list Packs assigned to your team\n codiedev pack install [slug] [--harness claude,codex,copilot,cursor,codie,pi] [--dry-run] [--json] [--quiet]\n Install one pack, or every pack that applies\n codiedev pack status [--json] Installed packs and whether they're up to date\n codiedev pack export <slug> --out <dir> [--git] [--manifest]\n Write the pack as a Claude-format plugin folder plus\n .claude-plugin/marketplace.json (commit it for CI).\n --git inits the folder and commits; --manifest writes\n an editable <slug>.pack.md instead\n codiedev pack apply <pack.md> [--dry-run]\n Create or update a pack from a pack.md\n\nFiles (scripts, PDFs, a runner folder): list them under \"## Files\" in pack.md,\none \"- <path in pack> <- <local path>\" per line (local paths are relative to\nthe pack.md; \".\" is the pack root):\n\n ## Files\n\n - runner <- ~/.claude/runner\n - skills/healthcare-scan <- ~/.claude/skills/healthcare-scan\n\n`pack apply` uploads them (admins and team managers; 15MB per file, 50MB per\npack; caches, .git and hidden files are skipped). Without a \"## Files\"\nsection the pack's files are left alone. In skills, point at files relative to\nthe skill's folder, or use ${CLAUDE_PLUGIN_ROOT}/runner/...: Codiedev swaps in the\nreal path for Codex, Copilot, Cursor, pi and Codie.\n\nCursor: packs are copied to ~/.cursor/plugins/local/codiedev-<slug>/. A Cursor\nteam admin can disable \"user local plugin imports\"; then Cursor ignores them.\n\nAuto-update: session starts run `codiedev pack install --quiet` in the background\nabout once an hour. Opt out with CODIEDEV_PACK_AUTOSYNC=0.\n\nHooks for specific skills only: codiedev hook-gate --skills a,b --cmd-b64 <base64url of the command>\n(the pack editor writes this; the command runs via /bin/sh, or cmd.exe on Windows)\n\nCI: set CODIEDEV_TOKEN to a pack CI key (cdvci_\u2026, from the pack's page in the portal).\nNo `codiedev connect` or config file is needed. CI mode (a cdvci_ key, or CI=true)\nnever auto-syncs, reports or prunes, installs the one pack the key can see, defaults\n--harness to claude when it's on PATH, and exits non-zero unless every requested tool\ngot the pack. Skills from a pack are /<pack-slug>:<skill> in Claude Code.\n\n GitLab CI (.gitlab-ci.yml; add CODIEDEV_TOKEN and ANTHROPIC_API_KEY as masked\n CI/CD variables):\n pack-scan:\n image: node:22\n script:\n - npm i -g codiedev @anthropic-ai/claude-code\n - codiedev pack install --harness claude\n - claude -p \"/platform-starter:healthcare-scan\" --output-format text\n\n GitHub Actions (repository secrets CODIEDEV_TOKEN and ANTHROPIC_API_KEY):\n - uses: actions/setup-node@v4\n with: { node-version: 22 }\n - run: npm i -g codiedev @anthropic-ai/claude-code\n - run: codiedev pack install --harness claude\n env:\n CODIEDEV_TOKEN: ${{ secrets.CODIEDEV_TOKEN }}\n - run: claude -p \"/platform-starter:healthcare-scan\" --output-format text\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n\n Or vendor the plugin into the repo instead of installing at build time:\n codiedev pack export <slug> --out .codiedev/packs";
@@ -37,6 +37,7 @@ exports.PACK_HELP = exports.DEFAULT_BACKEND_URL = void 0;
37
37
  exports.resolvePackConfig = resolvePackConfig;
38
38
  exports.ciHarnesses = ciHarnesses;
39
39
  exports.parsePackArgs = parsePackArgs;
40
+ exports.downloadPackFiles = downloadPackFiles;
40
41
  exports.runList = runList;
41
42
  exports.runInstall = runInstall;
42
43
  exports.runStatus = runStatus;
@@ -59,6 +60,7 @@ const harnesses_2 = require("../packs/harnesses");
59
60
  const os = __importStar(require("os"));
60
61
  const utils_1 = require("../utils");
61
62
  const shared_1 = require("./shared");
63
+ const files_1 = require("../packs/files");
62
64
  // --- CI (pack-scoped `cdvci_` keys) ------------------------------------------
63
65
  exports.DEFAULT_BACKEND_URL = "https://cli.codiedev.com";
64
66
  /** CODIEDEV_TOKEN alone is enough: no ~/.codiedev/config.json needed (CI).
@@ -101,7 +103,23 @@ const USAGE = `Usage:
101
103
  .claude-plugin/marketplace.json (commit it for CI).
102
104
  --git inits the folder and commits; --manifest writes
103
105
  an editable <slug>.pack.md instead
104
- codiedev pack apply <pack.md> Create or update a pack from a pack.md
106
+ codiedev pack apply <pack.md> [--dry-run]
107
+ Create or update a pack from a pack.md
108
+
109
+ Files (scripts, PDFs, a runner folder): list them under "## Files" in pack.md,
110
+ one "- <path in pack> <- <local path>" per line (local paths are relative to
111
+ the pack.md; "." is the pack root):
112
+
113
+ ## Files
114
+
115
+ - runner <- ~/.claude/runner
116
+ - skills/healthcare-scan <- ~/.claude/skills/healthcare-scan
117
+
118
+ \`pack apply\` uploads them (admins and team managers; 15MB per file, 50MB per
119
+ pack; caches, .git and hidden files are skipped). Without a "## Files"
120
+ section the pack's files are left alone. In skills, point at files relative to
121
+ the skill's folder, or use \${CLAUDE_PLUGIN_ROOT}/runner/...: Codiedev swaps in the
122
+ real path for Codex, Copilot, Cursor, pi and Codie.
105
123
 
106
124
  Cursor: packs are copied to ~/.cursor/plugins/local/codiedev-<slug>/. A Cursor
107
125
  team admin can disable "user local plugin imports"; then Cursor ignores them.
@@ -188,6 +206,16 @@ async function fetchManifest(config, slug) {
188
206
  const raw = await (0, shared_1.apiRequest)("GET", "/api/cli/packs/manifest", { config, query: { slug } });
189
207
  return (0, manifest_1.parseManifest)(raw);
190
208
  }
209
+ /** Download a pack's files (SIG-127) into the blob cache, verified by sha256. */
210
+ async function downloadPackFiles(config, slug, files, dir) {
211
+ if (!files.length)
212
+ return 0;
213
+ const { downloaded } = await (0, files_1.ensureBlobs)(dir, files, (file) => (0, shared_1.apiRequestBytes)("GET", "/api/cli/packs/file", { config, query: { slug, sha256: file.sha256 }, maxBytes: files_1.PACK_FILE_MAX_BYTES + 1 })
214
+ .catch((error) => {
215
+ throw new Error(`couldn't download ${file.path} (${error.message})`);
216
+ }));
217
+ return downloaded;
218
+ }
191
219
  async function runList(config, json, overrides = {}) {
192
220
  const packs = await listPacks(config);
193
221
  const registry = (0, store_1.readRegistry)((0, paths_1.packsRoot)(overrides.env, overrides.home));
@@ -206,6 +234,7 @@ async function runList(config, json, overrides = {}) {
206
234
  `${p.itemCount ?? 0} skill${p.itemCount === 1 ? "" : "s"}`,
207
235
  `${p.agentCount ?? 0} agent${p.agentCount === 1 ? "" : "s"}`,
208
236
  ...(p.hasHooks ? ["hooks"] : []),
237
+ ...(p.fileCount ? [`${p.fileCount} file${p.fileCount === 1 ? "" : "s"}`] : []),
209
238
  ].join(", ");
210
239
  const channel = (0, manifest_2.cleanLabel)(p.channel, 16);
211
240
  console.log(`${p.slug} v${p.version}${channel ? ` [${channel}]` : ""} ${(0, manifest_2.displayName)(p)} (${counts}) — ${state}`);
@@ -244,8 +273,27 @@ async function runInstall(config, args, overrides = {}) {
244
273
  // A listed pack that 404s a moment later (race with an unassign) is skipped.
245
274
  }
246
275
  }
247
- let installed = manifests.length
248
- ? (0, install_1.installPacks)({ ...overrides, manifests, harnesses, dryRun: args.dryRun })
276
+ // Pack files first: a pack whose files can't be fetched keeps its previous
277
+ // install untouched rather than shipping half its folders.
278
+ const blobDir = overrides.blobDir ?? (0, files_1.blobsDir)(root);
279
+ const ready = [];
280
+ let downloadFailed = false;
281
+ for (const manifest of manifests) {
282
+ if (args.dryRun || !manifest.files?.length) {
283
+ ready.push(manifest);
284
+ continue;
285
+ }
286
+ try {
287
+ await downloadPackFiles(config, manifest.slug, manifest.files, blobDir);
288
+ ready.push(manifest);
289
+ }
290
+ catch (error) {
291
+ downloadFailed = true;
292
+ console.error(`✗ ${manifest.slug}: ${error.message}`);
293
+ }
294
+ }
295
+ let installed = ready.length
296
+ ? (0, install_1.installPacks)({ ...overrides, manifests: ready, harnesses, dryRun: args.dryRun, blobDir })
249
297
  : [];
250
298
  let removed = [];
251
299
  if (!args.dryRun && !ci) {
@@ -293,7 +341,7 @@ async function runInstall(config, args, overrides = {}) {
293
341
  const ciFailed = ci && !args.dryRun && (installed.length === 0 || installed.some((p) => p.results.some((r) => r.status !== "ok")));
294
342
  if (ci && !args.dryRun && installed.length === 0)
295
343
  console.error("No pack is visible to this key.");
296
- if (failed || ciFailed || (args.slug && removed.length + revoked.length > 0))
344
+ if (failed || ciFailed || downloadFailed || (args.slug && removed.length + revoked.length > 0))
297
345
  process.exitCode = 1;
298
346
  return run;
299
347
  }
@@ -494,9 +542,9 @@ function readMarketplaceEntries(dir) {
494
542
  /** Write a CI-committable copy of the pack: `<out>/plugins/<slug>/…` plus
495
543
  * `<out>/.claude-plugin/marketplace.json` (existing entries for other packs
496
544
  * in the same folder are kept). */
497
- function exportPluginFolder(out, manifest) {
545
+ function exportPluginFolder(out, manifest, blobDir) {
498
546
  fs.mkdirSync(out, { recursive: true });
499
- const dir = (0, store_1.writePluginDir)(out, manifest);
547
+ const dir = (0, store_1.writePluginDir)(out, manifest, { blobDir });
500
548
  const entries = readMarketplaceEntries(out).filter((e) => e.slug !== manifest.slug);
501
549
  entries.push({ slug: manifest.slug, description: manifest.description || manifest.name, version: manifest.version });
502
550
  (0, store_1.writeMarketplace)(out, entries);
@@ -524,7 +572,24 @@ async function runExport(config, args) {
524
572
  fs.mkdirSync(out, { recursive: true });
525
573
  // <slug>.pack.md so several packs can share one folder.
526
574
  written = path.join(out, `${args.slug}.pack.md`);
527
- (0, store_1.writeFileAtomic)(written, res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
575
+ let markdown = res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`;
576
+ // Pack files go beside it in <slug>.files/, mapped back with `## Files`
577
+ // so `pack apply` round-trips them.
578
+ const files = (0, manifest_1.parseManifest)({ slug: args.slug, version: 0, contentHash: "-", files: res.files }).files ?? [];
579
+ if (files.length) {
580
+ const blobDir = (0, files_1.blobsDir)((0, paths_1.packsRoot)());
581
+ await downloadPackFiles(config, args.slug, files, blobDir);
582
+ const filesDir = path.join(out, `${args.slug}.files`);
583
+ fs.rmSync(filesDir, { recursive: true, force: true });
584
+ for (const f of files) {
585
+ const target = path.join(filesDir, ...f.path.split("/"));
586
+ fs.mkdirSync(path.dirname(target), { recursive: true });
587
+ fs.copyFileSync((0, files_1.blobPath)(blobDir, f.sha256), target);
588
+ fs.chmodSync(target, f.executable ? 0o755 : 0o644);
589
+ }
590
+ markdown = `${markdown.trimEnd()}\n\n## Files\n\n- . <- ./${args.slug}.files\n`;
591
+ }
592
+ (0, store_1.writeFileAtomic)(written, markdown);
528
593
  message = `codiedev pack export ${args.slug} (${args.slug}.pack.md)`;
529
594
  }
530
595
  else {
@@ -539,7 +604,9 @@ async function runExport(config, args) {
539
604
  throw new Error(`Refusing to export ${args.slug}: ${error.message}`);
540
605
  throw error;
541
606
  }
542
- written = exportPluginFolder(out, manifest);
607
+ const blobDir = (0, files_1.blobsDir)((0, paths_1.packsRoot)());
608
+ await downloadPackFiles(config, manifest.slug, manifest.files ?? [], blobDir);
609
+ written = exportPluginFolder(out, manifest, blobDir);
543
610
  message = `codiedev pack export ${manifest.slug} v${manifest.version}`;
544
611
  }
545
612
  const commit = args.git ? commitExport(out, message) : undefined;
@@ -567,13 +634,31 @@ async function runApply(config, args) {
567
634
  }
568
635
  if (!markdown.trim())
569
636
  throw new Error(`${file} is empty`);
637
+ // `## Files` (SIG-127): local folders/files to ship in the plugin dir.
638
+ // Absent ⇒ the pack's files are left as they are.
639
+ const entries = (0, files_1.parseFilesSection)(markdown);
640
+ const local = entries ? (0, files_1.collectLocalFiles)(entries, path.dirname(file)) : null;
641
+ for (const note of local?.notes ?? [])
642
+ console.log(` · ${note}`);
570
643
  if (args.dryRun) {
571
- console.log(`Dry run — would apply ${file} (${Buffer.byteLength(markdown)} bytes).`);
644
+ const extra = local ? `, ${local.files.length} file${local.files.length === 1 ? "" : "s"} (${formatBytes(local.files.reduce((n, f) => n + f.size, 0))})` : "";
645
+ console.log(`Dry run — would apply ${file} (${Buffer.byteLength(markdown)} bytes${extra}).`);
646
+ for (const f of local?.files ?? [])
647
+ console.log(` ${f.path}`);
572
648
  return {};
573
649
  }
650
+ let uploaded = 0;
651
+ if (local?.files.length)
652
+ uploaded = await uploadPackFiles(config, local.files);
574
653
  let res;
575
654
  try {
576
- res = await (0, shared_1.apiRequest)("POST", "/api/cli/packs/apply", { config, body: { markdown } });
655
+ res = await (0, shared_1.apiRequest)("POST", "/api/cli/packs/apply", {
656
+ config,
657
+ body: {
658
+ markdown,
659
+ ...(local ? { files: local.files.map((f) => ({ path: f.path, sha256: f.sha256, ...(f.executable ? { executable: true } : {}) })) } : {}),
660
+ },
661
+ });
577
662
  }
578
663
  catch (error) {
579
664
  // The server reports pack.md problems with line numbers; show them as-is.
@@ -590,6 +675,8 @@ async function runApply(config, args) {
590
675
  const verb = res.created === true ? "Created" : res.changed === false ? "No changes to" : "Updated";
591
676
  const version = typeof res.version === "number" ? ` v${res.version}` : "";
592
677
  console.log(`✓ ${verb} ${slug}${version} from ${path.basename(file)}`);
678
+ if (local)
679
+ console.log(` files: ${local.files.length} (${uploaded} uploaded, ${local.files.length - uploaded} already on Codiedev)`);
593
680
  if (Array.isArray(res.warnings)) {
594
681
  for (const w of res.warnings) {
595
682
  const text = typeof w === "string" ? w : typeof w?.message === "string" ? `${w.agent ? `${w.agent}: ` : ""}${w.message}` : JSON.stringify(w);
@@ -599,4 +686,36 @@ async function runApply(config, args) {
599
686
  }
600
687
  return res;
601
688
  }
689
+ function formatBytes(n) {
690
+ return n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)}MB` : n >= 1024 ? `${Math.round(n / 1024)}KB` : `${n}B`;
691
+ }
692
+ /** Upload the blobs Codiedev doesn't have yet. Returns how many were sent. */
693
+ async function uploadPackFiles(config, files) {
694
+ const unique = [...new Map(files.map((f) => [f.sha256, f])).values()];
695
+ let known;
696
+ try {
697
+ known = (await (0, shared_1.apiRequest)("POST", "/api/cli/packs/files/known", { config, body: { sha256s: unique.map((f) => f.sha256) } })).known ?? [];
698
+ }
699
+ catch (error) {
700
+ const e = error;
701
+ if (e.status === 403)
702
+ throw new Error(`Only admins and team managers can add files to a pack (${e.message})`);
703
+ throw error;
704
+ }
705
+ const have = new Set(known);
706
+ let sent = 0;
707
+ for (const f of unique) {
708
+ if (have.has(f.sha256))
709
+ continue;
710
+ const body = fs.readFileSync(f.abs);
711
+ try {
712
+ await (0, shared_1.apiRequestBytes)("POST", "/api/cli/packs/files/upload", { config, body, query: { sha256: f.sha256 } });
713
+ }
714
+ catch (error) {
715
+ throw new Error(`couldn't upload ${f.path} (${error.message})`);
716
+ }
717
+ sent += 1;
718
+ }
719
+ return sent;
720
+ }
602
721
  exports.PACK_HELP = USAGE;
@@ -21,6 +21,17 @@ export declare function apiRequest<T = unknown>(method: "GET" | "POST", path: st
21
21
  body?: Record<string, unknown>;
22
22
  query?: Record<string, string | number | undefined>;
23
23
  }): Promise<T>;
24
+ /**
25
+ * Binary twin of apiRequest: GET returns the raw body; POST sends `body` as
26
+ * application/octet-stream and returns parsed JSON. Same auth and errors
27
+ * (`status` on non-2xx). Used for starter-pack files (SIG-127).
28
+ */
29
+ export declare function apiRequestBytes(method: "GET" | "POST", path: string, options: {
30
+ config: CodiedevConfig;
31
+ body?: Buffer;
32
+ query?: Record<string, string | number | undefined>;
33
+ maxBytes?: number;
34
+ }): Promise<Buffer>;
24
35
  /**
25
36
  * Fire-and-forget per-invocation telemetry from CLI subcommands. Mirrors
26
37
  * the MCP server's emitTelemetry but stamps `source: "cli"` so adoption
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.applyConfigEnvOverrides = applyConfigEnvOverrides;
37
37
  exports.requireConfig = requireConfig;
38
38
  exports.apiRequest = apiRequest;
39
+ exports.apiRequestBytes = apiRequestBytes;
39
40
  exports.recordAgentEvent = recordAgentEvent;
40
41
  exports.recordInstallEvent = recordInstallEvent;
41
42
  exports.withTelemetry = withTelemetry;
@@ -135,6 +136,62 @@ function apiRequest(method, path, options) {
135
136
  req.end();
136
137
  });
137
138
  }
139
+ /**
140
+ * Binary twin of apiRequest: GET returns the raw body; POST sends `body` as
141
+ * application/octet-stream and returns parsed JSON. Same auth and errors
142
+ * (`status` on non-2xx). Used for starter-pack files (SIG-127).
143
+ */
144
+ function apiRequestBytes(method, path, options) {
145
+ return new Promise((resolve, reject) => {
146
+ const url = new URL(path, options.config.backendUrl);
147
+ for (const [k, v] of Object.entries(options.query ?? {})) {
148
+ if (v !== undefined)
149
+ url.searchParams.set(k, String(v));
150
+ }
151
+ const lib = url.protocol === "https:" ? https : http;
152
+ const req = lib.request({
153
+ hostname: url.hostname,
154
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
155
+ path: url.pathname + url.search,
156
+ method,
157
+ headers: {
158
+ Authorization: `Bearer ${options.config.token}`,
159
+ "X-Codiedev-Cli-Version": version_1.CLI_VERSION,
160
+ ...(options.body ? { "Content-Type": "application/octet-stream", "Content-Length": options.body.length } : {}),
161
+ },
162
+ }, (res) => {
163
+ const chunks = [];
164
+ let size = 0;
165
+ res.on("data", (c) => {
166
+ size += c.length;
167
+ if (options.maxBytes !== undefined && size > options.maxBytes) {
168
+ req.destroy(Object.assign(new Error(`response over ${options.maxBytes} bytes`), { status: res.statusCode }));
169
+ return;
170
+ }
171
+ chunks.push(c);
172
+ });
173
+ res.on("end", () => {
174
+ const buf = Buffer.concat(chunks);
175
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300)
176
+ return resolve(buf);
177
+ let message = `HTTP ${res.statusCode}`;
178
+ try {
179
+ const parsed = JSON.parse(buf.toString("utf8"));
180
+ if (parsed && typeof parsed.error === "string")
181
+ message = parsed.error;
182
+ }
183
+ catch {
184
+ // not JSON
185
+ }
186
+ reject(Object.assign(new Error(message), { status: res.statusCode }));
187
+ });
188
+ });
189
+ req.on("error", reject);
190
+ if (options.body)
191
+ req.write(options.body);
192
+ req.end();
193
+ });
194
+ }
138
195
  /**
139
196
  * Fire-and-forget per-invocation telemetry from CLI subcommands. Mirrors
140
197
  * the MCP server's emitTelemetry but stamps `source: "cli"` so adoption
@@ -0,0 +1,46 @@
1
+ export declare const PACK_MAX_FILES = 500;
2
+ export declare const PACK_FILE_MAX_BYTES: number;
3
+ export declare const PACK_FILES_MAX_TOTAL_BYTES: number;
4
+ export declare const SHA256_HEX: RegExp;
5
+ export interface PackBundleFile {
6
+ path: string;
7
+ sha256: string;
8
+ size: number;
9
+ executable?: boolean;
10
+ }
11
+ /** Why a plugin-relative path can't be written, or null when it's fine. */
12
+ export declare function packFilePathProblem(raw: unknown): string | null;
13
+ export declare function blobsDir(root: string): string;
14
+ export declare function blobPath(dir: string, sha256: string): string;
15
+ export declare function sha256Of(buf: Buffer): string;
16
+ /** Download every blob the manifest needs that isn't cached yet. Each body
17
+ * must match its sha256 and size or nothing is kept. */
18
+ export declare function ensureBlobs(dir: string, files: PackBundleFile[], download: (file: PackBundleFile) => Promise<Buffer>): Promise<{
19
+ downloaded: number;
20
+ }>;
21
+ export interface FilesEntry {
22
+ dest: string;
23
+ source: string;
24
+ line: number;
25
+ }
26
+ /** The `## Files` section of a pack.md, or null when there is none (then
27
+ * `pack apply` leaves the pack's files alone). Fenced blocks are skipped so
28
+ * agent bodies can't open a section. */
29
+ export declare function parseFilesSection(markdown: string): FilesEntry[] | null;
30
+ /** pack.md with its `## Files` section (heading and body) removed. */
31
+ export declare function stripFilesSection(markdown: string): string;
32
+ export interface LocalFile extends PackBundleFile {
33
+ abs: string;
34
+ }
35
+ export declare function expandHome(p: string, home?: string): string;
36
+ /** Walk each `## Files` entry. Junk (caches, VCS, editor files) is skipped
37
+ * quietly; symlinks, hidden files and a skill folder's own SKILL.md are
38
+ * skipped with a note. Throws on a missing source or a bad destination. */
39
+ export declare function collectLocalFiles(entries: FilesEntry[], baseDir: string, home?: string): {
40
+ files: LocalFile[];
41
+ notes: string[];
42
+ };
43
+ /** Only Claude Code sets ${CLAUDE_PLUGIN_ROOT}. In the copy other tools read,
44
+ * replace it with the plugin dir's real path (forward slashes, which every
45
+ * shell and Python accept, Windows included). */
46
+ export declare function rewritePluginRoot(content: string, pluginRoot: string): string;
@@ -0,0 +1,307 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SHA256_HEX = exports.PACK_FILES_MAX_TOTAL_BYTES = exports.PACK_FILE_MAX_BYTES = exports.PACK_MAX_FILES = void 0;
37
+ exports.packFilePathProblem = packFilePathProblem;
38
+ exports.blobsDir = blobsDir;
39
+ exports.blobPath = blobPath;
40
+ exports.sha256Of = sha256Of;
41
+ exports.ensureBlobs = ensureBlobs;
42
+ exports.parseFilesSection = parseFilesSection;
43
+ exports.stripFilesSection = stripFilesSection;
44
+ exports.expandHome = expandHome;
45
+ exports.collectLocalFiles = collectLocalFiles;
46
+ exports.rewritePluginRoot = rewritePluginRoot;
47
+ // Starter-pack files (SIG-127): skill subfolders, scripts, PDFs and runner
48
+ // folders shipped inside a pack's plugin dir.
49
+ //
50
+ // - Path rules mirror convex/lib/skillPackManifest.ts packFilePathProblem.
51
+ // The server checks them; the CLI checks again before any write, because a
52
+ // manifest is untrusted input that becomes paths on the member's machine.
53
+ // - Blobs are content-addressed (sha256 hex) and cached under
54
+ // <packs root>/.blobs/<sha256>, verified on download, so reinstalls and
55
+ // the portable copy never fetch twice.
56
+ // - `## Files` in pack.md maps local folders to plugin paths for
57
+ // `codiedev pack apply`: `- runner <- ./runner` (`.` = plugin root).
58
+ const crypto_1 = require("crypto");
59
+ const fs = __importStar(require("fs"));
60
+ const os = __importStar(require("os"));
61
+ const path = __importStar(require("path"));
62
+ exports.PACK_MAX_FILES = 500;
63
+ exports.PACK_FILE_MAX_BYTES = 15 * 1024 * 1024;
64
+ exports.PACK_FILES_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
65
+ const PATH_MAX = 300;
66
+ const MAX_DEPTH = 16;
67
+ const FILE_SEGMENT = /^[A-Za-z0-9_@+][A-Za-z0-9._ @+=,-]{0,127}$/;
68
+ exports.SHA256_HEX = /^[0-9a-f]{64}$/;
69
+ /** Why a plugin-relative path can't be written, or null when it's fine. */
70
+ function packFilePathProblem(raw) {
71
+ if (typeof raw !== "string" || !raw)
72
+ return "empty path";
73
+ if (raw.length > PATH_MAX)
74
+ return `longer than ${PATH_MAX} characters`;
75
+ if (raw.includes("\\"))
76
+ return "use / as the separator";
77
+ if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw))
78
+ return "must be relative to the pack folder";
79
+ const segments = raw.split("/");
80
+ if (segments.length > MAX_DEPTH)
81
+ return `nested deeper than ${MAX_DEPTH} folders`;
82
+ for (const seg of segments) {
83
+ if (!seg || seg === "." || seg === "..")
84
+ return "empty, . or .. segment";
85
+ if (!FILE_SEGMENT.test(seg) || seg.trim() !== seg || seg.endsWith("."))
86
+ return `unsupported name "${seg}"`;
87
+ }
88
+ const lower = raw.toLowerCase();
89
+ if (lower.startsWith(".claude-plugin/"))
90
+ return ".claude-plugin/ is written by Codiedev";
91
+ if (lower === "hooks/hooks.json")
92
+ return "hooks/hooks.json comes from the pack's hooks";
93
+ if (segments.length === 2 && segments[0].toLowerCase() === "agents" && lower.endsWith(".md"))
94
+ return "agents/*.md come from the pack's agents";
95
+ if (segments.length === 3 && segments[0].toLowerCase() === "skills" && segments[2].toLowerCase() === "skill.md") {
96
+ return "SKILL.md comes from the skill library";
97
+ }
98
+ return null;
99
+ }
100
+ // ─── Blob cache ─────────────────────────────────────────────────────────────
101
+ function blobsDir(root) {
102
+ return path.join(root, ".blobs");
103
+ }
104
+ function blobPath(dir, sha256) {
105
+ if (!exports.SHA256_HEX.test(sha256))
106
+ throw new Error(`bad sha256: ${sha256}`);
107
+ return path.join(dir, sha256);
108
+ }
109
+ function sha256Of(buf) {
110
+ return (0, crypto_1.createHash)("sha256").update(buf).digest("hex");
111
+ }
112
+ function hasBlob(dir, file) {
113
+ try {
114
+ return fs.statSync(blobPath(dir, file.sha256)).size === file.size;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** Download every blob the manifest needs that isn't cached yet. Each body
121
+ * must match its sha256 and size or nothing is kept. */
122
+ async function ensureBlobs(dir, files, download) {
123
+ let downloaded = 0;
124
+ const seen = new Set();
125
+ for (const file of files) {
126
+ if (seen.has(file.sha256) || hasBlob(dir, file))
127
+ continue;
128
+ seen.add(file.sha256);
129
+ const body = await download(file);
130
+ if (body.length !== file.size || sha256Of(body) !== file.sha256) {
131
+ throw new Error(`${file.path}: downloaded bytes don't match the pack (checksum mismatch)`);
132
+ }
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const target = blobPath(dir, file.sha256);
135
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
136
+ fs.writeFileSync(tmp, body, { mode: 0o644 });
137
+ fs.renameSync(tmp, target);
138
+ downloaded += 1;
139
+ }
140
+ return { downloaded };
141
+ }
142
+ /** The `## Files` section of a pack.md, or null when there is none (then
143
+ * `pack apply` leaves the pack's files alone). Fenced blocks are skipped so
144
+ * agent bodies can't open a section. */
145
+ function parseFilesSection(markdown) {
146
+ const lines = markdown.replace(/^/, "").split("\n").map((l) => l.replace(/\r$/, ""));
147
+ let fence = null;
148
+ let inFiles = false;
149
+ let found = false;
150
+ const out = [];
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i];
153
+ const f = line.match(/^(`{3,})/);
154
+ if (f) {
155
+ if (!fence)
156
+ fence = f[1];
157
+ else if (f[1].length >= fence.length && /^`+\s*$/.test(line))
158
+ fence = null;
159
+ continue;
160
+ }
161
+ if (fence)
162
+ continue;
163
+ const h2 = line.match(/^##\s+(.+?)\s*$/);
164
+ if (h2 && !line.startsWith("###")) {
165
+ inFiles = h2[1].toLowerCase() === "files";
166
+ if (inFiles)
167
+ found = true;
168
+ continue;
169
+ }
170
+ if (!inFiles || !line.trim())
171
+ continue;
172
+ const m = line.match(/^\s*[-*]\s+(.+?)\s+<-\s+(.+?)\s*$/);
173
+ if (!m)
174
+ throw new Error(`pack.md line ${i + 1}: expected "- <path in pack> <- <local path>" under ## Files`);
175
+ out.push({ dest: unquote(m[1]), source: unquote(m[2]), line: i + 1 });
176
+ }
177
+ return found ? out : null;
178
+ }
179
+ function unquote(value) {
180
+ const v = value.trim();
181
+ return (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v;
182
+ }
183
+ /** pack.md with its `## Files` section (heading and body) removed. */
184
+ function stripFilesSection(markdown) {
185
+ const lines = markdown.split("\n");
186
+ const out = [];
187
+ let fence = null;
188
+ let skipping = false;
189
+ for (const raw of lines) {
190
+ const line = raw.replace(/\r$/, "");
191
+ const f = line.match(/^(`{3,})/);
192
+ if (f && !skipping) {
193
+ if (!fence)
194
+ fence = f[1];
195
+ else if (f[1].length >= fence.length && /^`+\s*$/.test(line))
196
+ fence = null;
197
+ out.push(raw);
198
+ continue;
199
+ }
200
+ if (!fence) {
201
+ const h2 = line.match(/^##\s+(.+?)\s*$/);
202
+ if (h2 && !line.startsWith("###"))
203
+ skipping = h2[1].toLowerCase() === "files";
204
+ }
205
+ if (!skipping)
206
+ out.push(raw);
207
+ }
208
+ return out.join("\n");
209
+ }
210
+ // ─── Collecting local files for `pack apply` ────────────────────────────────
211
+ // Build/cache/VCS clutter that never belongs in a pack.
212
+ const SKIP_DIRS = new Set(["__pycache__", ".git", "node_modules", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".idea", ".vscode"]);
213
+ const SKIP_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
214
+ const SKIP_EXT = /\.(pyc|pyo|swp|tmp)$/i;
215
+ function expandHome(p, home = os.homedir()) {
216
+ return p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
217
+ }
218
+ /** Walk each `## Files` entry. Junk (caches, VCS, editor files) is skipped
219
+ * quietly; symlinks, hidden files and a skill folder's own SKILL.md are
220
+ * skipped with a note. Throws on a missing source or a bad destination. */
221
+ function collectLocalFiles(entries, baseDir, home = os.homedir()) {
222
+ const files = new Map();
223
+ const notes = [];
224
+ let total = 0;
225
+ const add = (dest, abs) => {
226
+ const problem = packFilePathProblem(dest);
227
+ if (problem) {
228
+ const parts = dest.split("/");
229
+ if (parts.length === 3 && parts[0] === "skills" && parts[2].toLowerCase() === "skill.md") {
230
+ notes.push(`skipped ${dest} (the skill itself comes from the skill library; push it with \`codiedev push\`)`);
231
+ return;
232
+ }
233
+ if (parts.some((s) => s.startsWith(".") && s !== "." && s !== "..")) {
234
+ notes.push(`skipped hidden file ${dest}`);
235
+ return;
236
+ }
237
+ throw new Error(`can't ship ${dest}: ${problem}`);
238
+ }
239
+ const stat = fs.statSync(abs);
240
+ if (stat.size > exports.PACK_FILE_MAX_BYTES)
241
+ throw new Error(`${abs} is over ${exports.PACK_FILE_MAX_BYTES / (1024 * 1024)}MB`);
242
+ const key = dest.toLowerCase();
243
+ if (files.has(key))
244
+ throw new Error(`two sources write ${dest}`);
245
+ const buf = fs.readFileSync(abs);
246
+ total += buf.length;
247
+ files.set(key, { path: dest, abs, size: buf.length, sha256: sha256Of(buf), ...((stat.mode & 0o111) !== 0 ? { executable: true } : {}) });
248
+ };
249
+ const walk = (dir, dest, depth) => {
250
+ if (depth > MAX_DEPTH)
251
+ throw new Error(`${dir} is nested too deep`);
252
+ for (const name of fs.readdirSync(dir).sort()) {
253
+ const abs = path.join(dir, name);
254
+ const lst = fs.lstatSync(abs);
255
+ const child = dest ? `${dest}/${name}` : name;
256
+ if (lst.isSymbolicLink()) {
257
+ notes.push(`skipped symlink ${child}`);
258
+ continue;
259
+ }
260
+ if (lst.isDirectory()) {
261
+ if (!SKIP_DIRS.has(name))
262
+ walk(abs, child, depth + 1);
263
+ continue;
264
+ }
265
+ if (!lst.isFile() || SKIP_FILES.has(name) || SKIP_EXT.test(name))
266
+ continue;
267
+ add(child, abs);
268
+ }
269
+ };
270
+ for (const entry of entries) {
271
+ const dest = entry.dest.replace(/^\.\/+/, "").replace(/\/+$/, "");
272
+ if (dest !== "." && (!dest || dest.startsWith("/") || dest.split("/").some((seg) => !seg || seg === "." || seg === ".."))) {
273
+ throw new Error(`pack.md line ${entry.line}: "${entry.dest}" must be a path inside the pack (or "." for its root)`);
274
+ }
275
+ const source = path.resolve(baseDir, expandHome(entry.source, home));
276
+ let stat;
277
+ try {
278
+ stat = fs.statSync(source);
279
+ }
280
+ catch {
281
+ throw new Error(`pack.md line ${entry.line}: ${entry.source} doesn't exist`);
282
+ }
283
+ if (stat.isDirectory())
284
+ walk(source, dest === "." ? "" : dest, 0);
285
+ else if (stat.isFile()) {
286
+ if (dest === "." || !dest)
287
+ throw new Error(`pack.md line ${entry.line}: give a file a path, e.g. "- tools/${path.basename(source)} <- ${entry.source}"`);
288
+ add(dest, source);
289
+ }
290
+ else
291
+ throw new Error(`pack.md line ${entry.line}: ${entry.source} is not a file or folder`);
292
+ }
293
+ const list = [...files.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
294
+ if (list.length > exports.PACK_MAX_FILES)
295
+ throw new Error(`a pack holds at most ${exports.PACK_MAX_FILES} files (found ${list.length})`);
296
+ if (total > exports.PACK_FILES_MAX_TOTAL_BYTES)
297
+ throw new Error(`pack files total over ${exports.PACK_FILES_MAX_TOTAL_BYTES / (1024 * 1024)}MB`);
298
+ return { files: list, notes };
299
+ }
300
+ // ─── Portable copies ────────────────────────────────────────────────────────
301
+ /** Only Claude Code sets ${CLAUDE_PLUGIN_ROOT}. In the copy other tools read,
302
+ * replace it with the plugin dir's real path (forward slashes, which every
303
+ * shell and Python accept, Windows included). */
304
+ function rewritePluginRoot(content, pluginRoot) {
305
+ const root = pluginRoot.split(path.sep).join("/");
306
+ return content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT(?![A-Za-z0-9_])/g, root);
307
+ }
@@ -259,8 +259,8 @@ function copyCursorSkills(home, manifest, pluginDirPath) {
259
259
  }
260
260
  fs.rmSync(dest, { recursive: true, force: true });
261
261
  }
262
- fs.mkdirSync(dest, { recursive: true });
263
- fs.copyFileSync(path.join(pluginDirPath, "skills", skill.name, "SKILL.md"), path.join(dest, "SKILL.md"));
262
+ // The whole skill folder: SKILL.md plus any pack files under it (SIG-127).
263
+ copyTreeNoSymlinks(path.join(pluginDirPath, "skills", skill.name), dest);
264
264
  fs.writeFileSync(path.join(dest, exports.CURSOR_MARKER), `${manifest.slug}\n`);
265
265
  }
266
266
  return notes;
@@ -321,8 +321,9 @@ function copyTreeNoSymlinks(source, dest, depth = 0) {
321
321
  const stat = fs.statSync(from); // follows links
322
322
  if (stat.isDirectory())
323
323
  copyTreeNoSymlinks(from, to, depth + 1);
324
+ // Keep the executable bit (pack scripts).
324
325
  else if (stat.isFile())
325
- fs.writeFileSync(to, fs.readFileSync(from));
326
+ fs.writeFileSync(to, fs.readFileSync(from), { mode: stat.mode & 0o777 });
326
327
  }
327
328
  }
328
329
  /** Copy the plugin dir into place via a sibling temp dir + rename. Refuses to
@@ -10,6 +10,8 @@ export interface InstallOptions {
10
10
  home?: string;
11
11
  run?: Runner;
12
12
  now?: () => number;
13
+ /** Where pack file blobs were downloaded (default <packs root>/.blobs). */
14
+ blobDir?: string;
13
15
  }
14
16
  export interface PackInstallResult {
15
17
  slug: string;
@@ -26,6 +28,8 @@ export declare function codexPackHooksPath(codexDir: string, slug: string, versi
26
28
  /** Trust entries for every registry pack installed into Codex. */
27
29
  export declare function codexPackTrustEntries(registry: Registry, codexDir: string): Map<string, CodexHookTrustEntry[]>;
28
30
  export declare function installPacks(options: InstallOptions): PackInstallResult[];
31
+ /** Drop cached blobs no installed pack references (best-effort). */
32
+ export declare function pruneBlobs(root: string, registry: Registry, dir?: string): number;
29
33
  export interface UninstallOptions {
30
34
  slugs: string[];
31
35
  env?: NodeJS.ProcessEnv;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.codexPackHooksPath = codexPackHooksPath;
37
37
  exports.codexPackTrustEntries = codexPackTrustEntries;
38
38
  exports.installPacks = installPacks;
39
+ exports.pruneBlobs = pruneBlobs;
39
40
  exports.uninstallPacks = uninstallPacks;
40
41
  exports.formatUninstallLines = formatUninstallLines;
41
42
  exports.formatResultLines = formatResultLines;
@@ -50,6 +51,7 @@ const codexHookTrust_1 = require("../codexHookTrust");
50
51
  const harnesses_1 = require("./harnesses");
51
52
  const manifest_1 = require("./manifest");
52
53
  const paths_1 = require("./paths");
54
+ const files_1 = require("./files");
53
55
  const shim_1 = require("./shim");
54
56
  const store_1 = require("./store");
55
57
  function marketplaceEntries(registry) {
@@ -136,7 +138,7 @@ function installPacks(options) {
136
138
  for (const manifest of options.manifests) {
137
139
  const dir = (0, paths_1.pluginDir)(root, manifest.slug);
138
140
  const previous = registry.packs[manifest.slug];
139
- const files = Object.keys((0, store_1.pluginFiles)(manifest)).sort();
141
+ const files = [...Object.keys((0, store_1.pluginFiles)(manifest)), ...(manifest.files ?? []).map((f) => f.path)].sort();
140
142
  const results = [];
141
143
  if (options.dryRun) {
142
144
  for (const adapter of adapters) {
@@ -148,9 +150,11 @@ function installPacks(options) {
148
150
  output.push({ slug: manifest.slug, version: manifest.version, contentHash: manifest.contentHash, dir, files, results, harnesses: [] });
149
151
  continue;
150
152
  }
151
- (0, store_1.writePluginDir)(root, manifest);
152
- // Same plugin with agent model levels removed, for Codex/Copilot/Cursor.
153
- (0, store_1.writePluginDir)((0, paths_1.portableRoot)(root), manifest, { portable: true });
153
+ const blobDir = options.blobDir ?? (0, files_1.blobsDir)(root);
154
+ (0, store_1.writePluginDir)(root, manifest, { blobDir });
155
+ // Same plugin with agent model levels removed and ${CLAUDE_PLUGIN_ROOT}
156
+ // made a real path, for Codex/Copilot/Cursor/pi/Codie.
157
+ (0, store_1.writePluginDir)((0, paths_1.portableRoot)(root), manifest, { portable: true, blobDir });
154
158
  // Harnesses read the marketplace during install, so it must list this pack first.
155
159
  const pending = {
156
160
  packs: {
@@ -196,12 +200,14 @@ function installPacks(options) {
196
200
  description: manifest.description,
197
201
  ...(manifest.channel ? { channel: manifest.channel } : {}),
198
202
  ...(manifest.icon ? { icon: manifest.icon } : {}),
203
+ ...(manifest.files?.length ? { blobs: [...new Set(manifest.files.map((f) => f.sha256))] } : {}),
199
204
  };
200
205
  (0, store_1.writeRegistry)(root, registry);
201
206
  output.push({ slug: manifest.slug, ...(manifest.channel ? { channel: manifest.channel } : {}), version: manifest.version, contentHash: manifest.contentHash, dir, files, results, harnesses });
202
207
  }
203
208
  if (options.dryRun || options.manifests.length === 0)
204
209
  return output;
210
+ pruneBlobs(root, registry, options.blobDir);
205
211
  if (selected.has("codex") && detected.get("codex")) {
206
212
  const bySlug = regenerateCodexTrust(registry, env, home);
207
213
  for (const pack of output) {
@@ -223,6 +229,30 @@ function installPacks(options) {
223
229
  }
224
230
  return output;
225
231
  }
232
+ /** Drop cached blobs no installed pack references (best-effort). */
233
+ function pruneBlobs(root, registry, dir = (0, files_1.blobsDir)(root)) {
234
+ const keep = new Set(Object.values(registry.packs).flatMap((e) => e.blobs ?? []));
235
+ let removed = 0;
236
+ let entries = [];
237
+ try {
238
+ entries = fs.readdirSync(dir);
239
+ }
240
+ catch {
241
+ return 0;
242
+ }
243
+ for (const name of entries) {
244
+ if (keep.has(name))
245
+ continue;
246
+ try {
247
+ fs.rmSync(path.join(dir, name), { force: true });
248
+ removed += 1;
249
+ }
250
+ catch {
251
+ // in use or already gone
252
+ }
253
+ }
254
+ return removed;
255
+ }
226
256
  function regenerateCodexTrust(registry, env, home) {
227
257
  const codexDir = (0, harnessDirs_1.codexHome)(env, home);
228
258
  const bySlug = codexPackTrustEntries(registry, codexDir);
@@ -276,6 +306,7 @@ function uninstallPacks(options) {
276
306
  if (fs.existsSync(codexConfig))
277
307
  regenerateCodexTrust(registry, env, home);
278
308
  (0, shim_1.writeShim)((0, paths_1.shimPath)(root), shimInputs(registry));
309
+ pruneBlobs(root, registry);
279
310
  return output;
280
311
  }
281
312
  function formatUninstallLines(results) {
@@ -1,3 +1,4 @@
1
+ import { PackBundleFile } from "./files";
1
2
  export interface PackManifestFile {
2
3
  name: string;
3
4
  content: string;
@@ -17,6 +18,8 @@ export interface PackManifest {
17
18
  channel?: string;
18
19
  /** Display emoji/icon; never used in paths. */
19
20
  icon?: string;
21
+ /** SIG-127: extra files in the plugin dir, fetched by sha256. */
22
+ files?: PackBundleFile[];
20
23
  }
21
24
  export interface PackSummary {
22
25
  slug: string;
@@ -28,6 +31,7 @@ export interface PackSummary {
28
31
  itemCount?: number;
29
32
  agentCount?: number;
30
33
  hasHooks?: boolean;
34
+ fileCount?: number;
31
35
  }
32
36
  export declare function isSafeSegment(value: unknown): value is string;
33
37
  export declare function isSafeSkillName(value: unknown): value is string;
@@ -1,7 +1,4 @@
1
1
  "use strict";
2
- // Server manifest shape (GET /api/cli/packs/manifest) and its validation.
3
- // Everything here is untrusted input that becomes file paths and shell hooks
4
- // on the member's machine, so names are checked strictly before any write.
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.ManifestError = void 0;
7
4
  exports.isSafeSegment = isSafeSegment;
@@ -10,6 +7,8 @@ exports.parseManifest = parseManifest;
10
7
  exports.cleanLabel = cleanLabel;
11
8
  exports.displayName = displayName;
12
9
  exports.parseFrontmatter = parseFrontmatter;
10
+ // Server manifest shape (GET /api/cli/packs/manifest) and its validation.
11
+ const files_1 = require("./files");
13
12
  // One path segment: starts alphanumeric, then alphanumerics, dot, dash or
14
13
  // underscore. Rejects "", ".", "..", slashes, backslashes, absolute paths,
15
14
  // drive letters, NUL and whitespace.
@@ -49,6 +48,46 @@ function fileList(value, field, safe) {
49
48
  return { name: entry.name, content: entry.content, ...(artifactId ? { artifactId } : {}) };
50
49
  });
51
50
  }
51
+ function bundleFiles(value, reserved) {
52
+ if (value === undefined || value === null)
53
+ return [];
54
+ if (!Array.isArray(value))
55
+ throw new ManifestError("manifest.files must be an array");
56
+ if (value.length > files_1.PACK_MAX_FILES)
57
+ throw new ManifestError(`manifest.files has more than ${files_1.PACK_MAX_FILES} entries`);
58
+ const seen = new Set();
59
+ const dirs = new Set();
60
+ let total = 0;
61
+ const out = value.map((item, index) => {
62
+ const f = item;
63
+ if (!f || typeof f !== "object")
64
+ throw new ManifestError(`manifest.files[${index}] is not an object`);
65
+ const problem = (0, files_1.packFilePathProblem)(f.path);
66
+ if (problem)
67
+ throw new ManifestError(`manifest.files[${index}] has an unsafe path ${JSON.stringify(f.path)}: ${problem}`);
68
+ const p = f.path;
69
+ if (typeof f.sha256 !== "string" || !files_1.SHA256_HEX.test(f.sha256))
70
+ throw new ManifestError(`manifest.files[${index}].sha256 is not a sha256`);
71
+ const size = Number(f.size);
72
+ if (!Number.isInteger(size) || size < 0 || size > files_1.PACK_FILE_MAX_BYTES)
73
+ throw new ManifestError(`manifest.files[${index}].size is out of range`);
74
+ total += size;
75
+ const key = p.toLowerCase();
76
+ if (seen.has(key) || reserved.has(key))
77
+ throw new ManifestError(`manifest.files has a clashing path: ${p}`);
78
+ seen.add(key);
79
+ const parts = key.split("/");
80
+ for (let n = 1; n < parts.length; n++)
81
+ dirs.add(parts.slice(0, n).join("/"));
82
+ return { path: p, sha256: f.sha256, size, ...(f.executable === true ? { executable: true } : {}) };
83
+ });
84
+ for (const key of seen)
85
+ if (dirs.has(key))
86
+ throw new ManifestError(`manifest.files has a path that is also a folder: ${key}`);
87
+ if (total > files_1.PACK_FILES_MAX_TOTAL_BYTES)
88
+ throw new ManifestError("manifest.files is too large");
89
+ return out;
90
+ }
52
91
  /** Validate a manifest from the server. Throws ManifestError on anything
53
92
  * unsafe or malformed — a partial install of a tampered pack is worse than
54
93
  * none. */
@@ -91,15 +130,24 @@ function parseManifest(input) {
91
130
  hooksJson = m.hooksJson;
92
131
  }
93
132
  }
133
+ const skills = fileList(m.skills, "skills", isSafeSkillName);
134
+ const agents = fileList(m.agents, "agents", isSafeSegment);
135
+ // Files may never land on a path Codiedev writes itself.
136
+ const reserved = new Set([
137
+ ...skills.map((s) => `skills/${s.name}/skill.md`.toLowerCase()),
138
+ ...agents.map((a) => `agents/${a.name}.md`.toLowerCase()),
139
+ ]);
140
+ const files = bundleFiles(m.files, reserved);
94
141
  return {
95
142
  slug: m.slug,
96
143
  name: typeof m.name === "string" && m.name ? m.name : m.slug,
97
144
  description: typeof m.description === "string" ? m.description : "",
98
145
  version,
99
146
  contentHash: m.contentHash,
100
- skills: fileList(m.skills, "skills", isSafeSkillName),
101
- agents: fileList(m.agents, "agents", isSafeSegment),
147
+ skills,
148
+ agents,
102
149
  hooksJson,
150
+ ...(files.length ? { files } : {}),
103
151
  ...(cleanLabel(m.channel, 16) ? { channel: cleanLabel(m.channel, 16) } : {}),
104
152
  ...(cleanLabel(m.icon, 16) ? { icon: cleanLabel(m.icon, 16) } : {}),
105
153
  };
@@ -9,6 +9,8 @@ export interface RegistryEntry {
9
9
  description?: string;
10
10
  channel?: string;
11
11
  icon?: string;
12
+ /** sha256 of each pack file (SIG-127), so unused cached blobs can be dropped. */
13
+ blobs?: string[];
12
14
  }
13
15
  export interface Registry {
14
16
  packs: Record<string, RegistryEntry>;
@@ -20,16 +22,21 @@ export declare const AGENT_LEVELS: Set<string>;
20
22
  /** Agent file for tools other than Claude Code: drop a `model:` line unless it
21
23
  * is a full `provider/model` id (levels and `inherit` are Claude aliases). */
22
24
  export declare function portableAgentContent(content: string): string;
23
- /** The full file set for a pack's plugin dir, relative path → content. */
24
- export declare function pluginFiles(manifest: PackManifest, options?: {
25
+ export interface PluginWriteOptions {
26
+ /** The copy for tools other than Claude Code (Codex, Copilot, Cursor, pi, Codie). */
25
27
  portable?: boolean;
26
- }): Record<string, string>;
28
+ /** Where pack file blobs are cached (required when the manifest has files). */
29
+ blobDir?: string;
30
+ /** Portable copies: the plugin dir's final path, substituted for ${CLAUDE_PLUGIN_ROOT}. */
31
+ pluginRoot?: string;
32
+ }
33
+ /** The full file set for a pack's plugin dir, relative path → content. Pack
34
+ * files (blobs) are copied separately by writePluginDir. */
35
+ export declare function pluginFiles(manifest: PackManifest, options?: PluginWriteOptions): Record<string, string>;
27
36
  /** Build the plugin dir in a sibling temp dir and swap it into place, so a
28
37
  * failed write never leaves a half-updated plugin and stale files (a removed
29
38
  * skill, hooks that were cleared) never linger. */
30
- export declare function writePluginDir(root: string, manifest: PackManifest, options?: {
31
- portable?: boolean;
32
- }): string;
39
+ export declare function writePluginDir(root: string, manifest: PackManifest, options?: PluginWriteOptions): string;
33
40
  export declare function readRegistry(root: string): Registry;
34
41
  export declare function writeRegistry(root: string, registry: Registry): void;
35
42
  export interface MarketplaceEntry {
@@ -48,6 +48,7 @@ exports.writeMarketplace = writeMarketplace;
48
48
  const fs = __importStar(require("fs"));
49
49
  const skillMarker_1 = require("../skillMarker");
50
50
  const path = __importStar(require("path"));
51
+ const files_1 = require("./files");
51
52
  const paths_1 = require("./paths");
52
53
  function pluginVersion(version) {
53
54
  return `${version}.0.0`;
@@ -75,8 +76,10 @@ function portableAgentContent(content) {
75
76
  return content;
76
77
  return fm[1] + kept.join("\n") + fm[3] + content.slice(fm[0].length);
77
78
  }
78
- /** The full file set for a pack's plugin dir, relative path → content. */
79
+ /** The full file set for a pack's plugin dir, relative path → content. Pack
80
+ * files (blobs) are copied separately by writePluginDir. */
79
81
  function pluginFiles(manifest, options = {}) {
82
+ const rewrite = (content) => (options.portable && options.pluginRoot ? (0, files_1.rewritePluginRoot)(content, options.pluginRoot) : content);
80
83
  const files = {};
81
84
  files[path.join(".claude-plugin", "plugin.json")] = JSON.stringify({
82
85
  name: manifest.slug,
@@ -85,10 +88,10 @@ function pluginFiles(manifest, options = {}) {
85
88
  }, null, 2) + "\n";
86
89
  for (const skill of manifest.skills) {
87
90
  // Identity marker so the skill's runs attribute to this exact artifact.
88
- files[path.join("skills", skill.name, "SKILL.md")] = (0, skillMarker_1.stampSkillMarker)(skill.content, skill.artifactId);
91
+ files[path.join("skills", skill.name, "SKILL.md")] = (0, skillMarker_1.stampSkillMarker)(rewrite(skill.content), skill.artifactId);
89
92
  }
90
93
  for (const agent of manifest.agents) {
91
- files[path.join("agents", `${agent.name}.md`)] = options.portable ? portableAgentContent(agent.content) : agent.content;
94
+ files[path.join("agents", `${agent.name}.md`)] = options.portable ? rewrite(portableAgentContent(agent.content)) : agent.content;
92
95
  }
93
96
  if (manifest.hooksJson) {
94
97
  files[path.join("hooks", "hooks.json")] = manifest.hooksJson;
@@ -113,12 +116,28 @@ function writePluginDir(root, manifest, options = {}) {
113
116
  const tmp = path.join(parent, `.${manifest.slug}.tmp-${stamp}`);
114
117
  const old = path.join(parent, `.${manifest.slug}.old-${stamp}`);
115
118
  try {
116
- for (const [rel, content] of Object.entries(pluginFiles(manifest, options))) {
119
+ const text = pluginFiles(manifest, { ...options, pluginRoot: options.pluginRoot ?? target });
120
+ for (const [rel, content] of Object.entries(text)) {
117
121
  const file = path.join(tmp, rel);
118
122
  assertInside(tmp, file);
119
123
  fs.mkdirSync(path.dirname(file), { recursive: true });
120
124
  fs.writeFileSync(file, content, "utf8");
121
125
  }
126
+ const written = new Set(Object.keys(text).map((rel) => rel.split(path.sep).join("/").toLowerCase()));
127
+ for (const f of manifest.files ?? []) {
128
+ const problem = (0, files_1.packFilePathProblem)(f.path);
129
+ if (problem)
130
+ throw new Error(`refusing pack file ${f.path}: ${problem}`);
131
+ if (written.has(f.path.toLowerCase()))
132
+ throw new Error(`pack file ${f.path} clashes with a file Codiedev writes`);
133
+ if (!options.blobDir)
134
+ throw new Error(`pack file ${f.path} wasn't downloaded`);
135
+ const file = path.join(tmp, ...f.path.split("/"));
136
+ assertInside(tmp, file);
137
+ fs.mkdirSync(path.dirname(file), { recursive: true });
138
+ fs.copyFileSync((0, files_1.blobPath)(options.blobDir, f.sha256), file);
139
+ fs.chmodSync(file, f.executable ? 0o755 : 0o644);
140
+ }
122
141
  if (fs.existsSync(target))
123
142
  fs.renameSync(target, old);
124
143
  fs.renameSync(tmp, target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.84",
3
+ "version": "0.9.85",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",