drafted 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/drafted.mjs CHANGED
@@ -1249,6 +1249,63 @@ async function unresolvableReason(res, org) {
1249
1249
  return why;
1250
1250
  }
1251
1251
 
1252
+ function unquoteYaml(s) {
1253
+ s = s.trim();
1254
+ if (s[0] === '"' && s[s.length - 1] === '"') { try { return JSON.parse(s); } catch { return s.slice(1, -1); } }
1255
+ if (s[0] === "'" && s[s.length - 1] === "'") return s.slice(1, -1);
1256
+ return s;
1257
+ }
1258
+
1259
+ // Parse the `setup:` block from a SKILL.md YAML frontmatter. Handles an inline
1260
+ // scalar (`setup: "cmd"` / `setup: cmd`), an inline JSON array (`setup: ["a","b"]`),
1261
+ // and a block list (`setup:` then indented `- cmd` lines). Returns string[].
1262
+ function parseSetupFromFrontmatter(md) {
1263
+ const fm = md.match(/^---\n([\s\S]*?)\n---/);
1264
+ if (!fm) return [];
1265
+ const lines = fm[1].split('\n');
1266
+ const out = [];
1267
+ for (let i = 0; i < lines.length; i++) {
1268
+ const m = lines[i].match(/^setup:(.*)$/);
1269
+ if (!m) continue;
1270
+ const rest = m[1].trim();
1271
+ if (rest) {
1272
+ if (rest.startsWith('[')) {
1273
+ try { const arr = JSON.parse(rest); if (Array.isArray(arr)) out.push(...arr.map(String)); } catch { /* malformed inline array */ }
1274
+ } else {
1275
+ out.push(unquoteYaml(rest));
1276
+ }
1277
+ } else {
1278
+ for (let j = i + 1; j < lines.length; j++) {
1279
+ const item = lines[j].match(/^\s+-\s+(.*)$/);
1280
+ if (item) out.push(unquoteYaml(item[1].trim()));
1281
+ else if (lines[j].trim() === '') continue;
1282
+ else break;
1283
+ }
1284
+ }
1285
+ break; // only the first setup: key
1286
+ }
1287
+ return out.filter((s) => s && s.trim());
1288
+ }
1289
+
1290
+ // Run a source-only skill's setup commands in <dir>, in order. Offline-safe: a
1291
+ // failure is reported and the loop stops, but the source in <dir> is never deleted
1292
+ // (the caller decides whether to keep or discard). Trusted-by-pinning — adding a
1293
+ // skill is the consent, same as cloning a repo and running its install; no per-cmd gate.
1294
+ function runSkillSetup(dir) {
1295
+ const skillMd = join(dir, 'SKILL.md');
1296
+ if (!existsSync(skillMd)) return { ok: false, skipped: true, reason: 'no SKILL.md' };
1297
+ let setup;
1298
+ try { setup = parseSetupFromFrontmatter(readFileSync(skillMd, 'utf8')); }
1299
+ catch (e) { return { ok: false, error: `parse SKILL.md: ${(e && e.message) || e}` }; }
1300
+ if (!setup.length) return { ok: true, skipped: true, ran: [] };
1301
+ const ran = [];
1302
+ for (const cmd of setup) {
1303
+ try { execSync(cmd, { cwd: dir, stdio: 'inherit' }); ran.push(cmd); }
1304
+ catch (e) { return { ok: false, failed: cmd, ran, error: String((e && e.message) || e) }; }
1305
+ }
1306
+ return { ok: true, ran };
1307
+ }
1308
+
1252
1309
  async function syncOneSkill(ref, outDir, org) {
1253
1310
  const at = ref.lastIndexOf('@');
1254
1311
  const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
@@ -1295,6 +1352,7 @@ skillCmd
1295
1352
  .option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
1296
1353
  .requiredOption('--out <dir>', 'output directory for bundles')
1297
1354
  .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
1355
+ .option('--no-setup', 'do not run each skill\'s setup after materializing (default: run setup so source-only skills are runnable)')
1298
1356
  .option('--format <fmt>', 'output format: json or text', 'text')
1299
1357
  .action(async (opts) => {
1300
1358
  requireLogin();
@@ -1305,6 +1363,16 @@ skillCmd
1305
1363
  for (const ref of refs) {
1306
1364
  try {
1307
1365
  const r = await syncOneSkill(ref, opts.out, opts.org);
1366
+ // Run setup by default so a synced source-only skill builds and is
1367
+ // immediately runnable. Hash was computed over the source BEFORE setup, so
1368
+ // node_modules/dist from setup never affect it. Offline-safe: a setup
1369
+ // failure is reported but the materialized source stays put (sync is still
1370
+ // `ok` — the bytes are there, just not built).
1371
+ if (r.status === 'ok' && opts.setup !== false) {
1372
+ const s = runSkillSetup(join(opts.out, r.slug));
1373
+ r.setup = s.skipped ? 'none' : (s.ok ? 'ok' : 'failed');
1374
+ if (!s.ok && !s.skipped) r.setupError = s.failed ? `${s.failed}: ${s.error}` : s.error;
1375
+ }
1308
1376
  results.push(r);
1309
1377
  if (r.status !== 'ok') allOk = false;
1310
1378
  } catch (err) {
@@ -1316,7 +1384,8 @@ skillCmd
1316
1384
  console.log(JSON.stringify(results));
1317
1385
  } else {
1318
1386
  for (const r of results) {
1319
- console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}${r.error ? '\t' + r.error : ''}`);
1387
+ const setupCol = r.setup ? `\tsetup:${r.setup}` : '';
1388
+ console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}${setupCol}${r.error ? '\t' + r.error : ''}`);
1320
1389
  }
1321
1390
  }
1322
1391
  process.exit(allOk ? 0 : 1);
@@ -1362,32 +1431,61 @@ skillCmd
1362
1431
 
1363
1432
  skillCmd
1364
1433
  .command('update')
1365
- .description('Update a Drafted skill (--id or --slug) from stdin JSON {description?,content?,triggerPatterns?,tags?}')
1434
+ .description('Update a Drafted skill (--id or --slug) from stdin JSON {name?,description?,content?,triggerPatterns?,tags?,setup?}; auto-forks into your org if the target is read-only (global/other-org)')
1366
1435
  .option('--id <id>', 'skill id')
1367
1436
  .option('--slug <slug>', 'skill slug (resolved to id)')
1437
+ .option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
1368
1438
  .option('--format <fmt>', 'output format: json or text', 'text')
1369
1439
  .action(async (opts) => {
1370
1440
  requireLogin();
1371
1441
  let p;
1372
1442
  try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
1373
1443
  const server = getServerUrl().replace(/\/$/, '');
1444
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1374
1445
  let id = opts.id;
1446
+ let slug = opts.slug;
1375
1447
  if (!id && opts.slug) {
1376
- const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`);
1448
+ const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
1377
1449
  if (!lookup.ok) { emitSkillResult(opts.format, { status: 'unresolvable', error: `slug ${opts.slug}: HTTP ${lookup.status}` }); process.exit(1); }
1378
- id = (await lookup.json().catch(() => ({}))).id;
1450
+ const lj = await lookup.json().catch(() => ({}));
1451
+ id = lj.id; slug = lj.slug;
1379
1452
  }
1380
1453
  if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
1381
- const res = await authFetch(`${server}/api/skills/${id}`, {
1382
- method: 'PUT', headers: { 'Content-Type': 'application/json' },
1383
- body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup }),
1384
- });
1454
+ const payload = JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup });
1455
+ const putSkill = (sid) => authFetch(`${server}/api/skills/${sid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...orgHeaders }, body: payload });
1456
+
1457
+ const res = await putSkill(id);
1385
1458
  const data = await res.json().catch(() => ({}));
1386
- if (!res.ok) {
1387
- emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1388
- process.exit(1);
1459
+ if (res.ok) {
1460
+ emitSkillResult(opts.format, { status: 'ok', forked: false, slug: data.slug, id: data.id, orgId: data.orgId, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1461
+ return;
1389
1462
  }
1390
- emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1463
+ // Auto-fork: the target is read-only (global/system or another org). Fork it into
1464
+ // our org, then write the copy — a single `update` transparently forks-then-writes.
1465
+ // The raw PUT stayed strict (403 skill_read_only); auto-fork is a tool convenience.
1466
+ if (res.status === 403 && data.code === 'skill_read_only') {
1467
+ const forkRes = await authFetch(`${server}/api/skills/${id}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders } });
1468
+ let forkId;
1469
+ if (forkRes.ok) {
1470
+ forkId = (await forkRes.json().catch(() => ({}))).id;
1471
+ } else if (forkRes.status === 409) {
1472
+ // Org already owns a copy of this slug — update that one (still "forked").
1473
+ const ownSlug = data.slug || slug;
1474
+ const own = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(ownSlug)}`, { headers: orgHeaders });
1475
+ if (!own.ok) { emitSkillResult(opts.format, { status: 'error', error: `resolve existing fork ${ownSlug}: HTTP ${own.status}` }); process.exit(1); }
1476
+ forkId = (await own.json().catch(() => ({}))).id;
1477
+ } else {
1478
+ const fe = await forkRes.json().catch(() => ({}));
1479
+ emitSkillResult(opts.format, { status: 'error', error: `fork failed: ${fe.error || 'HTTP ' + forkRes.status}` }); process.exit(1);
1480
+ }
1481
+ const up = await putSkill(forkId);
1482
+ const ud = await up.json().catch(() => ({}));
1483
+ if (!up.ok) { emitSkillResult(opts.format, { status: 'error', error: `update fork failed: ${ud.error || 'HTTP ' + up.status}` }); process.exit(1); }
1484
+ emitSkillResult(opts.format, { status: 'ok', forked: true, slug: ud.slug, id: ud.id, orgId: ud.orgId, hash: ud.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(ud) }), forkedFrom: ud.forkedFrom });
1485
+ return;
1486
+ }
1487
+ emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1488
+ process.exit(1);
1391
1489
  });
1392
1490
 
1393
1491
  // check reports the current canonical hash for refs WITHOUT materializing — the
@@ -1437,6 +1535,25 @@ skillCmd
1437
1535
  else for (const r of results) console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}`);
1438
1536
  });
1439
1537
 
1538
+ skillCmd
1539
+ .command('setup')
1540
+ .description('Run a source-only skill\'s setup commands (from <dir>/SKILL.md frontmatter) in <dir> — local, offline-safe')
1541
+ .requiredOption('--dir <dir>', 'skill directory containing SKILL.md')
1542
+ .option('--format <fmt>', 'output format: json or text', 'text')
1543
+ .action((opts) => {
1544
+ const r = runSkillSetup(resolve(opts.dir));
1545
+ if (opts.format === 'json') {
1546
+ console.log(JSON.stringify(r));
1547
+ } else if (r.skipped) {
1548
+ console.log(`skipped\t${r.reason || 'no setup'}`);
1549
+ } else if (r.ok) {
1550
+ console.log(`ok\tran ${r.ran.length}`);
1551
+ } else {
1552
+ console.log(`failed\t${r.failed || ''}\t${r.error || ''}`);
1553
+ }
1554
+ process.exit(r.ok ? 0 : 1);
1555
+ });
1556
+
1440
1557
  skillCmd
1441
1558
  .command('remove')
1442
1559
  .description('Delete a Drafted skill by --id (used to clean up throwaway skills)')
package/mcp/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { execFile } from 'child_process';
12
12
  import { createHash } from 'node:crypto';
13
- import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, appendFileSync } from 'fs';
13
+ import { readFileSync, existsSync, realpathSync, writeFileSync, mkdirSync, unlinkSync, appendFileSync, readdirSync, statSync } from 'fs';
14
14
  import { join, dirname, basename, extname, resolve } from 'path';
15
15
  import { homedir, platform, release as osRelease, arch as osArch } from 'os';
16
16
  import { fileURLToPath } from 'url';
@@ -719,13 +719,13 @@ const MIME_MAP = {
719
719
  };
720
720
  function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/octet-stream'; }
721
721
 
722
- async function api(method, path, body, _retried) {
722
+ async function api(method, path, body, extraHeaders = {}, _retried = false) {
723
723
  await ensureSession();
724
724
  const pid = getState().projectId;
725
725
  const sep = path.includes('?') ? '&' : '?';
726
726
  const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
727
727
  const url = `${getServerUrl()}${scopedPath}`;
728
- const headers = { ...getAuthHeaders() };
728
+ const headers = { ...getAuthHeaders(), ...extraHeaders };
729
729
  const opts = { method, headers };
730
730
 
731
731
  if (body !== undefined) {
@@ -743,7 +743,7 @@ async function api(method, path, body, _retried) {
743
743
  getState().sessionId = null;
744
744
  const approved = await consumePendingDeviceCode();
745
745
  if (!approved) await cloneSession();
746
- return api(method, path, body, true);
746
+ return api(method, path, body, extraHeaders, true);
747
747
  }
748
748
 
749
749
  let data;
@@ -783,7 +783,12 @@ async function api(method, path, body, _retried) {
783
783
  `Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
