drafted 1.8.6 → 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
@@ -1200,6 +1200,13 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
1200
1200
  // as the body only (name/description/triggerPatterns are columns), so we prepend
1201
1201
  // YAML frontmatter — JSON scalars are valid YAML — for Causeway's projection and
1202
1202
  // Claude's native loader to read.
1203
+ // normalizeSetup mirrors the server (server/lib/skill-hash.mjs) — keep in lockstep.
1204
+ function normalizeSetup(setup) {
1205
+ if (setup == null) return [];
1206
+ const arr = Array.isArray(setup) ? setup : [setup];
1207
+ return arr.filter((s) => typeof s === 'string' && s.trim().length > 0);
1208
+ }
1209
+
1203
1210
  function synthesizeSkillMd(skill) {
1204
1211
  const lines = ['---', `name: ${JSON.stringify(skill.name || skill.slug || '')}`];
1205
1212
  if (skill.description) lines.push(`description: ${JSON.stringify(skill.description)}`);
@@ -1208,6 +1215,15 @@ function synthesizeSkillMd(skill) {
1208
1215
  lines.push('triggerPatterns:');
1209
1216
  for (const t of triggers) lines.push(` - ${JSON.stringify(t)}`);
1210
1217
  }
1218
+ // `setup` emitted ONLY when present, so a skill without it produces the exact
1219
+ // SKILL.md (and hash) it did before the field existed. Must match the server.
1220
+ const setup = normalizeSetup(skill.setup);
1221
+ if (setup.length === 1 && typeof skill.setup === 'string') {
1222
+ lines.push(`setup: ${JSON.stringify(setup[0])}`);
1223
+ } else if (setup.length) {
1224
+ lines.push('setup:');
1225
+ for (const s of setup) lines.push(` - ${JSON.stringify(s)}`);
1226
+ }
1211
1227
  lines.push('---', '');
1212
1228
  return lines.join('\n') + (skill.content || '');
1213
1229
  }
@@ -1233,6 +1249,63 @@ async function unresolvableReason(res, org) {
1233
1249
  return why;
1234
1250
  }
