codiedev 0.9.84 → 0.9.86

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. */
@@ -20,8 +21,14 @@ export interface PackArgs {
20
21
  out?: string;
21
22
  git?: boolean;
22
23
  manifest?: boolean;
24
+ /** publish / apply --publish: which channel (default stable). */
25
+ channel?: "stable" | "beta" | "alpha";
26
+ /** apply: publish the draft afterwards. */
27
+ publish?: boolean;
23
28
  }
24
29
  export declare function parsePackArgs(args: string[]): PackArgs;
30
+ /** Download a pack's files (SIG-127) into the blob cache, verified by sha256. */
31
+ export declare function downloadPackFiles(config: CodiedevConfig, slug: string, files: PackBundleFile[], dir: string): Promise<number>;
25
32
  export declare function runList(config: CodiedevConfig, json: boolean, overrides?: Partial<InstallOptions>): Promise<void>;
26
33
  export interface InstallRun {
27
34
  installed: PackInstallResult[];
@@ -45,10 +52,19 @@ export declare function commitExport(dir: string, message: string): string;
45
52
  /** Write a CI-committable copy of the pack: `<out>/plugins/<slug>/…` plus
46
53
  * `<out>/.claude-plugin/marketplace.json` (existing entries for other packs
47
54
  * in the same folder are kept). */
48
- export declare function exportPluginFolder(out: string, manifest: PackManifest): string;
55
+ export declare function exportPluginFolder(out: string, manifest: PackManifest, blobDir?: string): string;
49
56
  export declare function runExport(config: CodiedevConfig, args: PackArgs): Promise<{
50
57
  written: string;
51
58
  commit?: string;
52
59
  }>;
53
60
  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";
61
+ export interface PublishResult {
62
+ slug: string;
63
+ channel: string;
64
+ version: number;
65
+ unchanged: boolean;
66
+ }
67
+ /** `codiedev pack publish <slug> [--channel stable|beta|alpha]`: snapshot the
68
+ * draft onto a channel. Re-running with no changes publishes nothing. */
69
+ export declare function runPublish(config: CodiedevConfig, args: PackArgs): Promise<PublishResult>;
70
+ 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> [--publish] [--channel stable|beta|alpha] [--dry-run]\n Create or update a pack from a pack.md\n (--publish then publishes it, see below)\n codiedev pack publish <slug> [--channel stable|beta|alpha]\n Publish the pack's draft to a channel (default\n stable). Nothing happens when it hasn't changed.\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 Keep a pack up to date from its repo (GitLab; on merges to main). Use a\n personal Codiedev token of a pack owner (portal \u2192 Integrations \u2192 CLI tokens) as a\n masked CODIEDEV_TOKEN: pack CI keys can only install, not publish.\n pack-release:\n image: node:22\n rules: [{ if: '$CI_COMMIT_BRANCH == \"main\"' }]\n script:\n - npm i -g codiedev\n - codiedev pack apply packs/platform-starter.pack.md --publish\n This ships the pack's files, agents, hooks and skill list. Edits to a skill's\n own SKILL.md still go to the library with `codiedev push`.\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;
@@ -45,6 +46,7 @@ exports.commitExport = commitExport;
45
46
  exports.exportPluginFolder = exportPluginFolder;
46
47
  exports.runExport = runExport;
47
48
  exports.runApply = runApply;
49
+ exports.runPublish = runPublish;
48
50
  const install_1 = require("../packs/install");
49
51
  const harnesses_1 = require("../packs/harnesses");
50
52
  const manifest_1 = require("../packs/manifest");
@@ -59,6 +61,7 @@ const harnesses_2 = require("../packs/harnesses");
59
61
  const os = __importStar(require("os"));
60
62
  const utils_1 = require("../utils");
61
63
  const shared_1 = require("./shared");
64
+ const files_1 = require("../packs/files");
62
65
  // --- CI (pack-scoped `cdvci_` keys) ------------------------------------------
63
66
  exports.DEFAULT_BACKEND_URL = "https://cli.codiedev.com";
64
67
  /** CODIEDEV_TOKEN alone is enough: no ~/.codiedev/config.json needed (CI).
@@ -101,7 +104,27 @@ const USAGE = `Usage:
101
104
  .claude-plugin/marketplace.json (commit it for CI).
102
105
  --git inits the folder and commits; --manifest writes
103
106
  an editable <slug>.pack.md instead
104
- codiedev pack apply <pack.md> Create or update a pack from a pack.md
107
+ codiedev pack apply <pack.md> [--publish] [--channel stable|beta|alpha] [--dry-run]
108
+ Create or update a pack from a pack.md
109
+ (--publish then publishes it, see below)
110
+ codiedev pack publish <slug> [--channel stable|beta|alpha]
111
+ Publish the pack's draft to a channel (default
112
+ stable). Nothing happens when it hasn't changed.
113
+
114
+ Files (scripts, PDFs, a runner folder): list them under "## Files" in pack.md,
115
+ one "- <path in pack> <- <local path>" per line (local paths are relative to
116
+ the pack.md; "." is the pack root):
117
+
118
+ ## Files
119
+
120
+ - runner <- ~/.claude/runner
121
+ - skills/healthcare-scan <- ~/.claude/skills/healthcare-scan
122
+
123
+ \`pack apply\` uploads them (admins and team managers; 15MB per file, 50MB per
124
+ pack; caches, .git and hidden files are skipped). Without a "## Files"
125
+ section the pack's files are left alone. In skills, point at files relative to
126
+ the skill's folder, or use \${CLAUDE_PLUGIN_ROOT}/runner/...: Codiedev swaps in the
127
+ real path for Codex, Copilot, Cursor, pi and Codie.
105
128
 
106
129
  Cursor: packs are copied to ~/.cursor/plugins/local/codiedev-<slug>/. A Cursor
107
130
  team admin can disable "user local plugin imports"; then Cursor ignores them.
@@ -138,8 +161,25 @@ got the pack. Skills from a pack are /<pack-slug>:<skill> in Claude Code.
138
161
  env:
139
162
  ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
140
163
 
164
+ Keep a pack up to date from its repo (GitLab; on merges to main). Use a
165
+ personal Codiedev token of a pack owner (portal → Integrations → CLI tokens) as a
166
+ masked CODIEDEV_TOKEN: pack CI keys can only install, not publish.
167
+ pack-release:
168
+ image: node:22
169
+ rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }]
170
+ script:
171
+ - npm i -g codiedev
172
+ - codiedev pack apply packs/platform-starter.pack.md --publish
173
+ This ships the pack's files, agents, hooks and skill list. Edits to a skill's
174
+ own SKILL.md still go to the library with \`codiedev push\`.
175
+
141
176
  Or vendor the plugin into the repo instead of installing at build time:
142
177
  codiedev pack export <slug> --out .codiedev/packs`;
178
+ function parseChannel(value) {
179
+ if (value === "stable" || value === "beta" || value === "alpha")
180
+ return value;
181
+ throw new Error(`--channel must be stable, beta or alpha (got ${value})`);
182
+ }
143
183
  function parsePackArgs(args) {
144
184
  const out = { dryRun: false, json: false };
145
185
  const positional = [];
@@ -157,6 +197,15 @@ function parsePackArgs(args) {
157
197
  out.git = true;
158
198
  else if (a === "--manifest")
159
199
  out.manifest = true;
200
+ else if (a === "--publish")
201
+ out.publish = true;
202
+ else if (a === "--channel") {
203
+ if (i + 1 >= args.length)
204
+ throw new Error("--channel needs stable, beta or alpha");
205
+ out.channel = parseChannel(args[++i]);
206
+ }
207
+ else if (a.startsWith("--channel="))
208
+ out.channel = parseChannel(a.slice("--channel=".length));
160
209
  else if (a === "--out" || a === "-o") {
161
210
  if (i + 1 >= args.length)
162
211
  throw new Error("--out needs a directory");
@@ -188,6 +237,16 @@ async function fetchManifest(config, slug) {
188
237
  const raw = await (0, shared_1.apiRequest)("GET", "/api/cli/packs/manifest", { config, query: { slug } });
189
238
  return (0, manifest_1.parseManifest)(raw);
190
239
  }
240
+ /** Download a pack's files (SIG-127) into the blob cache, verified by sha256. */
241
+ async function downloadPackFiles(config, slug, files, dir) {
242
+ if (!files.length)
243
+ return 0;
244
+ 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 })
245
+ .catch((error) => {
246
+ throw new Error(`couldn't download ${file.path} (${error.message})`);
247
+ }));
248
+ return downloaded;
249
+ }
191
250
  async function runList(config, json, overrides = {}) {
192
251
  const packs = await listPacks(config);
193
252
  const registry = (0, store_1.readRegistry)((0, paths_1.packsRoot)(overrides.env, overrides.home));
@@ -206,6 +265,7 @@ async function runList(config, json, overrides = {}) {
206
265
  `${p.itemCount ?? 0} skill${p.itemCount === 1 ? "" : "s"}`,
207
266
  `${p.agentCount ?? 0} agent${p.agentCount === 1 ? "" : "s"}`,
208
267
  ...(p.hasHooks ? ["hooks"] : []),
268
+ ...(p.fileCount ? [`${p.fileCount} file${p.fileCount === 1 ? "" : "s"}`] : []),
209
269
  ].join(", ");
210
270
  const channel = (0, manifest_2.cleanLabel)(p.channel, 16);
211
271
  console.log(`${p.slug} v${p.version}${channel ? ` [${channel}]` : ""} ${(0, manifest_2.displayName)(p)} (${counts}) — ${state}`);
@@ -244,8 +304,27 @@ async function runInstall(config, args, overrides = {}) {
244
304
  // A listed pack that 404s a moment later (race with an unassign) is skipped.
245
305
  }
246
306
  }
