slyplan-mcp 1.2.2 → 1.4.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/dist/cli.js CHANGED
@@ -1,82 +1,218 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import * as http from 'http';
4
5
  import * as readline from 'readline';
5
6
  import { createClient } from '@supabase/supabase-js';
7
+ import { exec } from 'child_process';
6
8
  const SUPABASE_URL = 'https://omfzpkwtuzucwwxmyuqt.supabase.co';
7
9
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9tZnpwa3d0dXp1Y3d3eG15dXF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5MjMwNDIsImV4cCI6MjA4NjQ5OTA0Mn0.KXGoUez7M45RtFM9qR7mjzGX6UhlaRE-gggAJxSkIHY';
8
- const PRE_DIRECTIVE = "ACTION REQUIRED: If you haven't set a SlyPlan project this session, call list_projects + set_project NOW. Then call search to find the node for your current task and add_to_work_mode. If no node exists, create one with add_node first. Do this BEFORE continuing with other work.";
9
- const POST_DIRECTIVE = "ACTION REQUIRED: You just made changes. Call search to find the SlyPlan node for this task, then update_node with current progress. Skip only for trivial edits (typos, config tweaks). Never mark done without asking the user first.";
10
- const POST_HOOK_FILE_CONTENT = `#!/usr/bin/env node
11
- const output = JSON.stringify({
12
- suppressOutput: true,
13
- hookSpecificOutput: {
14
- hookEventName: "PostToolUse",
15
- additionalContext: POST_DIRECTIVE_PLACEHOLDER
10
+ const SLYPLAN_URL = 'https://slyplan.com';
11
+ // Smart transcript-aware hook: checks if set_project + add_to_work_mode have been called.
12
+ // Silent when OK, reminds only when needed.
13
+ const PRE_HOOK_FILE_CONTENT = `#!/usr/bin/env node
14
+ const fs = require('fs');
15
+
16
+ let input = '';
17
+ process.stdin.setEncoding('utf8');
18
+ process.stdin.on('data', (chunk) => { input += chunk; });
19
+ process.stdin.on('end', () => {
20
+ try {
21
+ const data = JSON.parse(input);
22
+
23
+ if (!data.transcript_path) {
24
+ const output = JSON.stringify({
25
+ suppressOutput: true,
26
+ hookSpecificOutput: {
27
+ hookEventName: "PreToolCall",
28
+ additionalContext: "BLOCKED: Call list_projects + set_project before doing any work. This is not optional."
29
+ }
30
+ });
31
+ process.stdout.write(output);
32
+ process.exit(0);
33
+ }
34
+
35
+ const transcriptPath = data.transcript_path.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '');
36
+ if (!fs.existsSync(transcriptPath)) { process.exit(0); }
37
+
38
+ const content = fs.readFileSync(transcriptPath, 'utf8');
39
+ const lines = content.split('\\n').filter(Boolean);
40
+
41
+ let hasSetProject = false;
42
+ let hasWorkModeNode = false;
43
+
44
+ for (const line of lines) {
45
+ try {
46
+ const entry = JSON.parse(line);
47
+ if (entry.type === 'assistant' && entry.message && Array.isArray(entry.message.content)) {
48
+ for (const block of entry.message.content) {
49
+ if (block.type !== 'tool_use') continue;
50
+ const name = block.name || '';
51
+ if (name.includes('set_project')) hasSetProject = true;
52
+ if (name.includes('add_to_work_mode')) hasWorkModeNode = true;
53
+ }
54
+ }
55
+ } catch {}
56
+ }
57
+
58
+ if (!hasSetProject) {
59
+ const output = JSON.stringify({
60
+ suppressOutput: true,
61
+ hookSpecificOutput: {
62
+ hookEventName: "PreToolCall",
63
+ additionalContext: "BLOCKED: Call list_projects + set_project before doing any work. This is not optional."
64
+ }
65
+ });
66
+ process.stdout.write(output);
67
+ process.exit(0);
68
+ }
69
+
70
+ if (!hasWorkModeNode) {
71
+ const output = JSON.stringify({
72
+ suppressOutput: true,
73
+ hookSpecificOutput: {
74
+ hookEventName: "PreToolCall",
75
+ additionalContext: "SYNC NOW: You have a project set but no node in work mode. Call search + add_to_work_mode before continuing."
76
+ }
77
+ });
78
+ process.stdout.write(output);
79
+ process.exit(0);
80
+ }
81
+
82
+ process.exit(0);
83
+ } catch (e) {
84
+ process.exit(0);
16
85
  }
17
86
  });
