drafted 1.8.5 → 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 +146 -15
  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
  }
@@ -1223,15 +1239,29 @@ function bundleHash(files) {
1223
1239
  return h.digest('hex').slice(0, 12);
1224
1240
  }
1225
1241
 
1226
- async function syncOneSkill(ref, outDir) {
1242
+ // Build an `unresolvable` reason from a failed resolve response (404/403),
1243
+ // naming the org when --org was used so Causeway can render `✗ unresolvable
1244
+ // (org X)` instead of a silent miss.
1245
+ async function unresolvableReason(res, org) {
1246
+ let why = 'skill not found';
1247
+ try { const e = await res.json(); if (e && e.error) why = e.error; } catch { /* keep default */ }
1248
+ if (org && !why.toLowerCase().includes(String(org).toLowerCase())) return `${why} (org ${org})`;
1249
+ return why;
1250
+ }
1251
+
1252
+ async function syncOneSkill(ref, outDir, org) {
1227
1253
  const at = ref.lastIndexOf('@');
1228
1254
  const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
1229
1255
  const server = getServerUrl().replace(/\/$/, '');
1256
+ // --org binds resolution to a specific org per-request (X-Drafted-Org header);
1257
+ // the server validates it against the caller's memberships and never mutates
1258
+ // the shared session's active org. Omitted -> ambient session org (unchanged).
1259
+ const orgHeaders = org ? { 'X-Drafted-Org': org } : {};
1230
1260
  const loadUrl = UUID_RE.test(idOrSlug)
1231
1261
  ? `${server}/api/skills/${idOrSlug}`
1232
1262
  : `${server}/api/skills/slug/${encodeURIComponent(idOrSlug)}`;
1233
- const res = await authFetch(loadUrl);
1234
- if (res.status === 404) return { ref, slug: '', hash: '', status: 'unresolvable', error: 'skill not found' };
1263
+ const res = await authFetch(loadUrl, { headers: orgHeaders });
1264
+ if (res.status === 404 || res.status === 403) return { ref, slug: '', hash: '', status: 'unresolvable', error: await unresolvableReason(res, org) };
1235
1265
  if (!res.ok) return { ref, slug: '', hash: '', status: 'error', error: `HTTP ${res.status}` };
1236
1266
  const skill = await res.json();
1237
1267
  const slug = skill.slug || idOrSlug;
@@ -1240,12 +1270,14 @@ async function syncOneSkill(ref, outDir) {
1240
1270
  for (const p of Array.isArray(skill.files) ? skill.files : []) {
1241
1271
  if (typeof p !== 'string' || p.includes('..')) continue;
1242
1272
  const encoded = p.split('/').map(encodeURIComponent).join('/');
1243
- const fr = await authFetch(`${server}/api/skills/${skill.id}/files/${encoded}`);
1273
+ const fr = await authFetch(`${server}/api/skills/${skill.id}/files/${encoded}`, { headers: orgHeaders });
1244
1274
  if (!fr.ok) return { ref, slug, hash: '', status: 'error', error: `file ${p}: HTTP ${fr.status}` };
1245
1275
  const fdata = await fr.json();
1246
1276
  files[p] = typeof fdata === 'string' ? fdata : (fdata.content || '');
1247
1277
  }
1248
- 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);
1249
1281
 
1250
1282
  const base = join(outDir, slug);
1251
1283
  for (const [rel, content] of Object.entries(files)) {
@@ -1262,6 +1294,7 @@ skillCmd
1262
1294
  .description('Materialize pinned skills into a local cache dir (Causeway seam)')
1263
1295
  .option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
1264
1296
  .requiredOption('--out <dir>', 'output directory for bundles')
1297
+ .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
1265
1298
  .option('--format <fmt>', 'output format: json or text', 'text')
1266
1299
  .action(async (opts) => {
1267
1300
  requireLogin();
@@ -1271,7 +1304,7 @@ skillCmd
1271
1304
  let allOk = refs.length > 0;
1272
1305
  for (const ref of refs) {
1273
1306
  try {
1274
- const r = await syncOneSkill(ref, opts.out);
1307
+ const r = await syncOneSkill(ref, opts.out, opts.org);
1275
1308
  results.push(r);
1276
1309
  if (r.status !== 'ok') allOk = false;
1277
1310
  } catch (err) {
@@ -1317,14 +1350,14 @@ skillCmd
1317
1350
  const server = getServerUrl().replace(/\/$/, '');
1318
1351
  const res = await authFetch(`${server}/api/skills`, {
1319
1352
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1320
- 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 }),
1321
1354
  });
1322
1355
  const data = await res.json().catch(() => ({}));
1323
1356
  if (!res.ok) {
1324
1357
  emitSkillResult(opts.format, { status: res.status === 409 ? 'conflict' : 'error', error: data.error || `HTTP ${res.status}` });
1325
1358
  process.exit(1);
1326
1359
  }
1327
- 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) }) });
1328
1361
  });