247
- let installed = manifests.length
248
- ? (0, install_1.installPacks)({ ...overrides, manifests, harnesses, dryRun: args.dryRun })
307
+ // Pack files first: a pack whose files can't be fetched keeps its previous
308
+ // install untouched rather than shipping half its folders.
309
+ const blobDir = overrides.blobDir ?? (0, files_1.blobsDir)(root);
310
+ const ready = [];
311
+ let downloadFailed = false;
312
+ for (const manifest of manifests) {
313
+ if (args.dryRun || !manifest.files?.length) {
314
+ ready.push(manifest);
315
+ continue;
316
+ }
317
+ try {
318
+ await downloadPackFiles(config, manifest.slug, manifest.files, blobDir);
319
+ ready.push(manifest);
320
+ }
321
+ catch (error) {
322
+ downloadFailed = true;
323
+ console.error(`✗ ${manifest.slug}: ${error.message}`);
324
+ }
325
+ }
326
+ let installed = ready.length
327
+ ? (0, install_1.installPacks)({ ...overrides, manifests: ready, harnesses, dryRun: args.dryRun, blobDir })
249
328
  : [];
250
329
  let removed = [];
251
330
  if (!args.dryRun && !ci) {
@@ -293,7 +372,7 @@ async function runInstall(config, args, overrides = {}) {
293
372
  const ciFailed = ci && !args.dryRun && (installed.length === 0 || installed.some((p) => p.results.some((r) => r.status !== "ok")));
294
373
  if (ci && !args.dryRun && installed.length === 0)
295
374
  console.error("No pack is visible to this key.");
296
- if (failed || ciFailed || (args.slug && removed.length + revoked.length > 0))
375
+ if (failed || ciFailed || downloadFailed || (args.slug && removed.length + revoked.length > 0))
297
376
  process.exitCode = 1;
298
377
  return run;
299
378
  }
@@ -408,7 +487,7 @@ async function runPack(args) {
408
487
  console.error(USAGE);
409
488
  process.exit(1);
410
489
  }
411
- if (!parsed.sub || !["list", "ls", "install", "status", "export", "apply"].includes(parsed.sub)) {
490
+ if (!parsed.sub || !["list", "ls", "install", "status", "export", "apply", "publish"].includes(parsed.sub)) {
412
491
  console.error(USAGE);
413
492
  process.exit(parsed.sub ? 1 : 0);
414
493
  }
@@ -428,6 +507,8 @@ async function runPack(args) {
428
507
  await runExport(config, parsed);
429
508
  else if (parsed.sub === "apply")
430
509
  await runApply(config, parsed);
510
+ else if (parsed.sub === "publish")
511
+ await runPublish(config, parsed);
431
512
  else
432
513
  await runStatus(config, parsed.json, {}, { prune: !parsed.noPrune });
433
514
  });
@@ -494,9 +575,9 @@ function readMarketplaceEntries(dir) {
494
575
  /** Write a CI-committable copy of the pack: `<out>/plugins/<slug>/…` plus
495
576
  * `<out>/.claude-plugin/marketplace.json` (existing entries for other packs
496
577
  * in the same folder are kept). */
497
- function exportPluginFolder(out, manifest) {
578
+ function exportPluginFolder(out, manifest, blobDir) {
498
579
  fs.mkdirSync(out, { recursive: true });
499
- const dir = (0, store_1.writePluginDir)(out, manifest);
580
+ const dir = (0, store_1.writePluginDir)(out, manifest, { blobDir });
500
581
  const entries = readMarketplaceEntries(out).filter((e) => e.slug !== manifest.slug);
501
582
  entries.push({ slug: manifest.slug, description: manifest.description || manifest.name, version: manifest.version });
502
583
  (0, store_1.writeMarketplace)(out, entries);
@@ -524,7 +605,24 @@ async function runExport(config, args) {
524
605
  fs.mkdirSync(out, { recursive: true });
525
606
  // <slug>.pack.md so several packs can share one folder.
526
607
  written = path.join(out, `${args.slug}.pack.md`);
527
- (0, store_1.writeFileAtomic)(written, res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
608
+ let markdown = res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`;
609
+ // Pack files go beside it in <slug>.files/, mapped back with `## Files`
610
+ // so `pack apply` round-trips them.
611
+ const files = (0, manifest_1.parseManifest)({ slug: args.slug, version: 0, contentHash: "-", files: res.files }).files ?? [];
612
+ if (files.length) {
613
+ const blobDir = (0, files_1.blobsDir)((0, paths_1.packsRoot)());
614
+ await downloadPackFiles(config, args.slug, files, blobDir);
615
+ const filesDir = path.join(out, `${args.slug}.files`);
616
+ fs.rmSync(filesDir, { recursive: true, force: true });
617
+ for (const f of files) {
618
+ const target = path.join(filesDir, ...f.path.split("/"));
619
+ fs.mkdirSync(path.dirname(target), { recursive: true });
620
+ fs.copyFileSync((0, files_1.blobPath)(blobDir, f.sha256), target);
621
+ fs.chmodSync(target, f.executable ? 0o755 : 0o644);
622
+ }
623
+ markdown = `${markdown.trimEnd()}\n\n## Files\n\n- . <- ./${args.slug}.files\n`;
624
+ }
625
+ (0, store_1.writeFileAtomic)(written, markdown);
528
626
  message = `codiedev pack export ${args.slug} (${args.slug}.pack.md)`;
529
627
  }
530
628
  else {
@@ -539,7 +637,9 @@ async function runExport(config, args) {
539
637
  throw new Error(`Refusing to export ${args.slug}: ${error.message}`);
540
638
  throw error;
541
639
  }
542
- written = exportPluginFolder(out, manifest);
640
+ const blobDir = (0, files_1.blobsDir)((0, paths_1.packsRoot)());
641
+ await downloadPackFiles(config, manifest.slug, manifest.files ?? [], blobDir);
642
+ written = exportPluginFolder(out, manifest, blobDir);
543
643
  message = `codiedev pack export ${manifest.slug} v${manifest.version}`;
544
644
  }
545
645
  const commit = args.git ? commitExport(out, message) : undefined;
@@ -567,13 +667,31 @@ async function runApply(config, args) {
567
667
  }
568
668
  if (!markdown.trim())
569
669
  throw new Error(`${file} is empty`);
670
+ // `## Files` (SIG-127): local folders/files to ship in the plugin dir.
671
+ // Absent ⇒ the pack's files are left as they are.
672
+ const entries = (0, files_1.parseFilesSection)(markdown);
673
+ const local = entries ? (0, files_1.collectLocalFiles)(entries, path.dirname(file)) : null;
674
+ for (const note of local?.notes ?? [])
675
+ console.log(` · ${note}`);
570
676
  if (args.dryRun) {
571
- console.log(`Dry run — would apply ${file} (${Buffer.byteLength(markdown)} bytes).`);
677
+ const extra = local ? `, ${local.files.length} file${local.files.length === 1 ? "" : "s"} (${formatBytes(local.files.reduce((n, f) => n + f.size, 0))})` : "";
678
+ console.log(`Dry run — would apply ${file} (${Buffer.byteLength(markdown)} bytes${extra}).`);
679
+ for (const f of local?.files ?? [])
680
+ console.log(` ${f.path}`);
572
681
  return {};
573
682
  }
683
+ let uploaded = 0;
684
+ if (local?.files.length)
685
+ uploaded = await uploadPackFiles(config, local.files);
574
686
  let res;
575
687
  try {
576
- res = await (0, shared_1.apiRequest)("POST", "/api/cli/packs/apply", { config, body: { markdown } });
688
+ res = await (0, shared_1.apiRequest)("POST", "/api/cli/packs/apply", {
689
+ config,
690
+ body: {
691
+ markdown,
692
+ ...(local ? { files: local.files.map((f) => ({ path: f.path, sha256: f.sha256, ...(f.executable ? { executable: true } : {}) })) } : {}),
693
+ },
694
+ });
577
695
  }
578
696
  catch (error) {
579
697
  // The server reports pack.md problems with line numbers; show them as-is.
@@ -590,6 +708,8 @@ async function runApply(config, args) {
590
708
  const verb = res.created === true ? "Created" : res.changed === false ? "No changes to" : "Updated";
591
709
  const version = typeof res.version === "number" ? ` v${res.version}` : "";
592
710
  console.log(`✓ ${verb} ${slug}${version} from ${path.basename(file)}`);
711
+ if (local)
712
+ console.log(` files: ${local.files.length} (${uploaded} uploaded, ${local.files.length - uploaded} already on Codiedev)`);
593
713
  if (Array.isArray(res.warnings)) {
594
714
  for (const w of res.warnings) {
595
715
  const text = typeof w === "string" ? w : typeof w?.message === "string" ? `${w.agent ? `${w.agent}: ` : ""}${w.message}` : JSON.stringify(w);
@@ -597,6 +717,81 @@ async function runApply(config, args) {
597
717
  }
598
718
  }
599
719
  }
720
+ if (args.publish && typeof res.slug === "string") {
721
+ const published = await runPublish(config, { ...args, slug: res.slug, json: false });
722
+ if (args.json)
723
+ return { ...res, published };
724
+ }
600
725
  return res;
601
726
  }
727
+ /** `codiedev pack publish <slug> [--channel stable|beta|alpha]`: snapshot the
728
+ * draft onto a channel. Re-running with no changes publishes nothing. */
729
+ async function runPublish(config, args) {
730
+ if (!args.slug)
731
+ throw new Error("Usage: codiedev pack publish <slug> [--channel stable|beta|alpha]");
732
+ if (!(0, manifest_2.isSafeSegment)(args.slug))
733
+ throw new Error(`Not a pack slug: ${args.slug}`);
734
+ const channel = args.channel ?? "stable";
735
+ if (args.dryRun) {
736
+ console.log(`Dry run — would publish ${args.slug}'s draft to ${channel}.`);
737
+ return { slug: args.slug, channel, version: 0, unchanged: true };
738
+ }
739
+ let res;
740
+ try {
741
+ res = await (0, shared_1.apiRequest)("POST", "/api/cli/packs/publish", { config, body: { slug: args.slug, channel } });
742
+ }
743
+ catch (error) {
744
+ const e = error;
745
+ if (e.status === 404)
746
+ throw new Error(`Pack not found: ${args.slug}`);
747
+ if (e.status === 403)
748
+ throw new Error(`You can't publish ${args.slug} (only its owners and admins can)`);
749
+ throw new Error(e.message);
750
+ }
751
+ const out = {
752
+ slug: typeof res.slug === "string" ? res.slug : args.slug,
753
+ channel,
754
+ version: typeof res.version === "number" ? res.version : 0,
755
+ unchanged: res.unchanged === true,
756
+ };
757
+ if (args.json)
758
+ console.log(JSON.stringify(out));
759
+ else if (out.unchanged)
760
+ console.log(`· ${out.slug} v${out.version} is already on ${channel} (no changes to publish)`);
761
+ else
762
+ console.log(`✓ Published ${out.slug} v${out.version} to ${channel}. Teams on ${channel} get it at their next sync.`);
763
+ return out;
764
+ }
765
+ function formatBytes(n) {
766
+ return n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)}MB` : n >= 1024 ? `${Math.round(n / 1024)}KB` : `${n}B`;
767
+ }
768
+ /** Upload the blobs Codiedev doesn't have yet. Returns how many were sent. */
769
+ async function uploadPackFiles(config, files) {
770
+ const unique = [...new Map(files.map((f) => [f.sha256, f])).values()];
771
+ let known;
772
+ try {
773
+ known = (await (0, shared_1.apiRequest)("POST", "/api/cli/packs/files/known", { config, body: { sha256s: unique.map((f) => f.sha256) } })).known ?? [];
774
+ }
775
+ catch (error) {
776
+ const e = error;
777
+ if (e.status === 403)
778
+ throw new Error(`Only admins and team managers can add files to a pack (${e.message})`);
779
+ throw error;
780
+ }
781
+ const have = new Set(known);
782
+ let sent = 0;
783
+ for (const f of unique) {
784
+ if (have.has(f.sha256))
785
+ continue;
786
+ const body = fs.readFileSync(f.abs);
787
+ try {
788
+ await (0, shared_1.apiRequestBytes)("POST", "/api/cli/packs/files/upload", { config, body, query: { sha256: f.sha256 } });
789
+ }
790
+ catch (error) {
791
+ throw new Error(`couldn't upload ${f.path} (${error.message})`);
792
+ }
793
+ sent += 1;
794
+ }
795
+ return sent;
796
+ }
602
797
  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.86",
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",