18
- process.stdout.write(output);
19
- process.exit(0);
20
- `.replace('POST_DIRECTIVE_PLACEHOLDER', JSON.stringify(POST_DIRECTIVE));
21
- const PRE_HOOK_FILE_CONTENT = `#!/usr/bin/env node
22
- const output = JSON.stringify({
23
- suppressOutput: true,
24
- hookSpecificOutput: {
25
- hookEventName: "PreToolCall",
26
- additionalContext: PRE_DIRECTIVE_PLACEHOLDER
87
+ `;
88
+ // Smart transcript-aware hook: counts file changes since last SlyPlan sync.
89
+ // Silent when synced, reminds with count when unsynced.
90
+ const POST_HOOK_FILE_CONTENT = `#!/usr/bin/env node
91
+ const fs = require('fs');
92
+
93
+ let input = '';
94
+ process.stdin.setEncoding('utf8');
95
+ process.stdin.on('data', (chunk) => { input += chunk; });
96
+ process.stdin.on('end', () => {
97
+ try {
98
+ const data = JSON.parse(input);
99
+
100
+ if (!data.transcript_path) { process.exit(0); }
101
+
102
+ const transcriptPath = data.transcript_path.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '');
103
+ if (!fs.existsSync(transcriptPath)) { process.exit(0); }
104
+
105
+ const content = fs.readFileSync(transcriptPath, 'utf8');
106
+ const lines = content.split('\\n').filter(Boolean);
107
+
108
+ const FILE_TOOLS = ['Write', 'Edit', 'NotebookEdit'];
109
+ const SLYPLAN_SYNC_TOOLS = ['update_node', 'add_node', 'add_to_work_mode'];
110
+
111
+ let lastFileChangeIdx = -1;
112
+ let lastSlyplanSyncIdx = -1;
113
+ let fileChangeCount = 0;
114
+
115
+ for (let i = 0; i < lines.length; i++) {
116
+ try {
117
+ const entry = JSON.parse(lines[i]);
118
+ if (entry.type === 'assistant' && entry.message && Array.isArray(entry.message.content)) {
119
+ for (const block of entry.message.content) {
120
+ if (block.type !== 'tool_use') continue;
121
+ const name = block.name || '';
122
+
123
+ if (FILE_TOOLS.includes(name)) {
124
+ lastFileChangeIdx = i;
125
+ fileChangeCount++;
126
+ }
127
+ if (name === 'Bash' && block.input && typeof block.input.command === 'string') {
128
+ const cmd = block.input.command;
129
+ if (cmd.includes('git commit') || cmd.includes('mkdir')) {
130
+ lastFileChangeIdx = i;
131
+ fileChangeCount++;
132
+ }
133
+ }
134
+ for (const st of SLYPLAN_SYNC_TOOLS) {
135
+ if (name.includes(st)) lastSlyplanSyncIdx = i;
136
+ }
137
+ }
138
+ }
139
+ } catch {}
140
+ }
141
+
142
+ if (fileChangeCount === 0) { process.exit(0); }
143
+ if (lastSlyplanSyncIdx > lastFileChangeIdx) { process.exit(0); }
144
+
145
+ const output = JSON.stringify({
146
+ suppressOutput: true,
147
+ hookSpecificOutput: {
148
+ hookEventName: "PostToolUse",
149
+ additionalContext: "SYNC NOW: " + fileChangeCount + " file change(s) since last SlyPlan sync. Call update_node with current progress before continuing."
150
+ }
151
+ });
152
+ process.stdout.write(output);
153
+ process.exit(0);
154
+ } catch (e) {
155
+ process.exit(0);
27
156
  }
28
157
  });
29
- process.stdout.write(output);
30
- process.exit(0);
31
- `.replace('PRE_DIRECTIVE_PLACEHOLDER', JSON.stringify(PRE_DIRECTIVE));
158
+ `;
32
159
  const CLAUDEMD_SECTION = `
33
- ## SlyPlan SyncOBLIGATORISKE REGLER
160
+ ## Hook ComplianceHARD REQUIREMENTS
161
+
162
+ Hook messages containing **"BLOCKED"** or **"SYNC NOW"** are HARD REQUIREMENTS, not suggestions.
34
163
 
35
- DU SKAL holde SlyPlan oppdatert. Dette er ikke valgfritt. Følg disse reglene:
164
+ - **BLOCKED:** You MUST stop what you're doing and follow the instruction BEFORE doing anything else. No exceptions — regardless of whether you're mid-workflow, running a GSD command, or in a multi-step task.
165
+ - **SYNC NOW:** You MUST call \`update_node\` with updated progress BEFORE continuing with the next tool call. Max one file change between each sync.
166
+ - Hooks are transcript-aware — they only fire when sync is actually missing. If you see a message, it means you have NOT done the work.
167
+ - Ignoring hooks because you're "following a recipe" or "in the middle of something" is NEVER a valid reason.
36
168
 
37
- ### 1. Sett prosjekt FØRST
38
- - **Ved start av hver sesjon:** Kall \`list_projects\` + \`set_project\` FØR du gjør noe annet.
39
- - Match prosjekt basert på cwd-mappenavn eller brukerens kontekst.
40
- - Hvis tvetydig, spør brukeren. Cache prosjekt-ID resten av sesjonen.
169
+ ## SlyPlan Sync MANDATORY RULES
41
170
 
42
- ### 2. Finn eller opprett node FØR du begynner arbeid
43
- - Kall \`search\` for å finne noden som matcher oppgaven din.
44
- - Hvis ingen node finnes: opprett med \`add_node\` umiddelbart.
45
- - Kall \`add_to_work_mode\` på noden FØR du starter med Edit/Write/Bash.
46
- - **Du skal ALDRI starte arbeid uten en aktiv work mode node.**
171
+ You MUST keep SlyPlan updated. This is not optional. Follow these rules:
47
172
 
48
- ### 3. Oppdater progress UNDERVEIS
49
- - Etter hver meningsfull endring: kall \`update_node\` med oppdatert \`progress\` (0-100).
50
- - Oppdater \`description\` med hva som faktisk ble gjort.
51
- - Hopp over for trivielle endringer (typos, config-tweaks).
173
+ ### 1. Set project FIRST
174
+ - **At the start of every session:** Call \`list_projects\` + \`set_project\` BEFORE doing anything else.
175
+ - Match the project based on the cwd folder name or user context.
176
+ - If ambiguous, ask the user. Cache the project ID for the rest of the session.
52
177
 
53
- ### 4. ALDRI merk noe som done uten å spørre
54
- - Bruk \`AskUserQuestion\` med valg: "Yes, mark as done" / "Not yet" / "Skip".
55
- - **Ingen unntak.** Auto-marking done er forbudt.
178
+ ### 2. Find or create a node BEFORE starting work
179
+ - Call \`search\` to find the node matching your task.
180
+ - If no node exists: create one with \`add_node\` immediately.
181
+ - Call \`add_to_work_mode\` on the node BEFORE starting with Edit/Write/Bash.
182
+ - **You must NEVER start work without an active work mode node.**
56
183
 
57
- ### 5. Rydd opp work mode når du er ferdig
58
- - Kall \`remove_from_work_mode\` når du er ferdig med en node eller bytter til annen oppgave.
184
+ ### 3. Update progress AS YOU GO
185
+ - After each meaningful change: call \`update_node\` with updated \`progress\` (0-100).
186
+ - Update \`description\` with what was actually done.
187
+ - Skip for trivial changes (typos, config tweaks).
59
188
 
60
- ### 6. Alt arbeid skal reflekteres
61
- - Gjelder ALL type arbeid. Hvis kode endret seg, SKAL SlyPlan reflektere det.
62
- - Små fix = \`plan\`-node under eksisterende phase. Stor feature = ny \`phase\` med \`plan\`-children.
189
+ ### 4. NEVER mark anything as done without asking
190
+ - Use \`AskUserQuestion\` with choices: "Yes, mark as done" / "Not yet" / "Skip".
191
+ - **No exceptions.** Auto-marking done is forbidden.
63
192
 
64
- ### Node-hierarki
193
+ ### 5. Clean up work mode when done
194
+ - Call \`remove_from_work_mode\` when you're done with a node or switching to another task.
195
+
196
+ ### 6. All work must be reflected
197
+ - Applies to ALL types of work. If code changed, SlyPlan MUST reflect it.
198
+ - Small fixes = \`plan\` node under existing phase. Large features = new \`phase\` with \`plan\` children.
199
+
200
+ ### Node hierarchy
65
201
  \`project\` > \`category\` > \`phase\` > \`plan\`
66
202
 
67
- ### Ytelsesregler
68
- - ALDRI bruk \`get_tree\` (returnerer ~18k tokens). Bruk \`search\` i stedet.
69
-
70
- ### MCP-verktøy
71
- | Verktøy | Bruk |
72
- |---------|------|
73
- | \`list_projects\` | Finn prosjekter (sesjonstart) |
74
- | \`set_project\` | Velg aktivt prosjekt |
75
- | \`search\` | Finn noder etter navn/innhold |
76
- | \`add_node\` | Opprett ny node (project/category/phase/plan) |
77
- | \`update_node\` | Oppdater status, progress, description |
78
- | \`add_to_work_mode\` | Marker node som aktivt arbeid |
79
- | \`remove_from_work_mode\` | Fjern fra aktivt arbeid |
203
+ ### Performance rules
204
+ - NEVER use \`get_tree\` (returns ~18k tokens). Use \`search\` instead.
205
+
206
+ ### MCP tools
207
+ | Tool | Usage |
208
+ |------|-------|
209
+ | \`list_projects\` | Find projects (session start) |
210
+ | \`set_project\` | Select active project |
211
+ | \`search\` | Find nodes by name/content |
212
+ | \`add_node\` | Create new node (project/category/phase/plan) |
213
+ | \`update_node\` | Update status, progress, description |
214
+ | \`add_to_work_mode\` | Mark node as active work |
215
+ | \`remove_from_work_mode\` | Remove from active work |
80
216
  `;
81
217
  // --- Helpers ---
82
218
  function log(msg) {
@@ -137,7 +273,7 @@ async function validateCredentials(email, password) {
137
273
  const { data, error } = await client.auth.signInWithPassword({ email, password });
138
274
  if (error)
139
275
  return { ok: false, error: error.message };
140
- return { ok: true, userEmail: data.user?.email ?? email };
276
+ return { ok: true, userEmail: data.user?.email ?? email, refreshToken: data.session?.refresh_token };
141
277
  }
142
278
  function readJsonFile(filePath) {
143
279
  try {
@@ -152,8 +288,108 @@ function writeJsonFile(filePath, data) {
152
288
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
153
289
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
154
290
  }
291
+ // --- Browser Auth ---
292
+ function openBrowser(url) {
293
+ const platform = process.platform;
294
+ const cmd = platform === 'win32' ? 'start ""'
295
+ : platform === 'darwin' ? 'open'
296
+ : 'xdg-open';
297
+ exec(`${cmd} "${url}"`);
298
+ }
299
+ const SUCCESS_HTML = `<!DOCTYPE html>
300
+ <html lang="en">
301
+ <head><meta charset="utf-8"><title>SlyPlan - Authorized</title>
302
+ <style>
303
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
304
+ background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
305
+ .card { text-align: center; padding: 3rem; }
306
+ .check { width: 64px; height: 64px; margin: 0 auto 1.5rem; color: #22c55e; }
307
+ h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
308
+ p { color: #a1a1aa; font-size: 0.875rem; margin: 0; }
309
+ </style>
310
+ </head>
311
+ <body>
312
+ <div class="card">
313
+ <svg class="check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
314
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
315
+ </svg>
316
+ <h1>Authorized!</h1>
317
+ <p>You can close this tab and return to your terminal.</p>
318
+ </div>
319
+ </body>
320
+ </html>`;
321
+ const ERROR_HTML = (msg) => `<!DOCTYPE html>
322
+ <html lang="en">
323
+ <head><meta charset="utf-8"><title>SlyPlan - Error</title>
324
+ <style>
325
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
326
+ background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
327
+ .card { text-align: center; padding: 3rem; }
328
+ .icon { width: 64px; height: 64px; margin: 0 auto 1.5rem; color: #ef4444; }
329
+ h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
330
+ p { color: #a1a1aa; font-size: 0.875rem; margin: 0; }
331
+ </style>
332
+ </head>
333
+ <body>
334
+ <div class="card">
335
+ <svg class="icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
336
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
337
+ </svg>
338
+ <h1>Authorization Failed</h1>
339
+ <p>${msg}</p>
340
+ </div>
341
+ </body>
342
+ </html>`;
343
+ function startLocalAuthServer() {
344
+ return new Promise((resolve, reject) => {
345
+ const server = http.createServer((req, res) => {
346
+ const url = new URL(req.url ?? '/', `http://localhost`);
347
+ if (url.pathname === '/callback') {
348
+ const refreshToken = url.searchParams.get('refresh_token');
349
+ const email = url.searchParams.get('email') ?? '';
350
+ if (refreshToken) {
351
+ res.writeHead(200, { 'Content-Type': 'text/html' });
352
+ res.end(SUCCESS_HTML);
353
+ server.close();
354
+ resolve({ refreshToken, email });
355
+ }
356
+ else {
357
+ res.writeHead(400, { 'Content-Type': 'text/html' });
358
+ res.end(ERROR_HTML('Missing refresh token in callback.'));
359
+ server.close();
360
+ reject(new Error('Missing refresh token in callback'));
361
+ }
362
+ }
363
+ else {
364
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
365
+ res.end('Not found');
366
+ }
367
+ });
368
+ // Listen on random port
369
+ server.listen(0, '127.0.0.1', () => {
370
+ const addr = server.address();
371
+ if (!addr || typeof addr === 'string') {
372
+ reject(new Error('Failed to start local server'));
373
+ return;
374
+ }
375
+ const port = addr.port;
376
+ const callbackUrl = `http://localhost:${port}/callback`;
377
+ const authUrl = `${SLYPLAN_URL}/cli/auth?port=${port}&callback=${encodeURIComponent(callbackUrl)}`;
378
+ log(` Opening browser...`);
379
+ log(` If it doesn't open, visit:`);
380
+ log(` ${authUrl}`);
381
+ log('');
382
+ openBrowser(authUrl);
383
+ });
384
+ // Timeout after 120 seconds
385
+ setTimeout(() => {
386
+ server.close();
387
+ reject(new Error('Authorization timed out after 120 seconds. Please try again.'));
388
+ }, 120000);
389
+ });
390
+ }
155
391
  // --- Settings Merge ---
156
- function mergeSettings(existing, email, password) {
392
+ function mergeSettings(existing, refreshToken) {
157
393
  const result = { ...existing };
158
394
  // MCP server config
159
395
  if (!result.mcpServers)
@@ -162,8 +398,7 @@ function mergeSettings(existing, email, password) {
162
398
  command: 'npx',
163
399
  args: ['-y', 'slyplan-mcp@latest'],
164
400
  env: {
165
- SLYPLAN_EMAIL: email,
166
- SLYPLAN_PASSWORD: password,
401
+ SLYPLAN_REFRESH_TOKEN: refreshToken,
167
402
  },
168
403
  };
169
404
  // Hook config
@@ -256,41 +491,39 @@ async function runSetup() {
256
491
  if (args[i] === '--password' && args[i + 1])
257
492
  flagPassword = args[i + 1];
258
493
  }
259
- let email = flagEmail;
260
- let password = flagPassword;
261
- let authenticated = false;
262
- // Interactive credential prompts with retry
263
- for (let attempt = 0; attempt < 3; attempt++) {
264
- if (!email)
265
- email = await promptUser(' Email: ');
266
- if (!password)
267
- password = await promptUser(' Password: ', true);
268
- if (!email || !password) {
269
- log(' Email and password are required.');
270
- email = '';
271
- password = '';
272
- continue;
273
- }
274
- process.stderr.write(' Validating... ');
275
- const result = await validateCredentials(email, password);
276
- if (result.ok) {
277
- log(`OK (signed in as ${result.userEmail})`);
278
- authenticated = true;
279
- break;
280
- }
281
- else {
494
+ let refreshToken = '';
495
+ let userEmail = '';
496
+ if (flagEmail && flagPassword) {
497
+ // CI/scripted mode: validate credentials and extract refresh token
498
+ process.stderr.write(' Validating credentials... ');
499
+ const result = await validateCredentials(flagEmail, flagPassword);
500
+ if (!result.ok) {
282
501
  log(`FAILED (${result.error})`);
283
- if (attempt < 2) {
284
- log(' Try again:');
285
- email = '';
286
- password = '';
287
- }
502
+ process.exit(1);
288
503
  }
504
+ if (!result.refreshToken) {
505
+ log('FAILED (no refresh token returned)');
506
+ process.exit(1);
507
+ }
508
+ log(`OK (signed in as ${result.userEmail})`);
509
+ refreshToken = result.refreshToken;
510
+ userEmail = result.userEmail ?? flagEmail;
289
511
  }
290
- if (!authenticated) {
512
+ else {
513
+ // Interactive mode: browser-based auth
514
+ log(' Waiting for authorization in browser...');
291
515
  log('');
292
- log(' Authentication failed after 3 attempts. Aborting.');
293
- process.exit(1);
516
+ try {
517
+ const result = await startLocalAuthServer();
518
+ refreshToken = result.refreshToken;
519
+ userEmail = result.email;
520
+ log(` Authorized as ${userEmail}`);
521
+ }
522
+ catch (err) {
523
+ log('');
524
+ log(` ${err.message}`);
525
+ process.exit(1);
526
+ }
294
527
  }
295
528
  log('');
296
529
  // 1. Create hook files
@@ -313,7 +546,7 @@ async function runSetup() {
313
546
  else if (rawSettings) {
314
547
  existingSettings = rawSettings;
315
548
  }
316
- const mergedSettings = mergeSettings(existingSettings, email, password);
549
+ const mergedSettings = mergeSettings(existingSettings, refreshToken);
317
550
  writeJsonFile(settingsPath, mergedSettings);
318
551
  log(' [+] Updated .claude/settings.json');
319
552
  // 3. Optional CLAUDE.md append
@@ -321,7 +554,7 @@ async function runSetup() {
321
554
  if (appendAnswer.toLowerCase() !== 'n') {
322
555
  if (fs.existsSync(claudeMdPath)) {
323
556
  const content = fs.readFileSync(claudeMdPath, 'utf8');
324
- if (content.includes('## SlyPlan Sync')) {
557
+ if (content.includes('## SlyPlan Sync') || content.includes('## Hook Compliance')) {
325
558
  log(' [=] CLAUDE.md already has SlyPlan Sync section — skipped');
326
559
  }
327
560
  else {
package/dist/db.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TreeNode, Link } from './types.js';
1
+ import type { TreeNode, Link, NodeDependency } from './types.js';
2
2
  export declare function getTree(rootId?: string | null): Promise<TreeNode[]>;
3
3
  export declare function getNode(id: string): Promise<TreeNode | null>;
4
4
  export declare function insertNode(data: {
@@ -28,3 +28,21 @@ export declare function searchNodes(query: string, filters?: {
28
28
  type?: string[];
29
29
  status?: string[];
30
30
  }): Promise<TreeNode[]>;
31
+ export declare function insertDependency(fromNodeId: string, toNodeId: string): Promise<NodeDependency>;
32
+ export declare function deleteDependency(id: string): Promise<void>;
33
+ export declare function listDependencies(nodeIds: string[]): Promise<NodeDependency[]>;
34
+ export declare function getBlockedNodes(projectId: string): Promise<Array<{
35
+ nodeId: string;
36
+ blockedBy: string[];
37
+ }>>;
38
+ export declare function computeProjectProgress(projectId: string): Promise<{
39
+ overall: number;
40
+ breakdown: Array<{
41
+ id: string;
42
+ title: string;
43
+ type: string;
44
+ progress: number;
45
+ weight: number;
46
+ }>;
47
+ }>;
48
+ export declare function computeCriticalPath(projectId: string): Promise<string[]>;
package/dist/db.js CHANGED
@@ -303,3 +303,199 @@ export async function searchNodes(query, filters) {
303
303
  throw new Error(error.message);
304
304
  return (rows || []).map(row => dbNodeToTree(row, [], []));
305
305
  }
306
+ function dbDepToNodeDep(row) {
307
+ return {
308
+ id: row.id,
309
+ fromNodeId: row.from_node_id,
310
+ toNodeId: row.to_node_id,
311
+ dependencyType: row.dependency_type,
312
+ createdBy: row.created_by,
313
+ createdAt: row.created_at,
314
+ };
315
+ }
316
+ export async function insertDependency(fromNodeId, toNodeId) {
317
+ const { data, error } = await supabase
318
+ .from('node_dependencies')
319
+ .insert({
320
+ from_node_id: fromNodeId,
321
+ to_node_id: toNodeId,
322
+ created_by: getUserId(),
323
+ })
324
+ .select()
325
+ .single();
326
+ if (error) {
327
+ // Catch cycle detection error from DB trigger and re-throw with user-friendly message
328
+ if (error.message.includes('cycle') || error.message.includes('circular')) {
329
+ throw new Error(`Cannot create dependency: this would create a circular dependency chain. "${fromNodeId}" already depends (directly or indirectly) on "${toNodeId}".`);
330
+ }
331
+ throw new Error(error.message);
332
+ }
333
+ return dbDepToNodeDep(data);
334
+ }
335
+ export async function deleteDependency(id) {
336
+ const { error } = await supabase
337
+ .from('node_dependencies')
338
+ .delete()
339
+ .eq('id', id);
340
+ if (error)
341
+ throw new Error(error.message);
342
+ }
343
+ export async function listDependencies(nodeIds) {
344
+ const { data, error } = await supabase
345
+ .from('node_dependencies')
346
+ .select('*')
347
+ .or(`from_node_id.in.(${nodeIds.join(',')}),to_node_id.in.(${nodeIds.join(',')})`);
348
+ if (error)
349
+ throw new Error(error.message);
350
+ return (data || []).map(dbDepToNodeDep);
351
+ }
352
+ export async function getBlockedNodes(projectId) {
353
+ // Get all nodes for the project
354
+ const tree = await getTree(projectId);
355
+ if (tree.length === 0)
356
+ return [];
357
+ // Collect all node IDs and build a status map
358
+ const nodeMap = new Map();
359
+ function walkTree(nodes) {
360
+ for (const node of nodes) {
361
+ nodeMap.set(node.id, { id: node.id, status: node.status });
362
+ walkTree(node.children);
363
+ }
364
+ }
365
+ walkTree(tree);
366
+ const allNodeIds = Array.from(nodeMap.keys());
367
+ if (allNodeIds.length === 0)
368
+ return [];
369
+ // Get all dependencies for these nodes
370
+ const deps = await listDependencies(allNodeIds);
371
+ // Find nodes where from_node status !== 'done'
372
+ const blockedMap = new Map();
373
+ for (const dep of deps) {
374
+ const fromNode = nodeMap.get(dep.fromNodeId);
375
+ if (fromNode && fromNode.status !== 'done') {
376
+ const existing = blockedMap.get(dep.toNodeId) || [];
377
+ existing.push(dep.fromNodeId);
378
+ blockedMap.set(dep.toNodeId, existing);
379
+ }
380
+ }
381
+ return Array.from(blockedMap.entries()).map(([nodeId, blockedBy]) => ({
382
+ nodeId,
383
+ blockedBy,
384
+ }));
385
+ }
386
+ export async function computeProjectProgress(projectId) {
387
+ const tree = await getTree(projectId);
388
+ if (tree.length === 0)
389
+ return { overall: 0, breakdown: [] };
390
+ const root = tree[0];
391
+ // Count leaves for weighting
392
+ function countLeaves(node) {
393
+ if (node.children.length === 0)
394
+ return 1;
395
+ return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
396
+ }
397
+ // Compute weighted progress recursively
398
+ function weightedProgress(node) {
399
+ if (node.children.length === 0)
400
+ return node.progress;
401
+ let totalWeight = 0;
402
+ let weightedSum = 0;
403
+ for (const child of node.children) {
404
+ const weight = countLeaves(child);
405
+ const childProgress = weightedProgress(child);
406
+ weightedSum += childProgress * weight;
407
+ totalWeight += weight;
408
+ }
409
+ return totalWeight === 0 ? 0 : Math.round(weightedSum / totalWeight);
410
+ }
411
+ const breakdown = root.children.map(child => ({
412
+ id: child.id,
413
+ title: child.title,
414
+ type: child.type,
415
+ progress: weightedProgress(child),
416
+ weight: countLeaves(child),
417
+ }));
418
+ const overall = weightedProgress(root);
419
+ return { overall, breakdown };
420
+ }
421
+ export async function computeCriticalPath(projectId) {
422
+ // Get all nodes for the project
423
+ const tree = await getTree(projectId);
424
+ if (tree.length === 0)
425
+ return [];
426
+ // Collect all node IDs
427
+ const allNodeIds = [];
428
+ function walkTree(nodes) {
429
+ for (const node of nodes) {
430
+ allNodeIds.push(node.id);
431
+ walkTree(node.children);
432
+ }
433
+ }
434
+ walkTree(tree);
435
+ if (allNodeIds.length === 0)
436
+ return [];
437
+ // Get all dependencies
438
+ const deps = await listDependencies(allNodeIds);
439
+ if (deps.length === 0)
440
+ return [];
441
+ // Kahn's topological sort + longest path relaxation
442
+ const allNodes = new Set();
443
+ const adjList = new Map();
444
+ const inDegree = new Map();
445
+ for (const dep of deps) {
446
+ allNodes.add(dep.fromNodeId);
447
+ allNodes.add(dep.toNodeId);
448
+ }
449
+ for (const nodeId of allNodes) {
450
+ adjList.set(nodeId, []);
451
+ inDegree.set(nodeId, 0);
452
+ }
453
+ for (const dep of deps) {
454
+ adjList.get(dep.fromNodeId).push(dep.toNodeId);
455
+ inDegree.set(dep.toNodeId, (inDegree.get(dep.toNodeId) || 0) + 1);
456
+ }
457
+ const dist = new Map();
458
+ const prev = new Map();
459
+ const queue = [];
460
+ for (const nodeId of allNodes) {
461
+ dist.set(nodeId, 0);
462
+ prev.set(nodeId, null);
463
+ if (inDegree.get(nodeId) === 0) {
464
+ queue.push(nodeId);
465
+ }
466
+ }
467
+ while (queue.length > 0) {
468
+ const current = queue.shift();
469
+ const currentDist = dist.get(current);
470
+ for (const neighbor of adjList.get(current)) {
471
+ const newDist = currentDist + 1;
472
+ if (newDist > dist.get(neighbor)) {
473
+ dist.set(neighbor, newDist);
474
+ prev.set(neighbor, current);
475
+ }
476
+ inDegree.set(neighbor, inDegree.get(neighbor) - 1);
477
+ if (inDegree.get(neighbor) === 0) {
478
+ queue.push(neighbor);
479
+ }
480
+ }
481
+ }
482
+ // Find the node with max distance (end of critical path)
483
+ let maxDist = -1;
484
+ let endNode = '';
485
+ for (const [nodeId, d] of dist) {
486
+ if (d > maxDist) {
487
+ maxDist = d;
488
+ endNode = nodeId;
489
+ }
490
+ }
491
+ if (maxDist <= 0)
492
+ return [];
493
+ // Trace back from endNode to start
494
+ const path = [];
495
+ let current = endNode;
496
+ while (current !== null) {
497
+ path.unshift(current);
498
+ current = prev.get(current) ?? null;
499
+ }
500
+ return path;
501
+ }
package/dist/index.js 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 { z } from 'zod';
12
12
  import { authenticate, supabase } from './supabase.js';
13
- import { getTree, getNode, insertNode, updateNode, deleteNode, moveNode, searchNodes, getWorkMode, addToWorkMode, removeFromWorkMode } from './db.js';
13
+ import { getTree, getNode, insertNode, updateNode, deleteNode, moveNode, searchNodes, getWorkMode, addToWorkMode, removeFromWorkMode, insertDependency, deleteDependency, listDependencies, getBlockedNodes, computeProjectProgress, computeCriticalPath, } from './db.js';
14
14
  const server = new McpServer({
15
15
  name: 'slyplan',
16
16
  version: '1.0.0',
@@ -213,6 +213,163 @@ server.tool('whoami', 'Show which user is authenticated for this MCP session.',
213
213
  content: [{ type: 'text', text: `Authenticated as: ${user.email} (${user.id})` }],
214
214
  };
215
215
  });
216
+ // --- Dependency Tools ---
217
+ server.tool('add_dependency', 'Create a dependency. from_node blocks to_node (from_node must complete before to_node can start). Only use for REAL hard dependencies where work genuinely cannot start without the prerequisite. Do NOT create soft/nice-to-have dependencies.', {
218
+ from_node_id: z.string().describe('Node ID that must complete first (the blocker)'),
219
+ to_node_id: z.string().describe('Node ID that depends on from_node (the blocked)'),
220
+ }, async ({ from_node_id, to_node_id }) => {
221
+ try {
222
+ const dep = await insertDependency(from_node_id, to_node_id);
223
+ const fromNode = await getNode(from_node_id);
224
+ const toNode = await getNode(to_node_id);
225
+ const fromTitle = fromNode ? `"${fromNode.title}"` : from_node_id;
226
+ const toTitle = toNode ? `"${toNode.title}"` : to_node_id;
227
+ return {
228
+ content: [{ type: 'text', text: `Dependency created: ${fromTitle} blocks ${toTitle}\n\nID: ${dep.id}` }],
229
+ };
230
+ }
231
+ catch (e) {
232
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
233
+ }
234
+ });
235
+ server.tool('remove_dependency', 'Remove a dependency between two nodes', {
236
+ dependency_id: z.string().describe('Dependency ID to remove. Get from list_dependencies.'),
237
+ }, async ({ dependency_id }) => {
238
+ try {
239
+ await deleteDependency(dependency_id);
240
+ return { content: [{ type: 'text', text: `Dependency ${dependency_id} removed.` }] };
241
+ }
242
+ catch (e) {
243
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
244
+ }
245
+ });
246
+ server.tool('list_dependencies', 'List all dependencies for the active project or specific nodes', {
247
+ node_ids: z.array(z.string()).optional().describe('Specific node IDs to list dependencies for. Omit for all nodes in active project.'),
248
+ }, async ({ node_ids }) => {
249
+ try {
250
+ let ids = node_ids;
251
+ if (!ids || ids.length === 0) {
252
+ if (!activeProjectId) {
253
+ return { content: [{ type: 'text', text: 'No active project set. Use set_project first, or provide node_ids.' }], isError: true };
254
+ }
255
+ // Get all node IDs in the active project
256
+ const tree = await getTree(activeProjectId);
257
+ const allIds = [];
258
+ function walkTree(nodes) {
259
+ for (const node of nodes) {
260
+ allIds.push(node.id);
261
+ if (node.children)
262
+ walkTree(node.children);
263
+ }
264
+ }
265
+ walkTree(tree);
266
+ ids = allIds;
267
+ }
268
+ if (ids.length === 0)
269
+ return { content: [{ type: 'text', text: 'No nodes found.' }] };
270
+ const deps = await listDependencies(ids);
271
+ if (deps.length === 0)
272
+ return { content: [{ type: 'text', text: 'No dependencies found.' }] };
273
+ // Fetch node titles for readable output
274
+ const nodeCache = new Map();
275
+ for (const dep of deps) {
276
+ for (const nid of [dep.fromNodeId, dep.toNodeId]) {
277
+ if (!nodeCache.has(nid)) {
278
+ const node = await getNode(nid);
279
+ nodeCache.set(nid, node ? node.title : nid);
280
+ }
281
+ }
282
+ }
283
+ const lines = deps.map(dep => {
284
+ const fromTitle = nodeCache.get(dep.fromNodeId) || dep.fromNodeId;
285
+ const toTitle = nodeCache.get(dep.toNodeId) || dep.toNodeId;
286
+ return `• "${fromTitle}" blocks "${toTitle}" [${dep.id}]`;
287
+ });
288
+ return {
289
+ content: [{ type: 'text', text: `Dependencies (${deps.length}):\n${lines.join('\n')}` }],
290
+ };
291
+ }
292
+ catch (e) {
293
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
294
+ }
295
+ });
296
+ server.tool('get_blocked', 'Get all currently blocked nodes in the active project (nodes with unmet dependencies)', {}, async () => {
297
+ try {
298
+ if (!activeProjectId) {
299
+ return { content: [{ type: 'text', text: 'No active project set. Use set_project first.' }], isError: true };
300
+ }
301
+ const blocked = await getBlockedNodes(activeProjectId);
302
+ if (blocked.length === 0) {
303
+ return { content: [{ type: 'text', text: 'No blocked nodes. All dependencies are met.' }] };
304
+ }
305
+ // Fetch node titles
306
+ const nodeCache = new Map();
307
+ for (const item of blocked) {
308
+ for (const nid of [item.nodeId, ...item.blockedBy]) {
309
+ if (!nodeCache.has(nid)) {
310
+ const node = await getNode(nid);
311
+ nodeCache.set(nid, node ? node.title : nid);
312
+ }
313
+ }
314
+ }
315
+ const lines = blocked.map(item => {
316
+ const nodeName = nodeCache.get(item.nodeId) || item.nodeId;
317
+ const blockerNames = item.blockedBy.map(b => `"${nodeCache.get(b) || b}"`).join(', ');
318
+ return `• "${nodeName}" is blocked by ${blockerNames}`;
319
+ });
320
+ return {
321
+ content: [{ type: 'text', text: `Blocked nodes (${blocked.length}):\n${lines.join('\n')}` }],
322
+ };
323
+ }
324
+ catch (e) {
325
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
326
+ }
327
+ });
328
+ server.tool('get_progress', 'Get weighted project completion percentage and breakdown by phase/category', {
329
+ project_id: z.string().optional().describe('Project ID. Omit for active project.'),
330
+ }, async ({ project_id }) => {
331
+ try {
332
+ const pid = project_id || activeProjectId;
333
+ if (!pid) {
334
+ return { content: [{ type: 'text', text: 'No project specified and no active project set.' }], isError: true };
335
+ }
336
+ const result = await computeProjectProgress(pid);
337
+ const breakdownLines = result.breakdown.map(item => `• ${item.title} (${item.type}): ${item.progress}% (weight: ${item.weight})`);
338
+ return {
339
+ content: [{ type: 'text', text: `Overall: ${result.overall}%\n\nBreakdown:\n${breakdownLines.join('\n') || '(no children)'}` }],
340
+ };
341
+ }
342
+ catch (e) {
343
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
344
+ }
345
+ });
346
+ server.tool('critical_path', 'Get the critical dependency chain (longest path to project completion)', {
347
+ project_id: z.string().optional().describe('Project ID. Omit for active project.'),
348
+ }, async ({ project_id }) => {
349
+ try {
350
+ const pid = project_id || activeProjectId;
351
+ if (!pid) {
352
+ return { content: [{ type: 'text', text: 'No project specified and no active project set.' }], isError: true };
353
+ }
354
+ const path = await computeCriticalPath(pid);
355
+ if (path.length === 0) {
356
+ return { content: [{ type: 'text', text: 'No critical path found. The project has no dependencies or all dependencies are isolated.' }] };
357
+ }
358
+ // Fetch node titles
359
+ const titles = [];
360
+ for (const nodeId of path) {
361
+ const node = await getNode(nodeId);
362
+ titles.push(node ? node.title : nodeId);
363
+ }
364
+ const steps = titles.map((title, i) => `${i + 1}. ${title}`).join(' → ');
365
+ return {
366
+ content: [{ type: 'text', text: `Critical path (${path.length} steps):\n${steps}` }],
367
+ };
368
+ }
369
+ catch (e) {
370
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
371
+ }
372
+ });
216
373
  // --- Start ---
217
374
  async function main() {
218
375
  await authenticate();
package/dist/supabase.js CHANGED
@@ -1,24 +1,75 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
2
4
  const SUPABASE_URL = 'https://omfzpkwtuzucwwxmyuqt.supabase.co';
3
5
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9tZnpwa3d0dXp1Y3d3eG15dXF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5MjMwNDIsImV4cCI6MjA4NjQ5OTA0Mn0.KXGoUez7M45RtFM9qR7mjzGX6UhlaRE-gggAJxSkIHY';
4
6
  export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
5
7
  auth: { autoRefreshToken: true, persistSession: false },
6
8
  });
7
9
  let userId = null;
10
+ function findSettingsPath() {
11
+ // Walk up from cwd looking for .claude/settings.json
12
+ let dir = process.cwd();
13
+ while (true) {
14
+ const candidate = path.join(dir, '.claude', 'settings.json');
15
+ if (fs.existsSync(candidate))
16
+ return candidate;
17
+ const parent = path.dirname(dir);
18
+ if (parent === dir)
19
+ break;
20
+ dir = parent;
21
+ }
22
+ return null;
23
+ }
24
+ function persistNewRefreshToken(newToken) {
25
+ try {
26
+ const settingsPath = findSettingsPath();
27
+ if (!settingsPath)
28
+ return;
29
+ const raw = fs.readFileSync(settingsPath, 'utf8');
30
+ const settings = JSON.parse(raw);
31
+ const env = settings?.mcpServers?.slyplan?.env;
32
+ if (!env)
33
+ return;
34
+ env.SLYPLAN_REFRESH_TOKEN = newToken;
35
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
36
+ }
37
+ catch {
38
+ // Silent — token rotation is best-effort
39
+ }
40
+ }
8
41
  export async function authenticate() {
42
+ const refreshToken = process.env.SLYPLAN_REFRESH_TOKEN;
9
43
  const email = process.env.SLYPLAN_EMAIL;
10
44
  const password = process.env.SLYPLAN_PASSWORD;
11
- if (!email || !password) {
12
- console.error('Missing SLYPLAN_EMAIL or SLYPLAN_PASSWORD environment variables.');
13
- console.error('Set them in your Claude MCP config under "env".');
14
- process.exit(1);
45
+ // Prefer refresh token (browser auth flow)
46
+ if (refreshToken) {
47
+ const { data, error } = await supabase.auth.refreshSession({ refresh_token: refreshToken });
48
+ if (error) {
49
+ console.error(`Token refresh failed: ${error.message}`);
50
+ console.error('Re-run "npx slyplan-mcp setup" to re-authorize.');
51
+ process.exit(1);
52
+ }
53
+ userId = data.user.id;
54
+ // Persist rotated token for next startup
55
+ if (data.session?.refresh_token && data.session.refresh_token !== refreshToken) {
56
+ persistNewRefreshToken(data.session.refresh_token);
57
+ }
58
+ return;
15
59
  }
16
- const { data, error } = await supabase.auth.signInWithPassword({ email, password });
17
- if (error) {
18
- console.error(`Authentication failed: ${error.message}`);
19
- process.exit(1);
60
+ // Legacy fallback: email + password
61
+ if (email && password) {
62
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
63
+ if (error) {
64
+ console.error(`Authentication failed: ${error.message}`);
65
+ process.exit(1);
66
+ }
67
+ userId = data.user.id;
68
+ return;
20
69
  }
21
- userId = data.user.id;
70
+ console.error('Missing SLYPLAN_REFRESH_TOKEN (or legacy SLYPLAN_EMAIL + SLYPLAN_PASSWORD).');
71
+ console.error('Run "npx slyplan-mcp setup" to authorize.');
72
+ process.exit(1);
22
73
  }
23
74
  export function getUserId() {
24
75
  if (!userId)
package/dist/types.d.ts CHANGED
@@ -24,3 +24,11 @@ export interface Link {
24
24
  linkType: string;
25
25
  createdAt: string;
26
26
  }
27
+ export interface NodeDependency {
28
+ id: string;
29
+ fromNodeId: string;
30
+ toNodeId: string;
31
+ dependencyType: string;
32
+ createdBy: string | null;
33
+ createdAt: string;
34
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "slyplan-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for Slyplan — visual project management via Claude",
5
5
  "type": "module",
6
6
  "bin": {
7
- "slyplan-mcp": "./dist/index.js"
7
+ "slyplan-mcp": "dist/index.js"
8
8
  },
9
9
  "files": [
10
10
  "dist"