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 +327 -94
- package/dist/db.d.ts +19 -1
- package/dist/db.js +196 -0
- package/dist/index.js +158 -1
- package/dist/supabase.js +60 -9
- package/dist/types.d.ts +8 -0
- package/package.json +2 -2
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
process.exit(0);
|
|
31
|
-
`.replace('PRE_DIRECTIVE_PLACEHOLDER', JSON.stringify(PRE_DIRECTIVE));
|
|
158
|
+
`;
|
|
32
159
|
const CLAUDEMD_SECTION = `
|
|
33
|
-
##
|
|
160
|
+
## Hook Compliance — HARD REQUIREMENTS
|
|
161
|
+
|
|
162
|
+
Hook messages containing **"BLOCKED"** or **"SYNC NOW"** are HARD REQUIREMENTS, not suggestions.
|
|
34
163
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
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
|
-
###
|
|
54
|
-
-
|
|
55
|
-
-
|
|
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
|
-
###
|
|
58
|
-
-
|
|
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
|
-
###
|
|
61
|
-
-
|
|
62
|
-
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
68
|
-
-
|
|
69
|
-
|
|
70
|
-
### MCP
|
|
71
|
-
|
|
|
72
|
-
|
|
73
|
-
| \`list_projects\` |
|
|
74
|
-
| \`set_project\` |
|
|
75
|
-
| \`search\` |
|
|
76
|
-
| \`add_node\` |
|
|
77
|
-
| \`update_node\` |
|
|
78
|
-
| \`add_to_work_mode\` |
|
|
79
|
-
| \`remove_from_work_mode\` |
|
|
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,
|
|
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
|
-
|
|
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
|
|
260
|
-
let
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
+
else {
|
|
513
|
+
// Interactive mode: browser-based auth
|
|
514
|
+
log(' Waiting for authorization in browser...');
|
|
291
515
|
log('');
|
|
292
|
-
|
|
293
|
-
|
|
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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slyplan-mcp",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
7
|
+
"slyplan-mcp": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|