slyplan-mcp 1.5.2 → 1.6.3

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.
@@ -0,0 +1,129 @@
1
+ ---
2
+ description: Reorganize and sort all nodes in the project tree for clean structure
3
+ argument-hint: "[project-name]"
4
+ allowed-tools:
5
+ - mcp__slyplan__list_projects
6
+ - mcp__slyplan__set_project
7
+ - mcp__slyplan__get_tree
8
+ - mcp__slyplan__get_node
9
+ - mcp__slyplan__update_node
10
+ - mcp__slyplan__delete_node
11
+ - mcp__slyplan__move_node
12
+ - mcp__slyplan__add_node
13
+ - mcp__slyplan__search
14
+ ---
15
+
16
+ You are a tree reorganization agent for SlyPlan. Your job is to analyze the entire project tree for "$ARGUMENTS" and reorganize nodes so the structure is logical, clean, and well-grouped. Follow each step in order.
17
+
18
+ Initialize these counters at the start:
19
+ - moved = 0
20
+ - merged = 0
21
+ - created = 0
22
+ - deleted = 0
23
+
24
+ ## Step 1: Resolve Project
25
+
26
+ 1. Call `list_projects` to get all available projects.
27
+ 2. Find the project whose name best matches "$ARGUMENTS" (case-insensitive, fuzzy — partial match is acceptable).
28
+ 3. If no reasonable match is found, output: **"No project found matching '$ARGUMENTS'"** and stop.
29
+ 4. Call `set_project` with the matched project's ID.
30
+
31
+ ## Step 2: Fetch and Analyze Tree
32
+
33
+ 1. Call `get_tree` **once** to get the complete project hierarchy.
34
+ 2. Analyze the entire structure in your working memory.
35
+ 3. Identify problems:
36
+ - **Misplaced nodes:** Plans that should be under a different phase/category.
37
+ - **Duplicates:** Nodes with very similar titles that should be merged.
38
+ - **Orphaned nodes:** Nodes that don't logically belong where they are.
39
+ - **Missing groups:** Related nodes scattered across the tree that should be grouped.
40
+ - **Wrong types:** Nodes whose type doesn't match their role (e.g., a plan that's really a phase).
41
+ - **Empty containers:** Categories or phases with no children.
42
+ 4. Plan ALL changes before executing any MCP calls.
43
+
44
+ ## Step 3: Fix Hierarchy
45
+
46
+ The expected hierarchy is: **project > category > phase > plan**
47
+
48
+ Same-type nesting is allowed (category under category, phase under phase) for logical grouping.
49
+
50
+ Fix violations:
51
+ - **Plan directly under project:** Wrap in appropriate category > phase.
52
+ - **Plan directly under category:** Wrap in appropriate phase.
53
+ - **Phase directly under project:** Wrap in appropriate category.
54
+
55
+ Group multiple violations under shared wrappers where logical.
56
+
57
+ ## Step 4: Group Related Nodes
58
+
59
+ Look for nodes that logically belong together but are scattered:
60
+
61
+ 1. **Identify clusters** of related nodes (by title, description, or domain).
62
+ 2. **Create grouping nodes** (categories or phases) to hold them if none exist.
63
+ 3. **Move nodes** into their logical groups using `move_node`.
64
+ 4. Choose grouping names that are clear and descriptive.
65
+
66
+ ## Step 5: Merge Duplicates
67
+
68
+ For sibling nodes with very similar titles:
69
+
70
+ 1. **Exact match** (case-insensitive): definite duplicate.
71
+ 2. **One title contains the other**: likely duplicate.
72
+ 3. **Same key words in different order**: probable duplicate.
73
+
74
+ When merging A into B (B survives — pick the one with more children/progress):
75
+ 1. Move ALL children of A under B using `move_node`.
76
+ 2. Combine descriptions if A has unique content.
77
+ 3. Set B's status to the more advanced of the two.
78
+ 4. Set B's progress to `max(A.progress, B.progress)`.
79
+ 5. Delete A (now childless) using `delete_node`.
80
+
81
+ When in doubt, do NOT merge.
82
+
83
+ ## Step 6: Cleanup
84
+
85
+ 1. Delete categories and phases with zero children.
86
+ 2. Flatten single-child wrappers where the wrapper title matches the child.
87
+ 3. Do NOT delete project nodes.
88
+
89
+ ## CRITICAL SAFETY RULE
90
+
91
+ **Before calling `delete_node` on ANY node, you MUST first `move_node` ALL of its children to their new parent.**
92
+
93
+ The database uses `ON DELETE CASCADE` on `parent_id`. Deleting a parent node **permanently destroys ALL descendants**. There is no undo.
94
+
95
+ ## Behavioral Rules
96
+
97
+ - Do NOT rewrite existing node titles unless merging duplicates or fixing obvious typos.
98
+ - Do NOT change node statuses or progress unless merging.
99
+ - Fetch `get_tree` only ONCE (Step 2). Do not call it again.
100
+ - Plan all changes mentally before executing any MCP calls.
101
+ - Execute in order: hierarchy fixes → grouping → merging → cleanup.
102
+ - Process silently — no verbose progress commentary during execution.
103
+
104
+ ## Step 7: Report
105
+
106
+ After all operations, output a clean summary:
107
+
108
+ ```
109
+ Sort complete for "[Project Name]"
110
+
111
+ Moved: X nodes
112
+ Merged: Y duplicate nodes
113
+ Created: Z grouping nodes
114
+ Deleted: W empty containers
115
+ ```
116
+
117
+ Then show the key changes:
118
+ ```
119
+ Changes:
120
+ - Moved "Node" from "Old Parent" → "New Parent"
121
+ - Merged "Duplicate" into "Survivor"
122
+ - Created "New Category" to group related nodes
123
+ - Deleted empty "Old Phase"
124
+ ```
125
+
126
+ If nothing was changed, output:
127
+ ```
128
+ No changes needed for "[Project Name]" — tree structure is already clean.
129
+ ```
@@ -0,0 +1,90 @@
1
+ ---
2
+ description: Sort "Sort Later" tasks into the correct places in the project tree
3
+ argument-hint: "[project-name]"
4
+ allowed-tools:
5
+ - mcp__slyplan__list_projects
6
+ - mcp__slyplan__set_project
7
+ - mcp__slyplan__get_tree
8
+ - mcp__slyplan__get_node
9
+ - mcp__slyplan__update_node
10
+ - mcp__slyplan__delete_node
11
+ - mcp__slyplan__move_node
12
+ - mcp__slyplan__add_node
13
+ - mcp__slyplan__search
14
+ - mcp__supabase__execute_sql
15
+ ---
16
+
17
+ You are a task sorting agent for SlyPlan. Your job is to take all unsorted tasks from the "Sort Later" list (stored in project metadata) and create them as proper nodes in the correct places in the project tree for "$ARGUMENTS". Follow each step in order.
18
+
19
+ ## Step 1: Resolve Project
20
+
21
+ 1. Call `list_projects` to get all available projects.
22
+ 2. Find the project whose name best matches "$ARGUMENTS" (case-insensitive, fuzzy — partial match is acceptable).
23
+ 3. If no reasonable match is found, output: **"No project found matching '$ARGUMENTS'"** and stop.
24
+ 4. Call `set_project` with the matched project's ID.
25
+
26
+ ## Step 2: Find Sort Later Tasks
27
+
28
+ 1. Call `get_node` on the project to read its metadata.
29
+ 2. Look for `metadata.sortLaterTasks` — this is an array of tasks with `{ id, title, description, children: [{ id, title, description }] }`.
30
+ 3. If the array is empty or doesn't exist, output: **"No Sort Later tasks found — nothing to sort."** and stop.
31
+ 4. Note down all the tasks — their titles, descriptions, and children.
32
+
33
+ ## Step 3: Understand the Tree
34
+
35
+ 1. Call `get_tree` **once** to get the complete project hierarchy.
36
+ 2. Analyze the existing structure: categories, phases, and plans.
37
+ 3. For each Sort Later task, determine the best placement:
38
+ - Search for existing categories and phases that logically match the task.
39
+ - Consider the task's title and description to understand its intent.
40
+ 4. Plan ALL placements before executing any.
41
+
42
+ ## Step 4: Create Nodes for Each Task
43
+
44
+ For each Sort Later task:
45
+
46
+ 1. **If an existing category > phase fits:** Create the task as a `plan` node under that phase using `add_node`.
47
+ 2. **If a category exists but no fitting phase:** Create a new `phase` with `add_node`, then create the task under it.
48
+ 3. **If no relevant category exists:** Create a new `category` with `add_node`, then a `phase` under it, then the task.
49
+ 4. **If a task could itself be a category or phase:** Create it with that type and restructure accordingly.
50
+ 5. **If a task has children:** Create the parent first, then create each child as a `plan` node under it.
51
+ 6. **If a task makes absolutely no sense:** Ask the user what they want to do with it before creating or discarding it.
52
+
53
+ Use your intelligence to group things logically. Related tasks should be near each other.
54
+
55
+ ## Step 5: Clear Sort Later List
56
+
57
+ After all tasks are created as nodes, clear the Sort Later list from project metadata:
58
+
59
+ Use `execute_sql` to clear the sortLaterTasks from the project metadata:
60
+ ```sql
61
+ UPDATE nodes SET metadata = metadata - 'sortLaterTasks' WHERE id = '<project-id>';
62
+ ```
63
+
64
+ ## Behavioral Rules
65
+
66
+ - Do NOT modify existing nodes — only create new ones for the Sort Later tasks.
67
+ - Ask the user about ambiguous tasks rather than guessing wrong.
68
+ - Fetch `get_tree` only ONCE (Step 3). Work from the snapshot.
69
+ - Plan all changes before executing MCP calls.
70
+ - Process silently — no verbose progress commentary during execution.
71
+
72
+ ## Step 6: Report
73
+
74
+ After all operations, output a clean summary:
75
+
76
+ ```
77
+ Sort complete for "[Project Name]"
78
+
79
+ Sorted: X tasks
80
+ Created: Y new categories/phases
81
+ Skipped: Z tasks (asked user)
82
+ Sort Later list: [cleared | X tasks remaining]
83
+ ```
84
+
85
+ Then list where each task was placed:
86
+ ```
87
+ - "Task title" → Category > Phase
88
+ - "Task title" → Category > Phase (new)
89
+ - "Task title" → Skipped (asked user)
90
+ ```
package/dist/cli.js CHANGED
@@ -49,7 +49,9 @@ process.stdin.on('end', () => {
49
49
  if (block.type !== 'tool_use') continue;
50
50
  const name = block.name || '';
51
51
  if (name.includes('set_project')) hasSetProject = true;
52
+ // Track add/remove — last action wins
52
53
  if (name.includes('add_to_work_mode')) hasWorkModeNode = true;
54
+ if (name.includes('remove_from_work_mode')) hasWorkModeNode = false;
53
55
  }
54
56
  }
55
57
  } catch {}
@@ -72,7 +74,7 @@ process.stdin.on('end', () => {
72
74
  suppressOutput: true,
73
75
  hookSpecificOutput: {
74
76
  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."
77
+ additionalContext: "BLOCKED: No node in work mode. Call search + add_to_work_mode before doing any work. This is not optional."
76
78
  }
77
79
  });
78
80
  process.stdout.write(output);
@@ -85,11 +87,13 @@ process.stdin.on('end', () => {
85
87
  }
86
88
  });
87
89
  `;
88
- // Smart transcript-aware hook: counts file changes since last SlyPlan sync.
89
- // Silent when synced, reminds with count when unsynced.
90
+ // Smart transcript-aware hook: counts file changes SINCE last SlyPlan sync.
91
+ // Silent when synced. Fires after 3+ unsynced file changes or a git commit.
90
92
  const POST_HOOK_FILE_CONTENT = `#!/usr/bin/env node
91
93
  const fs = require('fs');
92
94
 
95
+ const BATCH_THRESHOLD = 3;
96
+
93
97
  let input = '';
94
98
  process.stdin.setEncoding('utf8');
95
99
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -108,9 +112,9 @@ process.stdin.on('end', () => {
108
112
  const FILE_TOOLS = ['Write', 'Edit', 'NotebookEdit'];
109
113
  const SLYPLAN_SYNC_TOOLS = ['update_node', 'add_node', 'add_to_work_mode'];
110
114
 
111
- let lastFileChangeIdx = -1;
112
115
  let lastSlyplanSyncIdx = -1;
113
- let fileChangeCount = 0;
116
+ let fileChangesSinceSync = 0;
117
+ let hasGitCommitSinceSync = false;
114
118
 
115
119
  for (let i = 0; i < lines.length; i++) {
116
120
  try {
@@ -120,36 +124,62 @@ process.stdin.on('end', () => {
120
124
  if (block.type !== 'tool_use') continue;
121
125
  const name = block.name || '';
122
126
 
123
- if (FILE_TOOLS.includes(name)) {
124
- lastFileChangeIdx = i;
125
- fileChangeCount++;
127
+ // Track SlyPlan syncs — resets the counter
128
+ for (const st of SLYPLAN_SYNC_TOOLS) {
129
+ if (name.includes(st)) {
130
+ lastSlyplanSyncIdx = i;
131
+ fileChangesSinceSync = 0;
132
+ hasGitCommitSinceSync = false;
133
+ }
134
+ }
135
+
136
+ // Count file changes since last sync
137
+ if (FILE_TOOLS.includes(name) && i > lastSlyplanSyncIdx) {
138
+ fileChangesSinceSync++;
126
139
  }
140
+
141
+ // Detect git commit since last sync
127
142
  if (name === 'Bash' && block.input && typeof block.input.command === 'string') {
128
143
  const cmd = block.input.command;
129
- if (cmd.includes('git commit') || cmd.includes('mkdir')) {
130
- lastFileChangeIdx = i;
131
- fileChangeCount++;
144
+ if (cmd.includes('git commit') && i > lastSlyplanSyncIdx) {
145
+ hasGitCommitSinceSync = true;
132
146
  }
133
147
  }
134
- for (const st of SLYPLAN_SYNC_TOOLS) {
135
- if (name.includes(st)) lastSlyplanSyncIdx = i;
136
- }
137
148
  }
138
149
  }
139
150
  } catch {}
140
151
  }
141
152
 
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
+ // No changes since last sync — stay silent
154
+ if (fileChangesSinceSync === 0 && !hasGitCommitSinceSync) { process.exit(0); }
155
+
156
+ // Fire on git commit
157
+ if (hasGitCommitSinceSync) {
158
+ const output = JSON.stringify({
159
+ suppressOutput: true,
160
+ hookSpecificOutput: {
161
+ hookEventName: "PostToolUse",
162
+ additionalContext: "SYNC NOW: Git commit detected \\u2014 update SlyPlan node with progress before continuing."
163
+ }
164
+ });
165
+ process.stdout.write(output);
166
+ process.exit(0);
167
+ }
168
+
169
+ // Fire when batch threshold reached
170
+ if (fileChangesSinceSync >= BATCH_THRESHOLD) {
171
+ const output = JSON.stringify({
172
+ suppressOutput: true,
173
+ hookSpecificOutput: {
174
+ hookEventName: "PostToolUse",
175
+ additionalContext: "SYNC NOW: " + fileChangesSinceSync + " file change(s) since last sync. Call update_node with current progress."
176
+ }
177
+ });
178
+ process.stdout.write(output);
179
+ process.exit(0);
180
+ }
181
+
182
+ // Under threshold — stay silent
153
183
  process.exit(0);
154
184
  } catch (e) {
155
185
  process.exit(0);
@@ -162,7 +192,7 @@ const CLAUDEMD_SECTION = `
162
192
  Hook messages containing **"BLOCKED"** or **"SYNC NOW"** are HARD REQUIREMENTS, not suggestions.
163
193
 
164
194
  - **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.
195
+ - **SYNC NOW:** You MUST call \`update_node\` with updated progress BEFORE continuing with the next tool call. Max 3 file changes between each sync.
166
196
  - 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
197
  - Ignoring hooks because you're "following a recipe" or "in the middle of something" is NEVER a valid reason.
168
198
 
@@ -175,17 +205,30 @@ You MUST keep SlyPlan updated. This is not optional. Follow these rules:
175
205
  - Match the project based on the cwd folder name or user context.
176
206
  - If ambiguous, ask the user. Cache the project ID for the rest of the session.
177
207
 
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.
208
+ ### 2. Find the right place in the tree BEFORE creating nodes
209
+ Before creating any node, you MUST understand where it belongs:
210
+ 1. Call \`get_tree\` with \`max_depth: 2\` to see the project's top-level structure (categories and their immediate children).
211
+ 2. Identify the most relevant category or phase for your work.
212
+ 3. Call \`get_node\` on that parent with \`max_depth: 2\` to see its full children (2 levels deep).
213
+ 4. Now decide: create your node under the best-fitting parent.
214
+ 5. If nothing in step 1 was relevant, create a new category.
215
+
216
+ Once you've found or created the right node:
217
+ - Call \`add_to_work_mode\` on it BEFORE starting with Edit/Write/Bash.
182
218
  - **You must NEVER start work without an active work mode node.**
183
219
 
184
220
  ### 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.
221
+ - After each meaningful change: call \`update_node\` with updated \`progress\` (0-100) and a \`status_log\` entry.
222
+ - The \`status_log\` parameter appends a log entry to the node — use it to describe what was just done.
223
+ - Keep the node's \`description\` clean and static. Progress notes go in \`status_log\`.
187
224
  - Skip for trivial changes (typos, config tweaks).
188
225
 
226
+ **Writing good descriptions:**
227
+ - Explain what this node does and WHY it exists — not just a title repeat.
228
+ - Show how it fits in the parent's flow (e.g. "Handles auth token refresh so the API layer can retry failed requests transparently").
229
+ - Keep it concise: 1-3 sentences. Code references are fine when they add clarity (e.g. "Refactors \`useElkLayout.ts\` to support per-project circular layout"), but don't dump implementation details.
230
+ - A non-developer should roughly understand the purpose from the description.
231
+
189
232
  ### 4. NEVER mark anything as done without asking
190
233
  - Use \`AskUserQuestion\` with choices: "Yes, mark as done" / "Not yet" / "Skip".
191
234
  - **No exceptions.** Auto-marking done is forbidden.
@@ -200,17 +243,28 @@ You MUST keep SlyPlan updated. This is not optional. Follow these rules:
200
243
  ### Node hierarchy
201
244
  \`project\` > \`category\` > \`phase\` > \`plan\`
202
245
 
246
+ Organize nodes logically for readability. Use subcategories/subphases when it helps, but no hard limit on children count — use good judgement.
247
+
248
+ **Node creation rules:**
249
+ - Always place new nodes under existing logical parents — search first, don't create top-level orphans.
250
+ - Split plans into 2-3 children when work touches multiple components/steps. Don't oversplit single changes.
251
+ - NEVER create a new node to fix/debug an existing one. Update the original node instead (status, description, progress). Debugging is part of the original work, not a separate task.
252
+
203
253
  ### Performance rules
204
- - NEVER use \`get_tree\` (returns ~18k tokens). Use \`search\` instead.
254
+ - NEVER use \`get_tree\` without \`max_depth\`. Always pass \`max_depth: 2\` or lower.
255
+ - Use \`search\` for finding specific nodes by name.
256
+ - Use \`get_node\` with \`max_depth: 2\` to explore a specific subtree.
205
257
 
206
258
  ### MCP tools
207
259
  | Tool | Usage |
208
260
  |------|-------|
209
261
  | \`list_projects\` | Find projects (session start) |
210
262
  | \`set_project\` | Select active project |
263
+ | \`get_tree\` | See project structure (ALWAYS use \`max_depth: 2\`) |
264
+ | \`get_node\` | Inspect a node + its children (use \`max_depth: 2\`) |
211
265
  | \`search\` | Find nodes by name/content |
212
266
  | \`add_node\` | Create new node (project/category/phase/plan) |
213
- | \`update_node\` | Update status, progress, description |
267
+ | \`update_node\` | Update status, progress, description, \`status_log\` (appends log entry) |
214
268
  | \`add_to_work_mode\` | Mark node as active work |
215
269
  | \`remove_from_work_mode\` | Remove from active work |
216
270
  `;
@@ -344,16 +398,21 @@ function writeJsonFile(filePath, data) {
344
398
  }
345
399
  // --- Settings Merge ---
346
400
  // MCP server config goes in .mcp.json (Claude Code reads servers from here)
347
- function mergeMcpJson(existing, refreshToken) {
401
+ function mergeMcpJson(existing, auth) {
348
402
  const result = { ...existing };
349
403
  if (!result.mcpServers)
350
404
  result.mcpServers = {};
405
+ const env = {};
406
+ if (auth.apiKey) {
407
+ env.SLYPLAN_API_KEY = auth.apiKey;
408
+ }
409
+ else if (auth.refreshToken) {
410
+ env.SLYPLAN_REFRESH_TOKEN = auth.refreshToken;
411
+ }
351
412
  result.mcpServers.slyplan = {
352
413
  command: 'npx',
353
414
  args: ['-y', 'slyplan-mcp@latest'],
354
- env: {
355
- SLYPLAN_REFRESH_TOKEN: refreshToken,
356
- },
415
+ env,
357
416
  };
358
417
  return result;
359
418
  }
@@ -498,6 +557,26 @@ async function runSetup() {
498
557
  }
499
558
  }
500
559
  log('');
560
+ // Generate permanent API key (recommended over refresh tokens)
561
+ let apiKey = '';
562
+ const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
563
+ auth: { autoRefreshToken: false, persistSession: false },
564
+ });
565
+ await client.auth.refreshSession({ refresh_token: refreshToken });
566
+ const keyAnswer = await promptUser(' Generate a permanent API key? (recommended) [Y/n]: ');
567
+ if (keyAnswer.toLowerCase() !== 'n') {
568
+ const keyName = await promptUser(' Key name [Claude Code]: ');
569
+ const name = keyName.trim() || 'Claude Code';
570
+ const { data, error } = await client.rpc('create_api_key', { key_name: name });
571
+ if (!error && data?.key) {
572
+ apiKey = data.key;
573
+ log(` [+] API key created: ${data.key_prefix}••••`);
574
+ }
575
+ else {
576
+ log(` [!] Could not create API key: ${error?.message || 'unknown error'}`);
577
+ log(' Falling back to refresh token.');
578
+ }
579
+ }
501
580
  // 1. Create hook files
502
581
  fs.mkdirSync(hooksDir, { recursive: true });
503
582
  fs.writeFileSync(preHookFilePath, PRE_HOOK_FILE_CONTENT, 'utf8');
@@ -506,9 +585,12 @@ async function runSetup() {
506
585
  log(' [+] Created .claude/hooks/slyplan-post-tool-sync.cjs');
507
586
  // 2. Write MCP server config to .mcp.json
508
587
  const existingMcp = readJsonFile(mcpJsonPath) ?? {};
509
- const mergedMcp = mergeMcpJson(existingMcp, refreshToken);
588
+ const auth = apiKey
589
+ ? { apiKey }
590
+ : { refreshToken };
591
+ const mergedMcp = mergeMcpJson(existingMcp, auth);
510
592
  writeJsonFile(mcpJsonPath, mergedMcp);
511
- log(' [+] Updated .mcp.json');
593
+ log(` [+] Updated .mcp.json (${apiKey ? 'API key' : 'refresh token'})`);
512
594
  // 3. Write hooks to .claude/settings.json
513
595
  let existingSettings = {};
514
596
  const rawSettings = readJsonFile(settingsPath);
@@ -525,7 +607,18 @@ async function runSetup() {
525
607
  const mergedSettings = mergeSettings(existingSettings);
526
608
  writeJsonFile(settingsPath, mergedSettings);
527
609
  log(' [+] Updated .claude/settings.json (hooks)');
528
- // 4. Optional CLAUDE.md append
610
+ // 4. Install slash commands (.claude/commands/sly/)
611
+ const commandsSrcDir = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), '..', 'commands', 'sly');
612
+ const commandsDestDir = path.join(claudeDir, 'commands', 'sly');
613
+ if (fs.existsSync(commandsSrcDir)) {
614
+ fs.mkdirSync(commandsDestDir, { recursive: true });
615
+ const commandFiles = fs.readdirSync(commandsSrcDir).filter(f => f.endsWith('.md'));
616
+ for (const file of commandFiles) {
617
+ fs.copyFileSync(path.join(commandsSrcDir, file), path.join(commandsDestDir, file));
618
+ }
619
+ log(` [+] Installed ${commandFiles.length} slash commands: ${commandFiles.map(f => '/sly:' + f.replace('.md', '')).join(', ')}`);
620
+ }
621
+ // 5. Optional CLAUDE.md append
529
622
  const appendAnswer = await promptUser(' Append SlyPlan sync instructions to CLAUDE.md? [Y/n]: ');
530
623
  if (appendAnswer.toLowerCase() !== 'n') {
531
624
  if (fs.existsSync(claudeMdPath)) {
@@ -595,7 +688,22 @@ async function runRemove() {
595
688
  log(' [-] Removed SlyPlan from .mcp.json');
596
689
  }
597
690
  }
598
- // 3. Clean settings.json
691
+ // 3. Remove slash commands
692
+ const commandsDir = path.join(claudeDir, 'commands', 'sly');
693
+ if (fs.existsSync(commandsDir)) {
694
+ const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
695
+ for (const file of files) {
696
+ fs.unlinkSync(path.join(commandsDir, file));
697
+ }
698
+ // Remove empty dirs
699
+ try {
700
+ fs.rmdirSync(commandsDir);
701
+ fs.rmdirSync(path.join(claudeDir, 'commands'));
702
+ }
703
+ catch { }
704
+ log(` [-] Removed ${files.length} slash commands`);
705
+ }
706
+ // 4. Clean settings.json
599
707
  const existing = readJsonFile(settingsPath);
600
708
  if (existing) {
601
709
  const cleaned = removeFromSettings(existing);
package/dist/db.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { TreeNode, Link, NodeDependency } from './types.js';
2
- export declare function getTree(rootId?: string | null): Promise<TreeNode[]>;
3
- export declare function getNode(id: string): Promise<TreeNode | null>;
2
+ export declare function getTree(rootId?: string | null, maxDepth?: number): Promise<TreeNode[]>;
3
+ export declare function getNode(id: string, maxDepth?: number): Promise<TreeNode | null>;
4
4
  export declare function insertNode(data: {
5
5
  parentId: string | null;
6
6
  type: string;
package/dist/db.js CHANGED
@@ -29,7 +29,7 @@ function dbLinkToLink(row) {
29
29
  };
30
30
  }
31
31
  // --- Node CRUD ---
32
- export async function getTree(rootId) {
32
+ export async function getTree(rootId, maxDepth) {
33
33
  const { data: allNodes, error: nodesErr } = await supabase
34
34
  .from('nodes')
35
35
  .select('*')
@@ -56,10 +56,12 @@ export async function getTree(rootId) {
56
56
  arr.push(node);
57
57
  childrenMap.set(parentKey, arr);
58
58
  }
59
- function buildSubtree(parentId) {
59
+ function buildSubtree(parentId, depth) {
60
60
  const children = childrenMap.get(parentId) || [];
61
61
  return children.map(node => {
62
- const subtreeChildren = buildSubtree(node.id);
62
+ const subtreeChildren = (maxDepth !== undefined && depth >= maxDepth)
63
+ ? []
64
+ : buildSubtree(node.id, depth + 1);
63
65
  const links = linksByNode.get(node.id) || [];
64
66
  let progress = node.progress;
65
67
  if (subtreeChildren.length > 0) {
@@ -73,16 +75,16 @@ export async function getTree(rootId) {
73
75
  const rootNode = nodeMap.get(rootId);
74
76
  if (!rootNode)
75
77
  return [];
76
- const children = buildSubtree(rootId);
78
+ const children = buildSubtree(rootId, 1);
77
79
  let progress = rootNode.progress;
78
80
  if (children.length > 0) {
79
81
  progress = Math.round(children.reduce((sum, c) => sum + c.progress, 0) / children.length);
80
82
  }
81
83
  return [dbNodeToTree({ ...rootNode, progress }, linksByNode.get(rootId) || [], children)];
82
84
  }
83
- return buildSubtree(null);
85
+ return buildSubtree(null, 0);
84
86
  }
85
- export async function getNode(id) {
87
+ export async function getNode(id, maxDepth = 1) {
86
88
  const { data: row, error } = await supabase
87
89
  .from('nodes')
88
90
  .select('*')
@@ -95,10 +97,18 @@ export async function getNode(id) {
95
97
  .select('*')
96
98
  .eq('node_id', id);
97
99
  const links = (linkRows || []).map(dbLinkToLink);
100
+ const children = maxDepth > 0 ? await getChildrenRecursive(id, maxDepth, 1) : [];
101
+ let progress = row.progress;
102
+ if (children.length > 0) {
103
+ progress = Math.round(children.reduce((sum, c) => sum + c.progress, 0) / children.length);
104
+ }
105
+ return dbNodeToTree({ ...row, progress }, links, children);
106
+ }
107
+ async function getChildrenRecursive(parentId, maxDepth, currentDepth) {
98
108
  const { data: childRows } = await supabase
99
109
  .from('nodes')
100
110
  .select('*')
101
- .eq('parent_id', id)
111
+ .eq('parent_id', parentId)
102
112
  .order('sort_order', { ascending: true });
103
113
  const children = [];
104
114
  for (const child of (childRows || [])) {
@@ -106,13 +116,16 @@ export async function getNode(id) {
106
116
  .from('links')
107
117
  .select('*')
108
118
  .eq('node_id', child.id);
109
- children.push(dbNodeToTree(child, (childLinkRows || []).map(dbLinkToLink), []));
110
- }
111
- let progress = row.progress;
112
- if (children.length > 0) {
113
- progress = Math.round(children.reduce((sum, c) => sum + c.progress, 0) / children.length);
119
+ const grandchildren = currentDepth < maxDepth
120
+ ? await getChildrenRecursive(child.id, maxDepth, currentDepth + 1)
121
+ : [];
122
+ let progress = child.progress;
123
+ if (grandchildren.length > 0) {
124
+ progress = Math.round(grandchildren.reduce((sum, c) => sum + c.progress, 0) / grandchildren.length);
125
+ }
126
+ children.push(dbNodeToTree({ ...child, progress }, (childLinkRows || []).map(dbLinkToLink), grandchildren));
114
127
  }
115
- return dbNodeToTree({ ...row, progress }, links, children);
128
+ return children;
116
129
  }
117
130
  export async function insertNode(data) {
118
131
  const id = uuid();
package/dist/index.js CHANGED
@@ -56,14 +56,20 @@ server.tool('set_project', 'Select which project to work in. All subsequent oper
56
56
  content: [{ type: 'text', text: `Active project set to: "${node.title}"\n\nAll add_node, get_tree and search operations will now apply to this project.\nUse get_tree to see the project structure.` }],
57
57
  };
58
58
  });
59
- server.tool('get_tree', 'Get the project tree. Shows the active project if set, or the full tree.', { root_id: z.string().optional().describe('Node ID to use as root. Omit for active project / full tree.') }, async ({ root_id }) => {
59
+ server.tool('get_tree', 'Get the project tree. Shows the active project if set, or the full tree. Use max_depth to limit how deep the tree goes.', {
60
+ root_id: z.string().optional().describe('Node ID to use as root. Omit for active project / full tree.'),
61
+ max_depth: z.number().min(0).optional().describe('Max depth of children to include (0 = root only, 1 = direct children, 2 = grandchildren). Omit for full tree.'),
62
+ }, async ({ root_id, max_depth }) => {
60
63
  const effectiveRoot = root_id ?? activeProjectId ?? null;
61
- const tree = await getTree(effectiveRoot);
64
+ const tree = await getTree(effectiveRoot, max_depth);
62
65
  const ctx = activeProjectId ? `Project: ${await getActiveProjectName()}\n\n` : '';
63
66
  return { content: [{ type: 'text', text: `${ctx}${JSON.stringify(tree, null, 2)}` }] };
64
67
  });
65
- server.tool('get_node', 'Get details about a single node', { id: z.string().describe('Node ID to fetch') }, async ({ id }) => {
66
- const node = await getNode(id);
68
+ server.tool('get_node', 'Get details about a single node and its children', {
69
+ id: z.string().describe('Node ID to fetch'),
70
+ max_depth: z.number().min(0).optional().describe('How many levels of children to include (0 = node only, 1 = direct children, 2 = grandchildren). Default: 1.'),
71
+ }, async ({ id, max_depth }) => {
72
+ const node = await getNode(id, max_depth ?? 1);
67
73
  if (!node)
68
74
  return { content: [{ type: 'text', text: 'Node not found' }], isError: true };
69
75
  return { content: [{ type: 'text', text: JSON.stringify(node, null, 2) }] };
@@ -97,7 +103,17 @@ server.tool('update_node', 'Update fields on an existing node', {
97
103
  status: z.enum(['not_started', 'in_progress', 'blocked', 'done']).optional().describe('New status'),
98
104
  progress: z.number().min(0).max(100).optional().describe('New progress (0-100)'),
99
105
  metadata: z.record(z.unknown()).optional().describe('New metadata'),
100
- }, async ({ id, ...updates }) => {
106
+ status_log: z.string().optional().describe('Append a status log entry (stored in metadata.statusLog array)'),
107
+ }, async ({ id, status_log, ...updates }) => {
108
+ if (status_log) {
109
+ const current = await getNode(id);
110
+ if (!current)
111
+ return { content: [{ type: 'text', text: 'Node not found' }], isError: true };
112
+ const meta = (current.metadata || {});
113
+ const log = Array.isArray(meta.statusLog) ? [...meta.statusLog] : [];
114
+ log.push(status_log);
115
+ updates.metadata = { ...meta, ...(updates.metadata || {}), statusLog: log };
116
+ }
101
117
  const node = await updateNode(id, updates);
102
118
  if (!node)
103
119
  return { content: [{ type: 'text', text: 'Node not found' }], isError: true };
package/dist/supabase.js CHANGED
@@ -33,10 +33,42 @@ supabase.auth.onAuthStateChange((event, session) => {
33
33
  }
34
34
  });
35
35
  export async function authenticate() {
36
+ const apiKey = process.env.SLYPLAN_API_KEY;
36
37
  const refreshToken = process.env.SLYPLAN_REFRESH_TOKEN;
37
38
  const email = process.env.SLYPLAN_EMAIL;
38
39
  const password = process.env.SLYPLAN_PASSWORD;
39
- // Prefer refresh token (new browser-based flow)
40
+ // 1. Preferred: permanent API key (never expires)
41
+ // Calls edge function to exchange API key → Supabase session tokens
42
+ if (apiKey) {
43
+ try {
44
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/exchange-api-key`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ api_key: apiKey }),
48
+ });
49
+ if (!res.ok) {
50
+ const err = await res.json().catch(() => ({ error: res.statusText }));
51
+ console.error(`API key authentication failed: ${err.error || res.statusText}`);
52
+ process.exit(1);
53
+ }
54
+ const tokens = await res.json();
55
+ const { error: sessionError } = await supabase.auth.setSession({
56
+ access_token: tokens.access_token,
57
+ refresh_token: tokens.refresh_token,
58
+ });
59
+ if (sessionError) {
60
+ console.error(`Failed to set session from API key: ${sessionError.message}`);
61
+ process.exit(1);
62
+ }
63
+ userId = tokens.user_id;
64
+ }
65
+ catch (err) {
66
+ console.error(`API key authentication failed: ${err.message}`);
67
+ process.exit(1);
68
+ }
69
+ return;
70
+ }
71
+ // 2. Refresh token (browser-based flow)
40
72
  if (refreshToken) {
41
73
  lastPersistedToken = refreshToken;
42
74
  const { data, error } = await supabase.auth.refreshSession({ refresh_token: refreshToken });
@@ -62,7 +94,7 @@ export async function authenticate() {
62
94
  userId = data.user.id;
63
95
  return;
64
96
  }
65
- console.error('Missing SLYPLAN_REFRESH_TOKEN (or SLYPLAN_EMAIL + SLYPLAN_PASSWORD).');
97
+ console.error('Missing SLYPLAN_API_KEY, SLYPLAN_REFRESH_TOKEN, or SLYPLAN_EMAIL + SLYPLAN_PASSWORD.');
66
98
  console.error('Run "npx slyplan-mcp setup" to configure authentication.');
67
99
  process.exit(1);
68
100
  }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "slyplan-mcp",
3
- "version": "1.5.2",
3
+ "version": "1.6.3",
4
4
  "description": "MCP server for Slyplan — visual project management via Claude",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "slyplan-mcp": "dist/index.js"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "dist",
11
+ "commands"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "tsc",