784
784
  );
785
785
  }
786
- throw new Error(msg);
786
+ // Attach the structured body so callers can key off machine-readable fields
787
+ // (e.g. `code:"skill_read_only"` to drive auto-fork) instead of matching prose.
788
+ const apiErr = new Error(msg);
789
+ apiErr.status = res.status;
790
+ if (data && typeof data === 'object') { apiErr.code = data.code; apiErr.body = data; }
791
+ throw apiErr;
787
792
  }
788
793
  return data;
789
794
  }
@@ -2612,10 +2617,45 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
2612
2617
 
2613
2618
  // ── Skill library tool ───────────────────────────────────────────
2614
2619
 
2615
- tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. Dispatch by `action`: search/load/list for discovery; add/update/remove for org skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory.', {
2620
+ // Walk a local source tree for `skill action=push` with dir, pre-filtering heavy
2621
+ // dirs + any .skillignore as a convenience so we don't ship a node_modules tree
2622
+ // over the wire. The SERVER re-runs the authoritative hygiene pipeline. Mirrors the
2623
+ // CLI's collectSkillTree.
2624
+ function collectSkillTreeForPush(dir) {
2625
+ const root = resolve(dir);
2626
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
2627
+ const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
2628
+ const NUL = String.fromCharCode(0);
2629
+ let ignore = () => false;
2630
+ const ign = join(root, '.skillignore');
2631
+ if (existsSync(ign)) {
2632
+ const pats = readFileSync(ign, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
2633
+ ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
2634
+ }
2635
+ const out = [];
2636
+ const walk = (abs, rel) => {
2637
+ for (const name of readdirSync(abs)) {
2638
+ const childAbs = join(abs, name);
2639
+ const childRel = rel ? `${rel}/${name}` : name;
2640
+ const st = statSync(childAbs);
2641
+ if (st.isDirectory()) { if (SKIP.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
2642
+ else if (st.isFile()) {
2643
+ if (ignore(childRel)) continue;
2644
+ const content = readFileSync(childAbs, 'utf8');
2645
+ if (content.includes(NUL)) continue; // skip binary; server rejects it anyway
2646
+ out.push({ path: childRel, content });
2647
+ }
2648
+ }
2649
+ };
2650
+ walk(root, '');
2651
+ return out;
2652
+ }
2653
+
2654
+ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. Dispatch by `action`: search/load/list for discovery; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory.', {
2616
2655
  action: z.enum([
2617
2656
  'search', 'load', 'list',
2618
2657
  'add', 'update', 'remove',
2658
+ 'fork', 'push',
2619
2659
  'attach', 'detach',
2620
2660
  'favorite', 'unfavorite',
2621
2661
  'read_file', 'update_file',
@@ -2632,6 +2672,11 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2632
2672
  content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
2633
2673
  triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
2634
2674
  path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
2675
+ org: z.string().optional().describe('[fork|push|update] resolve/fork into this Drafted org (id or name); scopes the request without switching the session'),
2676
+ setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
2677
+ files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps'),
2678
+ dir: z.string().optional().describe('[push] local directory to push instead of files[]; walked locally (heavy dirs + .skillignore pre-filtered), server re-enforces'),
2679
+ deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
2635
2680
  }, async (args) => {
2636
2681
  try {
2637
2682
  const { action } = args;
@@ -2703,30 +2748,85 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2703
2748
  case 'add': {
2704
2749
  const g2 = g2Block(getSessionState().gates);
2705
2750
  if (g2) return err(new Error(g2));
2706
- const { name, description, content, tags, triggerPatterns } = args;
2751
+ const { name, description, content, tags, triggerPatterns, setup } = args;
2707
2752
  if (!name || !description || !content) throw new Error('name, description, content required for action=add');
2708
2753
  const body = { name, description, content };
2709
2754
  if (tags) body.tags = tags;
2710
2755
  if (triggerPatterns) body.triggerPatterns = triggerPatterns;
2756
+ if (setup !== undefined) body.setup = setup;
2711
2757
  return ok(await api('POST', '/api/skills', body));
2712
2758
  }
2713
2759
  case 'update': {
2714
- const { skillId, name, description, content, tags, triggerPatterns } = args;
2715
- if (!skillId) throw new Error('skillId required for action=update');
2760
+ // Auto-fork-on-update (tool behavior): if the target is read-only (global/
2761
+ // system or another org), the server returns 403 {code:"skill_read_only"};
2762
+ // we fork into the caller's org and write the copy, returning forked:true.
2763
+ // The raw PUT stays strict — this composition lives in the tool, mirroring
2764
+ // the CLI verbatim so consumers treat CLI and MCP identically.
2765
+ const { skillId, skill: slugArg, name, description, content, tags, triggerPatterns, setup, org } = args;
2766
+ const extra = org ? { 'X-Drafted-Org': org } : {};
2767
+ let id = skillId;
2768
+ let slug = slugArg;
2769
+ if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; slug = s.slug; }
2770
+ if (!id) throw new Error('skillId or skill (slug) required for action=update');
2716
2771
  const body = {};
2717
2772
  if (name !== undefined) body.name = name;
2718
2773
  if (description !== undefined) body.description = description;
2719
2774
  if (content !== undefined) body.content = content;
2720
2775
  if (tags !== undefined) body.tags = tags;
2721
2776
  if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
2777
+ if (setup !== undefined) body.setup = setup;
2722
2778
  if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
2723
- return ok(await api('PUT', `/api/skills/${skillId}`, body));
2779
+ try {
2780
+ const r = await api('PUT', `/api/skills/${id}`, body, extra);
2781
+ return ok({ ...r, forked: false });
2782
+ } catch (e) {
2783
+ if (e.code !== 'skill_read_only') throw e;
2784
+ let forkId;
2785
+ try {
2786
+ forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
2787
+ } catch (fe) {
2788
+ if (fe.status !== 409) throw fe;
2789
+ const ownSlug = (fe.body && fe.body.slug) || (e.body && e.body.slug) || slug;
2790
+ forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
2791
+ }
2792
+ const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
2793
+ return ok({ ...r2, forked: true });
2794
+ }
2724
2795
  }
2725
2796
  case 'remove': {
2726
2797
  const { skillId } = args;
2727
2798
  if (!skillId) throw new Error('skillId required for action=remove');
2728
2799
  return ok(await api('DELETE', `/api/skills/${skillId}`));
2729
2800
  }
2801
+ case 'fork': {
2802
+ // Block-then-fork: copy a readable (global/other-org) skill into the
2803
+ // caller's org so it can be edited. Mirrors `drafted skill fork` verbatim.
2804
+ const { skillId, skill: slugArg, org } = args;
2805
+ const extra = org ? { 'X-Drafted-Org': org } : {};
2806
+ let id = skillId;
2807
+ if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
2808
+ if (!id) throw new Error('skillId or skill (slug) required for action=fork');
2809
+ try {
2810
+ return ok(await api('POST', `/api/skills/${id}/fork`, {}, extra));
2811
+ } catch (e) {
2812
+ if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
2813
+ throw e;
2814
+ }
2815
+ }
2816
+ case 'push': {
2817
+ // Source-tree ingest. Pass files:[{path,content}] (the usual MCP shape) or a
2818
+ // local dir (walked here, server re-enforces the §3 ingest guards). Mirrors
2819
+ // `drafted skill push`.
2820
+ const { skillId, skill: slugArg, files, dir, deleteMissing, org } = args;
2821
+ const extra = org ? { 'X-Drafted-Org': org } : {};
2822
+ let id = skillId;
2823
+ if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
2824
+ if (!id) throw new Error('skillId or skill (slug) required for action=push');
2825
+ let fileList = files;
2826
+ if (!fileList && dir) fileList = collectSkillTreeForPush(dir);
2827
+ if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
2828
+ return ok(await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra));
2829
+ }
2730
2830
  case 'attach': {
2731
2831
  const { skillId } = args;
2732
2832
  if (!skillId) throw new Error('skillId required for action=attach');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [