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.
- package/cli/drafted.mjs +146 -15
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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": [
|