drafted 1.1.4 → 1.2.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.
Files changed (2) hide show
  1. package/mcp/server.mjs +103 -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;
@@ -1016,8 +1071,23 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1016
1071
  if (!lines && result.ok && result.id) {
1017
1072
  readFrameIds.add(result.id);
1018
1073
  }
1019
- return ok(result.content || result, {
1020
- structuredContent: frameStructuredContent(result),
1074
+ // Surface content as the visible text. Some Claude clients prefer
1075
+ // structuredContent over text when both are present and structured looks
1076
+ // "complete" — so include content in BOTH places to ensure the agent
1077
+ // can always see the frame's actual content, not just metadata.
1078
+ const structured = frameStructuredContent(result);
1079
+ structured.content = result.content ?? '';
1080
+ structured.size = result.size;
1081
+ structured.totalLines = result.totalLines;
1082
+ const visibleText = JSON.stringify({
1083
+ path: result.path,
1084
+ contentType: result.contentType,
1085
+ size: result.size,
1086
+ totalLines: result.totalLines,
1087
+ content: result.content ?? '',
1088
+ }, null, 2);
1089
+ return ok(visibleText, {
1090
+ structuredContent: structured,
1021
1091
  _meta: { frameHtml: result.content },
1022
1092
  });
1023
1093
  }
@@ -1026,6 +1096,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1026
1096
  if (!path) throw new Error('path required for action=write');
1027
1097
  if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
1028
1098
  if (content == null && !file_path) throw new Error('Provide content or file_path');
1099
+ const skillErr = await checkProjectSkills(getState().projectId);
1100
+ if (skillErr) return err(new Error(skillErr));
1029
1101
  const anchorErr = await checkAnchors(parseLayer(path));
1030
1102
  if (anchorErr) return err(new Error(anchorErr));
1031
1103
  const parts = path.replace(/^\/+/, '').split('/');
@@ -1061,6 +1133,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1061
1133
  const { path, operations } = args;
1062
1134
  if (!path) throw new Error('path required for action=edit');
1063
1135
  if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
1136
+ const skillErr = await checkProjectSkills(getState().projectId);
1137
+ if (skillErr) return err(new Error(skillErr));
1064
1138
  const anchorErr = await checkAnchors(parseLayer(path));
1065
1139
  if (anchorErr) return err(new Error(anchorErr));
1066
1140
  const result = await api('POST', '/api/fs/edit', { path, operations });
@@ -1072,6 +1146,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1072
1146
  case 'mv': {
1073
1147
  const { from, to } = args;
1074
1148
  if (!from || !to) throw new Error('from and to required for action=mv');
1149
+ const skillErr = await checkProjectSkills(getState().projectId);
1150
+ if (skillErr) return err(new Error(skillErr));
1075
1151
  const fromErr = await checkAnchors(parseLayer(from));
1076
1152
  if (fromErr) return err(new Error(fromErr));
1077
1153
  const toErr = await checkAnchors(parseLayer(to));
@@ -1082,6 +1158,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1082
1158
  const { path, anchored } = args;
1083
1159
  if (!path) throw new Error('path required for action=anchor');
1084
1160
  if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
1161
+ const skillErr = await checkProjectSkills(getState().projectId);
1162
+ if (skillErr) return err(new Error(skillErr));
1085
1163
  return ok(await api('POST', '/api/fs/anchor', { path, anchored }));
1086
1164
  }
1087
1165
  case 'search': {
@@ -1174,6 +1252,8 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
1174
1252
  path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
1175
1253
  }, async ({ path }) => {
1176
1254
  try {
1255
+ const skillErr = await checkProjectSkills(getState().projectId);
1256
+ if (skillErr) return err(new Error(skillErr));
1177
1257
  const anchorErr = await checkAnchors(parseLayer(path));
1178
1258
  if (anchorErr) return err(new Error(anchorErr));
1179
1259
  const clean = path.replace(/^\/+|\/+$/g, '');
@@ -1303,6 +1383,8 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
1303
1383
  const projectId = getState().projectId;
1304
1384
  if (!projectId) throw new Error('No active project. Call project(action="open") first.');
1305
1385
  if (action === 'upload') {
1386
+ const skillErr = await checkProjectSkills(projectId);
1387
+ if (skillErr) return err(new Error(skillErr));
1306
1388
  const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
1307
1389
  if (!asset_path) throw new Error('asset_path is required for action=upload');
1308
1390
  if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
@@ -1351,6 +1433,8 @@ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight t
1351
1433
  layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
1352
1434
  }, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
1353
1435
  try {
1436
+ const skillErr = await checkProjectSkills(getState().projectId);
1437
+ if (skillErr) return err(new Error(skillErr));
1354
1438
  const body = { text, shape };
1355
1439
  if (layer) body.layer = layer;
1356
1440
  if (lane) body.lane = lane;
@@ -1375,6 +1459,8 @@ tool('group', 'Create a group (swim lane / region) on the surface. Groups are la
1375
1459
  order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
1376
1460
  }, async ({ label, color, fill, layer, lane, order }) => {
1377
1461
  try {
1462
+ const skillErr = await checkProjectSkills(getState().projectId);
1463
+ if (skillErr) return err(new Error(skillErr));
1378
1464
  const body = { label };
1379
1465
  if (color) body.color = color;
1380
1466
  if (fill) body.fill = fill;
@@ -1397,6 +1483,8 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
1397
1483
  connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
1398
1484
  }, async (args) => {
1399
1485
  try {
1486
+ const skillErr = await checkProjectSkills(getState().projectId);
1487
+ if (skillErr) return err(new Error(skillErr));
1400
1488
  const { action } = args;
1401
1489
  if (action === 'connect') {
1402
1490
  const { source, target, label, type = 'arrow-forward', color } = args;
@@ -1438,6 +1526,8 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
1438
1526
  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
1527
  }, async ({ direction, groups }) => {
1440
1528
  try {
1529
+ const skillErr = await checkProjectSkills(getState().projectId);
1530
+ if (skillErr) return err(new Error(skillErr));
1441
1531
  const body = { direction };
1442
1532
  if (groups) body.groups = true;
1443
1533
  const result = await api('POST', '/api/layout', body);
@@ -1492,7 +1582,9 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1492
1582
  if (!skill) throw new Error('skill (ID or slug) is required for action=load');
1493
1583
  const isUuid = /^[a-f0-9-]{36}$/.test(skill);
1494
1584
  const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
1495
- return ok(await api('GET', endpoint));
1585
+ const result = await api('GET', endpoint);
1586
+ if (result?.id) loadedSkillIds.add(result.id);
1587
+ return ok(result);
1496
1588
  }
1497
1589
  case 'list': {
1498
1590
  if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
@@ -1527,13 +1619,17 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1527
1619
  const { skillId } = args;
1528
1620
  if (!skillId) throw new Error('skillId required for action=attach');
1529
1621
  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 }));
1622
+ const result = await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId });
1623
+ invalidateProjectSkillsCache(getState().projectId);
1624
+ return ok(result);
1531
1625
  }
1532
1626
  case 'detach': {
1533
1627
  const { skillId } = args;
1534
1628
  if (!skillId) throw new Error('skillId required for action=detach');
1535
1629
  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}`));
1630
+ const result = await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`);
1631
+ invalidateProjectSkillsCache(getState().projectId);
1632
+ return ok(result);
1537
1633
  }
1538
1634
  case 'favorite': {
1539
1635
  const { skillId } = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "description": "Drafted CLI - Design preview server for Claude agents",
5
5
  "type": "module",
6
6
  "files": [