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 +246 -15
- package/mcp/server.mjs +110 -10
- 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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1364
|
-
|
|
1365
|
-
|
|
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 (
|
|
1369
|
-
emitSkillResult(opts.format, { status:
|
|
1370
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2715
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|