1235
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
+
1236
1309
  async function syncOneSkill(ref, outDir, org) {
1237
1310
  const at = ref.lastIndexOf('@');
1238
1311
  const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
@@ -1259,7 +1332,9 @@ async function syncOneSkill(ref, outDir, org) {
1259
1332
  const fdata = await fr.json();
1260
1333
  files[p] = typeof fdata === 'string' ? fdata : (fdata.content || '');
1261
1334
  }
1262
- const hash = bundleHash(files);
1335
+ // Drafted is the hash authority — prefer the server-reported hash; fall back to
1336
+ // local bundle compute only for older servers that don't return one.
1337
+ const hash = skill.hash || bundleHash(files);
1263
1338
 
1264
1339
  const base = join(outDir, slug);
1265
1340
  for (const [rel, content] of Object.entries(files)) {
@@ -1277,6 +1352,7 @@ skillCmd
1277
1352
  .option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
1278
1353
  .requiredOption('--out <dir>', 'output directory for bundles')
1279
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)')
1280
1356
  .option('--format <fmt>', 'output format: json or text', 'text')
1281
1357
  .action(async (opts) => {
1282
1358
  requireLogin();
@@ -1287,6 +1363,16 @@ skillCmd
1287
1363
  for (const ref of refs) {
1288
1364
  try {
1289
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
+ }
1290
1376
  results.push(r);
1291
1377
  if (r.status !== 'ok') allOk = false;
1292
1378
  } catch (err) {
@@ -1298,7 +1384,8 @@ skillCmd
1298
1384
  console.log(JSON.stringify(results));
1299
1385
  } else {
1300
1386
  for (const r of results) {
1301
- 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 : ''}`);
1302
1389
  }
1303
1390
  }
1304
1391
  process.exit(allOk ? 0 : 1);
@@ -1332,44 +1419,73 @@ skillCmd
1332
1419
  const server = getServerUrl().replace(/\/$/, '');
1333
1420
  const res = await authFetch(`${server}/api/skills`, {
1334
1421
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1335
- body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns }),
1422
+ body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup }),
1336
1423
  });
1337
1424
  const data = await res.json().catch(() => ({}));
1338
1425
  if (!res.ok) {
1339
1426
  emitSkillResult(opts.format, { status: res.status === 409 ? 'conflict' : 'error', error: data.error || `HTTP ${res.status}` });
1340
1427
  process.exit(1);
1341
1428
  }
1342
- emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1429
+ emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1343
1430
  });
1344
1431
 
1345
1432
  skillCmd
1346
1433
  .command('update')
1347
- .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)')
1348
1435
  .option('--id <id>', 'skill id')
1349
1436
  .option('--slug <slug>', 'skill slug (resolved to id)')
1437
+ .option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
1350
1438
  .option('--format <fmt>', 'output format: json or text', 'text')
1351
1439
  .action(async (opts) => {
1352
1440
  requireLogin();
1353
1441
  let p;
1354
1442
  try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
1355
1443
  const server = getServerUrl().replace(/\/$/, '');
1444
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1356
1445
  let id = opts.id;
1446
+ let slug = opts.slug;
1357
1447
  if (!id && opts.slug) {
1358
- 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 });
1359
1449
  if (!lookup.ok) { emitSkillResult(opts.format, { status: 'unresolvable', error: `slug ${opts.slug}: HTTP ${lookup.status}` }); process.exit(1); }
1360
- id = (await lookup.json().catch(() => ({}))).id;
1450
+ const lj = await lookup.json().catch(() => ({}));
1451
+ id = lj.id; slug = lj.slug;
1361
1452
  }
1362
1453
  if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
1363
- const res = await authFetch(`${server}/api/skills/${id}`, {
1364
- method: 'PUT', headers: { 'Content-Type': 'application/json' },
1365
- body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns }),
1366
- });
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);
1367
1458
  const data = await res.json().catch(() => ({}));
1368
- if (!res.ok) {
1369
- emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1370
- 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;
1462
+ }
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;
1371
1486
  }
1372
- emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1487
+ emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1488
+ process.exit(1);
1373
1489
  });
1374
1490
 
1375
1491
  // check reports the current canonical hash for refs WITHOUT materializing — the
@@ -1387,6 +1503,10 @@ async function checkOneSkill(ref, org) {
1387
1503
  if (res.status === 404 || res.status === 403) return { ref, slug: '', hash: '', status: 'unresolvable', error: await unresolvableReason(res, org) };
1388
1504
  if (!res.ok) return { ref, slug: '', hash: '', status: 'error', error: `HTTP ${res.status}` };
1389
1505
  const skill = await res.json();
1506
+ // Drafted is the hash authority — when the server reports a hash, trust it and
1507
+ // skip the per-file fetches entirely (cheap freshness probe). Older servers
1508
+ // without a reported hash fall back to local bundle compute.
1509
+ if (skill.hash) return { ref, slug: skill.slug, hash: skill.hash, status: 'ok', updatedAt: skill.updatedAt };
1390
1510
  const files = { 'SKILL.md': synthesizeSkillMd(skill) };
1391
1511
  for (const p of Array.isArray(skill.files) ? skill.files : []) {
1392
1512
  if (typeof p !== 'string' || p.includes('..')) continue;
@@ -1415,6 +1535,25 @@ skillCmd
1415
1535
  else for (const r of results) console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}`);
1416
1536
  });
1417
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
+
1418
1557
  skillCmd
1419
1558
  .command('remove')
1420
1559
  .description('Delete a Drafted skill by --id (used to clean up throwaway skills)')
@@ -1432,4 +1571,96 @@ skillCmd
1432
1571
  emitSkillResult(opts.format, { status: 'ok', id: opts.id });
1433
1572
  });
1434
1573
 
