drafted 1.8.6 → 1.9.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.
Files changed (2) hide show
  1. package/cli/drafted.mjs +119 -5
  2. package/package.json +1 -1
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
  }
@@ -1259,7 +1275,9 @@ async function syncOneSkill(ref, outDir, org) {
1259
1275
  const fdata = await fr.json();
1260
1276
  files[p] = typeof fdata === 'string' ? fdata : (fdata.content || '');
1261
1277
  }
1262
- const hash = bundleHash(files);
1278
+ // Drafted is the hash authority — prefer the server-reported hash; fall back to
1279
+ // local bundle compute only for older servers that don't return one.
1280
+ const hash = skill.hash || bundleHash(files);
1263
1281
 
1264
1282
  const base = join(outDir, slug);
1265
1283
  for (const [rel, content] of Object.entries(files)) {
@@ -1332,14 +1350,14 @@ skillCmd
1332
1350
  const server = getServerUrl().replace(/\/$/, '');
1333
1351
  const res = await authFetch(`${server}/api/skills`, {
1334
1352
  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 }),
1353
+ body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup }),
1336
1354
  });
1337
1355
  const data = await res.json().catch(() => ({}));
1338
1356
  if (!res.ok) {
1339
1357
  emitSkillResult(opts.format, { status: res.status === 409 ? 'conflict' : 'error', error: data.error || `HTTP ${res.status}` });
1340
1358
  process.exit(1);
1341
1359
  }
1342
- emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1360
+ emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1343
1361
  });
1344
1362
 
1345
1363
  skillCmd
@@ -1362,14 +1380,14 @@ skillCmd
1362
1380
  if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
1363
1381
  const res = await authFetch(`${server}/api/skills/${id}`, {
1364
1382
  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 }),
1383
+ body: JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup }),
1366
1384
  });
1367
1385
  const data = await res.json().catch(() => ({}));
1368
1386
  if (!res.ok) {
1369
1387
  emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1370
1388
  process.exit(1);
1371
1389
  }
1372
- emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1390
+ emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
1373
1391
  });
1374
1392
 
1375
1393
  // check reports the current canonical hash for refs WITHOUT materializing — the
@@ -1387,6 +1405,10 @@ async function checkOneSkill(ref, org) {
1387
1405
  if (res.status === 404 || res.status === 403) return { ref, slug: '', hash: '', status: 'unresolvable', error: await unresolvableReason(res, org) };
1388
1406
  if (!res.ok) return { ref, slug: '', hash: '', status: 'error', error: `HTTP ${res.status}` };
1389
1407
  const skill = await res.json();
1408
+ // Drafted is the hash authority — when the server reports a hash, trust it and
1409
+ // skip the per-file fetches entirely (cheap freshness probe). Older servers
1410
+ // without a reported hash fall back to local bundle compute.
1411
+ if (skill.hash) return { ref, slug: skill.slug, hash: skill.hash, status: 'ok', updatedAt: skill.updatedAt };
1390
1412
  const files = { 'SKILL.md': synthesizeSkillMd(skill) };
1391
1413
  for (const p of Array.isArray(skill.files) ? skill.files : []) {
1392
1414
  if (typeof p !== 'string' || p.includes('..')) continue;
@@ -1432,4 +1454,96 @@ skillCmd
1432
1454
  emitSkillResult(opts.format, { status: 'ok', id: opts.id });
1433
1455
  });
1434
1456
 
