drafted 1.1.3 → 1.2.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/mcp/server.mjs +88 -7
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -478,6 +478,43 @@ async function checkAnchors(layer) {
478
478
  `Read all anchored frames first, then retry your operation.`;
479
479
  }
480
480
 
481
+ // ── Project skill enforcement ─────────────────────────────────────
482
+ // Skills attached to a project must be loaded by the agent before any
483
+ // mutating operation in that project. Same shape as anchor enforcement.
484
+ const loadedSkillIds = new Set();
485
+ const projectSkillsCache = new Map(); // projectId -> { data, time }
486
+
487
+ async function getProjectSkills(projectId) {
488
+ if (!projectId) return [];
489
+ const cached = projectSkillsCache.get(projectId);
490
+ if (cached && Date.now() - cached.time < 10000) return cached.data;
491
+ try {
492
+ const result = await api('GET', `/api/projects/${projectId}/skills`);
493
+ const skills = Array.isArray(result?.skills) ? result.skills : (Array.isArray(result) ? result : []);
494
+ projectSkillsCache.set(projectId, { data: skills, time: Date.now() });
495
+ return skills;
496
+ } catch {
497
+ return [];
498
+ }
499
+ }
500
+
501
+ function invalidateProjectSkillsCache(projectId) {
502
+ if (projectId) projectSkillsCache.delete(projectId);
503
+ }
504
+
505
+ async function checkProjectSkills(projectId) {
506
+ if (!projectId) return null;
507
+ const skills = await getProjectSkills(projectId);
508
+ if (skills.length === 0) return null;
509
+ const unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
510
+ if (unloaded.length === 0) return null;
511
+
512
+ const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
513
+ return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
514
+ `Unloaded skills:\n${lines.join('\n')}\n\n` +
515
+ `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
516
+ }
517
+
481
518
  // ── CLI passthrough (for login/start/stop only) ───────────────────
482
519
 
483
520
  function runCLI(command, args = [], options = {}) {
@@ -602,7 +639,7 @@ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL i
602
639
 
603
640
  // ── Project management tools (direct HTTP) ────────────────────────
604
641
 
605
- tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (switch the active project; required before reading/writing frames), create (new project, optionally from a template), update (change name/folder/description/layers), move (transfer to another org). The org switches automatically when you open a project.', {
642
+ tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (switch the active project; required before reading/writing frames), create (new project, optionally from a template), update (change name/folder/description/layers), move (transfer to another org). The org switches automatically when you open a project. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, shape, group, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
606
643
  action: z.enum(['list', 'open', 'create', 'update', 'move']).describe('Operation to perform.'),
607
644
  projectId: z.string().optional().describe('[open|update|move] project ID. Get IDs from action=list.'),
608
645
  name: z.string().optional().describe('[create|update] project name'),
@@ -683,11 +720,27 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
683
720
  const full = await api('GET', `/api/skills/${s.id}`);
684
721
  s.content = full.content;
685
722
  s.files = full.files || [];
723
+ // Skill content was inlined in this response — count it as loaded
724
+ // so the project-skill gate doesn't immediately demand a re-load.
725
+ loadedSkillIds.add(s.id);
686
726
  } catch { /* skip */ }
687
727
  }
688
728
  }
689
729
 
690
- return ok({ ...result, url, opened: true, navigated, skills: projectSkillsList });
730
+ // Refresh project-skill cache so the gate uses fresh data after open
731
+ invalidateProjectSkillsCache(projectId);
732
+
733
+ const responseExtras = { url, opened: true, navigated, skills: projectSkillsList };
734
+ if (projectSkillsList.length > 3) {
735
+ const unloaded = projectSkillsList.filter(s => !loadedSkillIds.has(s.id));
736
+ if (unloaded.length > 0) {
737
+ responseExtras.skillGateNotice =
738
+ `This project has ${projectSkillsList.length} attached skills (too many to auto-inline). ` +
739
+ `You MUST call skill(action="load") for each before any mutation. Unloaded: ` +
740
+ unloaded.map(s => s.slug || s.id).join(', ');
741
+ }
742
+ }
743
+ return ok({ ...result, ...responseExtras });
691
744
  }
692
745
  case 'create': {
693
746
  const { name, description, templateSlug } = args;
@@ -862,6 +915,8 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
862
915
  try {
863
916
  const { action, projectId } = args;
864
917
  if (!projectId) throw new Error('projectId is required');
918
+ const skillErr = await checkProjectSkills(projectId);
919
+ if (skillErr) return err(new Error(skillErr));
865
920
  switch (action) {
866
921
  case 'add': {
867
922
  const { key, label, type, width, height, description, prompt } = args;
@@ -1026,6 +1081,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1026
1081
  if (!path) throw new Error('path required for action=write');
1027
1082
  if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
1028
1083
  if (content == null && !file_path) throw new Error('Provide content or file_path');
1084
+ const skillErr = await checkProjectSkills(getState().projectId);
1085
+ if (skillErr) return err(new Error(skillErr));
1029
1086
  const anchorErr = await checkAnchors(parseLayer(path));
1030
1087
  if (anchorErr) return err(new Error(anchorErr));
1031
1088
  const parts = path.replace(/^\/+/, '').split('/');
@@ -1061,6 +1118,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1061
1118
  const { path, operations } = args;
1062
1119
  if (!path) throw new Error('path required for action=edit');
1063
1120
  if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
1121
+ const skillErr = await checkProjectSkills(getState().projectId);
1122
+ if (skillErr) return err(new Error(skillErr));
1064
1123
  const anchorErr = await checkAnchors(parseLayer(path));
1065
1124
  if (anchorErr) return err(new Error(anchorErr));
1066
1125
  const result = await api('POST', '/api/fs/edit', { path, operations });
@@ -1072,6 +1131,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1072
1131
  case 'mv': {
1073
1132
  const { from, to } = args;
1074
1133
  if (!from || !to) throw new Error('from and to required for action=mv');
1134
+ const skillErr = await checkProjectSkills(getState().projectId);
1135
+ if (skillErr) return err(new Error(skillErr));
1075
1136
  const fromErr = await checkAnchors(parseLayer(from));
1076
1137
  if (fromErr) return err(new Error(fromErr));
1077
1138
  const toErr = await checkAnchors(parseLayer(to));
@@ -1082,6 +1143,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1082
1143
  const { path, anchored } = args;
1083
1144
  if (!path) throw new Error('path required for action=anchor');
1084
1145
  if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
1146
+ const skillErr = await checkProjectSkills(getState().projectId);
1147
+ if (skillErr) return err(new Error(skillErr));
1085
1148
  return ok(await api('POST', '/api/fs/anchor', { path, anchored }));
1086
1149
  }
1087
1150
  case 'search': {
@@ -1174,6 +1237,8 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
1174
1237
  path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
1175
1238
  }, async ({ path }) => {
1176
1239
  try {
1240
+ const skillErr = await checkProjectSkills(getState().projectId);
1241
+ if (skillErr) return err(new Error(skillErr));
1177
1242
  const anchorErr = await checkAnchors(parseLayer(path));
1178
1243
  if (anchorErr) return err(new Error(anchorErr));
1179
1244
  const clean = path.replace(/^\/+|\/+$/g, '');
@@ -1303,6 +1368,8 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
1303
1368
  const projectId = getState().projectId;
1304
1369
  if (!projectId) throw new Error('No active project. Call project(action="open") first.');
1305
1370
  if (action === 'upload') {
1371
+ const skillErr = await checkProjectSkills(projectId);
1372
+ if (skillErr) return err(new Error(skillErr));
1306
1373
  const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
1307
1374
  if (!asset_path) throw new Error('asset_path is required for action=upload');
1308
1375
  if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
@@ -1351,6 +1418,8 @@ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight t
1351
1418
  layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
1352
1419
  }, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
1353
1420
  try {
1421
+ const skillErr = await checkProjectSkills(getState().projectId);
1422
+ if (skillErr) return err(new Error(skillErr));
1354
1423
  const body = { text, shape };
1355
1424
  if (layer) body.layer = layer;
1356
1425
  if (lane) body.lane = lane;
@@ -1375,6 +1444,8 @@ tool('group', 'Create a group (swim lane / region) on the surface. Groups are la
1375
1444
  order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
1376
1445
  }, async ({ label, color, fill, layer, lane, order }) => {
1377
1446
  try {
1447
+ const skillErr = await checkProjectSkills(getState().projectId);
1448
+ if (skillErr) return err(new Error(skillErr));
1378
1449
  const body = { label };
1379
1450
  if (color) body.color = color;
1380
1451
  if (fill) body.fill = fill;
@@ -1397,6 +1468,8 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
1397
1468
  connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
1398
1469
  }, async (args) => {
1399
1470
  try {
1471
+ const skillErr = await checkProjectSkills(getState().projectId);
1472
+ if (skillErr) return err(new Error(skillErr));
1400
1473
  const { action } = args;
1401
1474
  if (action === 'connect') {
1402
1475
  const { source, target, label, type = 'arrow-forward', color } = args;
@@ -1438,6 +1511,8 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
1438
1511
  groups: z.boolean().optional().default(false).describe('Enable group clustering. When true, shapes assigned to a group (via the group param) are clustered together, and group frames are resized to enclose their members.'),
1439
1512
  }, async ({ direction, groups }) => {
1440
1513
  try {
1514
+ const skillErr = await checkProjectSkills(getState().projectId);
1515
+ if (skillErr) return err(new Error(skillErr));
1441
1516
  const body = { direction };
1442
1517
  if (groups) body.groups = true;
1443
1518
  const result = await api('POST', '/api/layout', body);
@@ -1492,7 +1567,9 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1492
1567
  if (!skill) throw new Error('skill (ID or slug) is required for action=load');
1493
1568
  const isUuid = /^[a-f0-9-]{36}$/.test(skill);
1494
1569
  const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
1495
- return ok(await api('GET', endpoint));
1570
+ const result = await api('GET', endpoint);
1571
+ if (result?.id) loadedSkillIds.add(result.id);
1572
+ return ok(result);
1496
1573
  }
1497
1574
  case 'list': {
1498
1575
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
@@ -1527,13 +1604,17 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1527
1604
  const { skillId } = args;
1528
1605
  if (!skillId) throw new Error('skillId required for action=attach');
1529
1606
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
1530
- return ok(await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId }));
1607
+ const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
1608
+ invalidateProjectSkillsCache(getState().projectId);
1609
+ return ok(result);
1531
1610
  }
1532
1611
  case 'detach': {
1533
1612
  const { skillId } = args;
1534
1613
  if (!skillId) throw new Error('skillId required for action=detach');
1535
1614
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
1536
- return ok(await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`));
1615
+ const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
1616
+ invalidateProjectSkillsCache(getState().projectId);
1617
+ return ok(result);
1537
1618
  }
1538
1619
  case 'favorite': {
1539
1620
  const { skillId } = args;
@@ -1601,7 +1682,7 @@ async function main() {
1601
1682
  sessionIdGenerator: () => randomUUID(),
1602
1683
  });
1603
1684
 
1604
- await server.connect(transport);
1685
+ await mcpServer.connect(transport);
1605
1686
 
1606
1687
  const httpServer = createServer((req, res) => {
1607
1688
  if (req.url === '/mcp') {
@@ -1618,7 +1699,7 @@ async function main() {
1618
1699
  });
1619
1700
  } else {
1620
1701
  const transport = new StdioServerTransport();
1621
- await server.connect(transport);
1702
+ await mcpServer.connect(transport);
1622
1703
  }
1623
1704
  }
1624
1705
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Drafted CLI - Design preview server for Claude agents",
5
5
  "type": "module",
6
6
  "files": [