drafted 1.9.0 → 1.10.1
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 +132 -12
- package/mcp/server.mjs +110 -10
- package/package.json +1 -1
package/cli/drafted.mjs
CHANGED
|
@@ -1249,6 +1249,66 @@ async function unresolvableReason(res, org) {
|
|
|
1249
1249
|
return why;
|
|
1250
1250
|
}
|
|
1251
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
|
+
// Route the subprocess's stdout+stderr to OUR stderr (fd 2), never our stdout —
|
|
1304
|
+
// so `skill setup --format json` (and sync, which calls this) emit ONLY the
|
|
1305
|
+
// result JSON on stdout. Build noise (npm/tsup) stays visible on stderr.
|
|
1306
|
+
try { execSync(cmd, { cwd: dir, stdio: ['ignore', 2, 2] }); ran.push(cmd); }
|
|
1307
|
+
catch (e) { return { ok: false, failed: cmd, ran, error: String((e && e.message) || e) }; }
|
|
1308
|
+
}
|
|
1309
|
+
return { ok: true, ran };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1252
1312
|
async function syncOneSkill(ref, outDir, org) {
|
|
1253
1313
|
const at = ref.lastIndexOf('@');
|
|
1254
1314
|
const idOrSlug = at > 0 ? ref.slice(0, at) : ref;
|
|
@@ -1295,6 +1355,7 @@ skillCmd
|
|
|
1295
1355
|
.option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
|
|
1296
1356
|
.requiredOption('--out <dir>', 'output directory for bundles')
|
|
1297
1357
|
.option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
|
|
1358
|
+
.option('--no-setup', 'do not run each skill\'s setup after materializing (default: run setup so source-only skills are runnable)')
|
|
1298
1359
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1299
1360
|
.action(async (opts) => {
|
|
1300
1361
|
requireLogin();
|
|
@@ -1305,6 +1366,16 @@ skillCmd
|
|
|
1305
1366
|
for (const ref of refs) {
|
|
1306
1367
|
try {
|
|
1307
1368
|
const r = await syncOneSkill(ref, opts.out, opts.org);
|
|
1369
|
+
// Run setup by default so a synced source-only skill builds and is
|
|
1370
|
+
// immediately runnable. Hash was computed over the source BEFORE setup, so
|
|
1371
|
+
// node_modules/dist from setup never affect it. Offline-safe: a setup
|
|
1372
|
+
// failure is reported but the materialized source stays put (sync is still
|
|
1373
|
+
// `ok` — the bytes are there, just not built).
|
|
1374
|
+
if (r.status === 'ok' && opts.setup !== false) {
|
|
1375
|
+
const s = runSkillSetup(join(opts.out, r.slug));
|
|
1376
|
+
r.setup = s.skipped ? 'none' : (s.ok ? 'ok' : 'failed');
|
|
1377
|
+
if (!s.ok && !s.skipped) r.setupError = s.failed ? `${s.failed}: ${s.error}` : s.error;
|
|
1378
|
+
}
|
|
1308
1379
|
results.push(r);
|
|
1309
1380
|
if (r.status !== 'ok') allOk = false;
|
|
1310
1381
|
} catch (err) {
|
|
@@ -1316,7 +1387,8 @@ skillCmd
|
|
|
1316
1387
|
console.log(JSON.stringify(results));
|
|
1317
1388
|
} else {
|
|
1318
1389
|
for (const r of results) {
|
|
1319
|
-
|
|
1390
|
+
const setupCol = r.setup ? `\tsetup:${r.setup}` : '';
|
|
1391
|
+
console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}${setupCol}${r.error ? '\t' + r.error : ''}`);
|
|
1320
1392
|
}
|
|
1321
1393
|
}
|
|
1322
1394
|
process.exit(allOk ? 0 : 1);
|
|
@@ -1362,32 +1434,61 @@ skillCmd
|
|
|
1362
1434
|
|
|
1363
1435
|
skillCmd
|
|
1364
1436
|
.command('update')
|
|
1365
|
-
.description('Update a Drafted skill (--id or --slug) from stdin JSON {description?,content?,triggerPatterns?,tags?}')
|
|
1437
|
+
.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)')
|
|
1366
1438
|
.option('--id <id>', 'skill id')
|
|
1367
1439
|
.option('--slug <slug>', 'skill slug (resolved to id)')
|
|
1440
|
+
.option('--org <org>', 'fork into / resolve against this Drafted org (id or name)')
|
|
1368
1441
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1369
1442
|
.action(async (opts) => {
|
|
1370
1443
|
requireLogin();
|
|
1371
1444
|
let p;
|
|
1372
1445
|
try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
|
|
1373
1446
|
const server = getServerUrl().replace(/\/$/, '');
|
|
1447
|
+
const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
|
|
1374
1448
|
let id = opts.id;
|
|
1449
|
+
let slug = opts.slug;
|
|
1375
1450
|
if (!id && opts.slug) {
|
|
1376
|
-
const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}
|
|
1451
|
+
const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
|
|
1377
1452
|
if (!lookup.ok) { emitSkillResult(opts.format, { status: 'unresolvable', error: `slug ${opts.slug}: HTTP ${lookup.status}` }); process.exit(1); }
|
|
1378
|
-
|
|
1453
|
+
const lj = await lookup.json().catch(() => ({}));
|
|
1454
|
+
id = lj.id; slug = lj.slug;
|
|
1379
1455
|
}
|
|
1380
1456
|
if (!id) { emitSkillResult(opts.format, { status: 'error', error: 'update requires --id or --slug' }); process.exit(1); }
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1457
|
+
const payload = JSON.stringify({ name: p.name, description: p.description, content: p.content, tags: p.tags, triggerPatterns: p.triggerPatterns, setup: p.setup });
|
|
1458
|
+
const putSkill = (sid) => authFetch(`${server}/api/skills/${sid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...orgHeaders }, body: payload });
|
|
1459
|
+
|
|
1460
|
+
const res = await putSkill(id);
|
|
1385
1461
|
const data = await res.json().catch(() => ({}));
|
|
1386
|
-
if (
|
|
1387
|
-
emitSkillResult(opts.format, { status:
|
|
1388
|
-
|
|
1462
|
+
if (res.ok) {
|
|
1463
|
+
emitSkillResult(opts.format, { status: 'ok', forked: false, slug: data.slug, id: data.id, orgId: data.orgId, hash: data.hash || bundleHash({ 'SKILL.md': synthesizeSkillMd(data) }) });
|
|
1464
|
+
return;
|
|
1389
1465
|
}
|
|
1390
|
-
|
|
1466
|
+
// Auto-fork: the target is read-only (global/system or another org). Fork it into
|
|
1467
|
+
// our org, then write the copy — a single `update` transparently forks-then-writes.
|
|
1468
|
+
// The raw PUT stayed strict (403 skill_read_only); auto-fork is a tool convenience.
|
|
1469
|
+
if (res.status === 403 && data.code === 'skill_read_only') {
|
|
1470
|
+
const forkRes = await authFetch(`${server}/api/skills/${id}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orgHeaders } });
|
|
1471
|
+
let forkId;
|
|
1472
|
+
if (forkRes.ok) {
|
|
1473
|
+
forkId = (await forkRes.json().catch(() => ({}))).id;
|
|
1474
|
+
} else if (forkRes.status === 409) {
|
|
1475
|
+
// Org already owns a copy of this slug — update that one (still "forked").
|
|
1476
|
+
const ownSlug = data.slug || slug;
|
|
1477
|
+
const own = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(ownSlug)}`, { headers: orgHeaders });
|
|
1478
|
+
if (!own.ok) { emitSkillResult(opts.format, { status: 'error', error: `resolve existing fork ${ownSlug}: HTTP ${own.status}` }); process.exit(1); }
|
|
1479
|
+
forkId = (await own.json().catch(() => ({}))).id;
|
|
1480
|
+
} else {
|
|
1481
|
+
const fe = await forkRes.json().catch(() => ({}));
|
|
1482
|
+
emitSkillResult(opts.format, { status: 'error', error: `fork failed: ${fe.error || 'HTTP ' + forkRes.status}` }); process.exit(1);
|
|
1483
|
+
}
|
|
1484
|
+
const up = await putSkill(forkId);
|
|
1485
|
+
const ud = await up.json().catch(() => ({}));
|
|
1486
|
+
if (!up.ok) { emitSkillResult(opts.format, { status: 'error', error: `update fork failed: ${ud.error || 'HTTP ' + up.status}` }); process.exit(1); }
|
|
1487
|
+
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 });
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
emitSkillResult(opts.format, { status: res.status === 404 ? 'unresolvable' : 'error', error: data.error || `HTTP ${res.status}` });
|
|
1491
|
+
process.exit(1);
|
|
1391
1492
|
});
|
|
1392
1493
|
|
|
1393
1494
|
// check reports the current canonical hash for refs WITHOUT materializing — the
|
|
@@ -1437,6 +1538,25 @@ skillCmd
|
|
|
1437
1538
|
else for (const r of results) console.log(`${r.status}\t${r.ref}\t${r.slug}\t${r.hash}`);
|
|
1438
1539
|
});
|
|
1439
1540
|
|
|
1541
|
+
skillCmd
|
|
1542
|
+
.command('setup')
|
|
1543
|
+
.description('Run a source-only skill\'s setup commands (from <dir>/SKILL.md frontmatter) in <dir> — local, offline-safe')
|
|
1544
|
+
.requiredOption('--dir <dir>', 'skill directory containing SKILL.md')
|
|
1545
|
+
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1546
|
+
.action((opts) => {
|
|
1547
|
+
const r = runSkillSetup(resolve(opts.dir));
|
|
1548
|
+
if (opts.format === 'json') {
|
|
1549
|
+
console.log(JSON.stringify(r));
|
|
1550
|
+
} else if (r.skipped) {
|
|
1551
|
+
console.log(`skipped\t${r.reason || 'no setup'}`);
|
|
1552
|
+
} else if (r.ok) {
|
|
1553
|
+
console.log(`ok\tran ${r.ran.length}`);
|
|
1554
|
+
} else {
|
|
1555
|
+
console.log(`failed\t${r.failed || ''}\t${r.error || ''}`);
|
|
1556
|
+
}
|
|
1557
|
+
process.exit(r.ok ? 0 : 1);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1440
1560
|
skillCmd
|
|
1441
1561
|
.command('remove')
|
|
1442
1562
|
.description('Delete a Drafted skill by --id (used to clean up throwaway skills)')
|
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.1",
|
|
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": [
|