1329
1362
 
1330
1363
  skillCmd
@@ -1347,34 +1380,39 @@ skillCmd
1347
1380
  if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
1348
1381
  const res = await authFetch(`${server}/api/skills/${id}`, {
1349
1382
  method: 'PUT', headers: { 'Content-Type': 'application/json' },
1350
- 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 }),
1351
1384
  });
1352
1385
  const data = await res.json().catch(() => ({}));
1353
1386
  if (!res.ok) {
1354
1387
  emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
1355
1388
  process.exit(1);
1356
1389
  }
1357
- 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) }) });
1358
1391
  });
1359
1392
 
1360
1393
  // check reports the current canonical hash for refs WITHOUT materializing — the
1361
1394
  // Causeway freshness probe. Hash is computed the same way as sync, so a check
1362
1395
  // hash and a sync hash for the same skill match.
1363
- async function checkOneSkill(ref) {
1396
+ async function checkOneSkill(ref, org) {
1364
1397
  const at = ref.lastIndexOf('@');
1365
1398
  const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
1366
1399
  const server = getServerUrl().replace(/\/$/, '');
1400
+ const orgHeaders = org ? { 'X-Drafted-Org': org } : {};
1367
1401
  const loadUrl = UUID_RE.test(idOrSlug)
1368
1402
  ? `${server}/api/skills/${idOrSlug}`
1369
1403
  : `${server}/api/skills/slug/${encodeURIComponent(idOrSlug)}`;
1370
- const res = await authFetch(loadUrl);
1371
- if (res.status === 404) return { ref, slug: '', hash: '', status: 'unresolvable' };
1404
+ const res = await authFetch(loadUrl, { headers: orgHeaders });
1405
+ if (res.status === 404 || res.status === 403) return { ref, slug: '', hash: '', status: 'unresolvable', error: await unresolvableReason(res, org) };
1372
1406
  if (!res.ok) return { ref, slug: '', hash: '', status: 'error', error: `HTTP ${res.status}` };
1373
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 };
1374
1412
  const files = { 'SKILL.md': synthesizeSkillMd(skill) };
1375
1413
  for (const p of Array.isArray(skill.files) ? skill.files : []) {
1376
1414
  if (typeof p !== 'string' || p.includes('..')) continue;
1377
- const fr = await authFetch(`${server}/api/skills/${skill.id}/files/${p.split('/').map(encodeURIComponent).join('/')}`);
1415
+ const fr = await authFetch(`${server}/api/skills/${skill.id}/files/${p.split('/').map(encodeURIComponent).join('/')}`, { headers: orgHeaders });
1378
1416
  if (!fr.ok) return { ref, slug: skill.slug, hash: '', status: 'error', error: `file ${p}: HTTP ${fr.status}` };
1379
1417
  const fdata = await fr.json();
1380
1418
  files[p] = typeof fdata === 'string' ? fdata : (fdata.content || '');
@@ -1386,12 +1424,13 @@ skillCmd
1386
1424
  .command('check')
1387
1425
  .description('Report the current canonical hash for refs without materializing (freshness)')
1388
1426
  .option('--ref <ref>', 'skill ref (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
1427
+ .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
1389
1428
  .option('--format <fmt>', 'output format: json or text', 'text')
1390
1429
  .action(async (opts) => {
1391
1430
  requireLogin();
1392
1431
  const results = [];
1393
1432
  for (const ref of (opts.ref || [])) {
1394
- try { results.push(await checkOneSkill(ref)); }
1433
+ try { results.push(await checkOneSkill(ref, opts.org)); }
1395
1434
  catch (e) { results.push({ ref, slug: '', hash: '', status: 'error', error: String((e && e.message) || e) }); }
1396
1435
  }
1397
1436
  if (opts.format === 'json') console.log(JSON.stringify(results));
@@ -1415,4 +1454,96 @@ skillCmd
1415
1454
  emitSkillResult(opts.format, { status: 'ok', id: opts.id });
1416
1455
  });
1417
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
+
1418
1549
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.8.5",
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": [