1457
+ // Resolve --slug -> id (honoring --org), shared by fork/push. Returns id or null.
1458
+ async function resolveSkillId(opts, server, orgHeaders) {
1459
+ if (opts.id) return opts.id;
1460
+ if (!opts.slug) return null;
1461
+ const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
1462
+ if (!lookup.ok) return null;
1463
+ return (await lookup.json().catch(() => ({}))).id || null;
1464
+ }
1465
+
1466
+ skillCmd
1467
+ .command('fork')
1468
+ .description('Fork a skill into your org so you can edit it (block-then-fork; system/other-org skills are read-only)')
1469
+ .option('--id <id>', 'source skill id')
1470
+ .option('--slug <slug>', 'source skill slug (resolved to id)')
1471
+ .option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
1472
+ .option('--format <fmt>', 'output format: json or text', 'text')
1473
+ .action(async (opts) => {
1474
+ requireLogin();
1475
+ const server = getServerUrl().replace(/\/$/, '');
1476
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1477
+ const id = await resolveSkillId(opts, server, orgHeaders);
1478
+ if (!id) { emitSkillResult(opts.format, { status: 'unresolvable', error: opts.slug ? `slug ${opts.slug} not found` : 'fork requires --id or --slug' }); process.exit(1); }
1479
+ const res = await authFetch(`${server}/api/skills/${id}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders } });
1480
+ const data = await res.json().catch(() => ({}));
1481
+ if (!res.ok) {
1482
+ const status = res.status === 409 ? 'conflict' : (res.status === 404 || res.status === 403 ? 'unresolvable' : 'error');
1483
+ emitSkillResult(opts.format, { status, slug: data.slug, error: data.error || `HTTP ${res.status}` });
1484
+ process.exit(1);
1485
+ }
1486
+ emitSkillResult(opts.format, { status: 'ok', slug: data.slug, id: data.id, hash: data.hash });
1487
+ });
1488
+
1489
+ // collectSkillTree walks a local source tree for `skill push`, pre-filtering heavy
1490
+ // dirs and any local .skillignore as a convenience so we don't ship a node_modules
1491
+ // tree over the wire. The SERVER re-runs the authoritative hygiene pipeline.
1492
+ function collectSkillTree(dir) {
1493
+ const root = resolve(dir);
1494
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
1495
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
1496
+ let ignore = () => false;
1497
+ const ignPath = join(root, '.skillignore');
1498
+ if (existsSync(ignPath)) {
1499
+ const pats = readFileSync(ignPath, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
1500
+ ignore = (rel) => pats.some((p) => { const d = p.replace(/\/$/, ''); return rel === d || rel.startsWith(d + '/') || rel.split('/').pop() === d; });
1501
+ }
1502
+ const out = [];
1503
+ const walk = (abs, rel) => {
1504
+ for (const name of readdirSync(abs)) {
1505
+ const childAbs = join(abs, name);
1506
+ const childRel = rel ? `${rel}/${name}` : name;
1507
+ const st = statSync(childAbs);
1508
+ if (st.isDirectory()) { if (SKIP_DIRS.has(name) || ignore(childRel)) continue; walk(childAbs, childRel); }
1509
+ else if (st.isFile()) {
1510
+ if (ignore(childRel)) continue;
1511
+ const content = readFileSync(childAbs, 'utf8');
1512
+ if (content.includes('\x00')) continue; // skip binary; server rejects it anyway
1513
+ out.push({ path: childRel, content });
1514
+ }
1515
+ }
1516
+ };
1517
+ walk(root, '');
1518
+ return out;
1519
+ }
1520
+
1521
+ skillCmd
1522
+ .command('push')
1523
+ .description('Push a local source tree into a skill (bulk ingest; server strips artifacts + enforces caps)')
1524
+ .option('--id <id>', 'skill id')
1525
+ .option('--slug <slug>', 'skill slug (resolved to id)')
1526
+ .requiredOption('--dir <dir>', 'local source tree to push')
1527
+ .option('--org <org>', 'resolve against this Drafted org (id or name)')
1528
+ .option('--delete-missing', 'remove stored files not present in the pushed tree')
1529
+ .option('--format <fmt>', 'output format: json or text', 'text')
1530
+ .action(async (opts) => {
1531
+ requireLogin();
1532
+ const server = getServerUrl().replace(/\/$/, '');
1533
+ const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
1534
+ const id = await resolveSkillId(opts, server, orgHeaders);
1535
+ if (!id) { emitSkillResult(opts.format, { status: 'unresolvable', error: opts.slug ? `slug ${opts.slug} not found` : 'push requires --id or --slug' }); process.exit(1); }
1536
+ let files;
1537
+ try { files = collectSkillTree(opts.dir); }
1538
+ catch (e) { emitSkillResult(opts.format, { status: 'error', error: String((e && e.message) || e) }); process.exit(1); }
1539
+ const res = await authFetch(`${server}/api/skills/${id}/files/bulk`, {
1540
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders },
1541
+ body: JSON.stringify({ files, deleteMissing: !!opts.deleteMissing }),
1542
+ });
1543
+ const data = await res.json().catch(() => ({}));
1544
+ if (!res.ok) { emitSkillResult(opts.format, { status: 'error', id, error: data.error || `HTTP ${res.status}` }); process.exit(1); }
1545
+ if (opts.format === 'json') console.log(JSON.stringify({ status: 'ok', id, hash: data.hash, count: data.count, stripped: data.stripped, files: data.files }));
1546
+ else console.log(`ok\t${id}\t${data.hash}\tpushed ${data.count}${data.stripped ? `, stripped ${data.stripped}` : ''}`);
1547
+ });
1548
+
1435
1549
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.8.6",
3
+ "version": "1.9.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": [