1574
+ // Resolve --slug -> id (honoring --org), shared by fork/push. Returns id or null.
1575
+ async function resolveSkillId(opts, server, orgHeaders) {
1576
+ if (opts.id) return opts.id;
1577
+ if (!opts.slug) return null;
1578
+ const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
1579
+ if (!lookup.ok) return null;
1580
+ return (await lookup.json().catch(() => ({}))).id || null;
1581
+ }
1582
+
1583
+ skillCmd
1584
+ .command('fork')
1585
+ .description('Fork a skill into your org so you can edit it (block-then-fork; system/other-org skills are read-only)')
1586
+ .option('--id <id>', 'source skill id')
1587
+ .option('--slug <slug>', 'source skill slug (resolved to id)')
1588
+ .option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
1589
+ .option('--format <fmt>', 'output format: json or text', 'text')
1590
+ .action(async (opts) => {
1591
+ requireLogin();
1592
+ const server = getServerUrl().replace(/\/$/, '');
1593
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1594
+ const id = await resolveSkillId(opts, server, orgHeaders);
1595
+ if (!id) { emitSkillResult(opts.format, { status: 'unresolvable', error: opts.slug ? `slug ${opts.slug} not found` : 'fork requires --id or --slug' }); process.exit(1); }
1596
+ const res = await authFetch(`${server}/api/skills/${id}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders } });
1597
+ const data = await res.json().catch(() => ({}));
1598
+ if (!res.ok) {
1599
+ const status = res.status === 409 ? 'conflict' : (res.status === 404 || res.status === 403 ? 'unresolvable' : 'error');
1600
+ emitSkillResult(opts.format, { status, slug: data.slug, error: data.error || `HTTP ${res.status}` });
1601
+ process.exit(1);
1602
+ }
1603
+ emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash });
1604
+ });
1605
+
1606
+ // collectSkillTree walks a local source tree for `skill push`, pre-filtering heavy
1607
+ // dirs and any local .skillignore as a convenience so we don't ship a node_modules
1608
+ // tree over the wire. The SERVER re-runs the authoritative hygiene pipeline.
1609
+ function collectSkillTree(dir) {
1610
+ const root = resolve(dir);
1611
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
1612
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
1613
+ let ignore = () => false;
1614
+ const ignPath = join(root, '.skillignore');
1615
+ if (existsSync(ignPath)) {
1616
+ const pats = readFileSync(ignPath, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
1617
+ ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
1618
+ }
1619
+ const out = [];
1620
+ const walk = (abs, rel) => {
1621
+ for (const name of readdirSync(abs)) {
1622
+ const childAbs = join(abs, name);
1623
+ const childRel = rel ? `${rel}/${name}` : name;
1624
+ const st = statSync(childAbs);
1625
+ if (st.isDirectory()) { if (SKIP_DIRS.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
1626
+ else if (st.isFile()) {
1627
+ if (ignore(childRel)) continue;
1628
+ const content = readFileSync(childAbs, 'utf8');
1629
+ if (content.includes('\x00')) continue; // skip binary; server rejects it anyway
1630
+ out.push({ path: childRel, content });
1631
+ }
1632
+ }
1633
+ };
1634
+ walk(root, '');
1635
+ return out;
1636
+ }
1637
+
1638
+ skillCmd
1639
+ .command('push')
1640
+ .description('Push a local source tree into a skill (bulk ingest; server strips artifacts + enforces caps)')
1641
+ .option('--id <id>', 'skill id')
1642
+ .option('--slug <slug>', 'skill slug (resolved to id)')
1643
+ .requiredOption('--dir <dir>', 'local source tree to push')
1644
+ .option('--org <org>', 'resolve against this Drafted org (id or name)')
1645
+ .option('--delete-missing', 'remove stored files not present in the pushed tree')
1646
+ .option('--format <fmt>', 'output format: json or text', 'text')
1647
+ .action(async (opts) => {
1648
+ requireLogin();
1649
+ const server = getServerUrl().replace(/\/$/, '');
1650
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1651
+ const id = await resolveSkillId(opts, server, orgHeaders);
1652
+ if (!id) { emitSkillResult(opts.format, { status: 'unresolvable', error: opts.slug ? `slug ${opts.slug} not found` : 'push requires --id or --slug' }); process.exit(1); }
1653
+ let files;
1654
+ try { files = collectSkillTree(opts.dir); }
1655
+ catch (e) { emitSkillResult(opts.format, { status: 'error', error: String((e && e.message) || e) }); process.exit(1); }
1656
+ const res = await authFetch(`${server}/api/skills/${id}/files/bulk`, {
1657
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders },
1658
+ body: JSON.stringify({ files, deleteMissing: !!opts.deleteMissing }),
1659
+ });
1660
+ const data = await res.json().catch(() => ({}));
1661
+ if (!res.ok) { emitSkillResult(opts.format, { status: 'error', id, error: data.error || `HTTP ${res.status}` }); process.exit(1); }
1662
+ if (opts.format === 'json') console.log(JSON.stringify({ status: 'ok', id, hash: data.hash, count: data.count, stripped: data.stripped, files: data.files }));
1663
+ else console.log(`ok\t${id}\t${data.hash}\tpushed ${data.count}${data.stripped ? `, stripped ${data.stripped}` : ''}`);
1664
+ });
1665
+
1435
1666
  program.parse();
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.8.6",
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": [