dr-claw 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/assets/{_basePickBy-CTZ7OJiJ.js → _basePickBy-7CEpiVqc.js} +1 -1
  2. package/dist/assets/{_baseUniq-LYG5erFT.js → _baseUniq-yh8GoKah.js} +1 -1
  3. package/dist/assets/{arc-CYhqYtN8.js → arc-xxYot-So.js} +1 -1
  4. package/dist/assets/{architectureDiagram-VXUJARFQ-B7xb9vYd.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  5. package/dist/assets/{blockDiagram-VD42YOAC-Dz6eN3Yc.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  6. package/dist/assets/{c4Diagram-YG6GDRKO-DIa9rHl5.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  7. package/dist/assets/channel-BJdHHkhR.js +1 -0
  8. package/dist/assets/{chunk-4BX2VUAB-JZVAPeYL.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  9. package/dist/assets/{chunk-55IACEB6-BoNiAnP-.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  10. package/dist/assets/{chunk-B4BG7PRW-BURhBXxZ.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  11. package/dist/assets/{chunk-DI55MBZ5-Bxa-FsXa.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  12. package/dist/assets/{chunk-FMBD7UC4-Dn_6iLa4.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  13. package/dist/assets/{chunk-QN33PNHL-B0N2-K2K.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  14. package/dist/assets/{chunk-QZHKN3VN-Bd7csSYW.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  15. package/dist/assets/{chunk-TZMSLE5B-DV5aJTr6.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  16. package/dist/assets/{classDiagram-2ON5EDUG-iZL_J_xq.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-WZHVMYZB-iZL_J_xq.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  18. package/dist/assets/clone-CwXTbwxl.js +1 -0
  19. package/dist/assets/{cose-bilkent-S5V4N54A-C_Uhc5Rn.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  20. package/dist/assets/{dagre-6UL2VRFP-BiW_P9KL.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  21. package/dist/assets/{diagram-PSM6KHXK-uGQgzEbt.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  22. package/dist/assets/{diagram-QEK2KX5R-qox_ZDR7.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  23. package/dist/assets/{diagram-S2PKOQOG-RKFFDPoU.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  24. package/dist/assets/{erDiagram-Q2GNP2WA-11Go9j0k.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  25. package/dist/assets/{flowDiagram-NV44I4VS-DBAiHvAx.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  26. package/dist/assets/{ganttDiagram-JELNMOA3-Dzc11a-7.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  27. package/dist/assets/{gitGraphDiagram-V2S2FVAM-DJVXfOqP.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  28. package/dist/assets/{graph-CJ95S3XQ.js → graph-DGtZXuDY.js} +1 -1
  29. package/dist/assets/index-C-sL-ag0.css +1 -0
  30. package/dist/assets/index-VdqxIFPL.js +670 -0
  31. package/dist/assets/{infoDiagram-HS3SLOUP-DpdkGgBK.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  32. package/dist/assets/{journeyDiagram-XKPGCS4Q-CSGpNz7D.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  33. package/dist/assets/{kanban-definition-3W4ZIXB7-BvPB3vMM.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  34. package/dist/assets/{layout-C96BbhFQ.js → layout-Cnc-DCSb.js} +1 -1
  35. package/dist/assets/{linear-HjDfr5-H.js → linear-BtRgmV19.js} +1 -1
  36. package/dist/assets/{mermaid.core-J7RjAKTD.js → mermaid.core-B20uzv6N.js} +5 -5
  37. package/dist/assets/{mindmap-definition-VGOIOE7T-C6MlEWCC.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  38. package/dist/assets/{pieDiagram-ADFJNKIX-Bi5BLoNG.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-AYHSOK5B-Bo14V3Q1.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  40. package/dist/assets/{requirementDiagram-UZGBJVZJ-CP3_EwJ4.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-TZEHDZUN-B3wFfI_W.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-WL72ISMW-fZVhVt_s.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  43. package/dist/assets/{stateDiagram-FKZM4ZOC-BooCitAX.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  45. package/dist/assets/{timeline-definition-IT6M3QCI-CM0zUx-Q.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  46. package/dist/assets/{treemap-GDKQZRPO-B-8ph3e8.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  47. package/dist/assets/{xychartDiagram-PRI3JC2R-Q2k0EFpn.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  48. package/dist/icons/nanoclaude.png +0 -0
  49. package/dist/index.html +2 -2
  50. package/package.json +1 -1
  51. package/public/icons/nanoclaude.png +0 -0
  52. package/scripts/native-runtime.mjs +5 -3
  53. package/server/__tests__/gemini-api.test.mjs +699 -0
  54. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  55. package/server/claude-sdk.js +82 -3
  56. package/server/cli-chat.js +28 -17
  57. package/server/cli.js +13 -1
  58. package/server/data/news-log-arxiv.json +1 -0
  59. package/server/data/news-log-huggingface.json +1 -0
  60. package/server/data/news-results-arxiv.json +460 -0
  61. package/server/data/news-results-huggingface.json +817 -0
  62. package/server/data/research_interests_arxiv.json +50 -0
  63. package/server/data/research_interests_huggingface.json +4 -0
  64. package/server/database/db.js +92 -0
  65. package/server/database/init.sql +17 -1
  66. package/server/gemini-api.js +1838 -0
  67. package/server/gemini-cli.js +13 -2
  68. package/server/index.js +94 -17
  69. package/server/load-env.js +13 -1
  70. package/server/local-gpu.js +3 -1
  71. package/server/nano-claude-code.js +340 -0
  72. package/server/nanoSessionPaths.js +41 -0
  73. package/server/openai-codex.js +6 -2
  74. package/server/openrouter.js +37 -22
  75. package/server/projects.js +207 -0
  76. package/server/routes/agent.js +32 -7
  77. package/server/routes/auto-research.js +1 -1
  78. package/server/routes/claude-btw.js +55 -0
  79. package/server/routes/cli-auth.js +103 -2
  80. package/server/routes/commands.js +25 -1
  81. package/server/routes/community-tools.js +26 -21
  82. package/server/routes/memory.js +143 -0
  83. package/server/routes/news.js +3 -0
  84. package/server/routes/skills.js +35 -0
  85. package/server/routes/user.js +3 -5
  86. package/server/utils/__tests__/safePath.test.js +93 -0
  87. package/server/utils/cliResolution.js +13 -1
  88. package/server/utils/geminiOAuth.js +333 -0
  89. package/server/utils/memoryPrompt.js +78 -0
  90. package/server/utils/safePath.js +51 -0
  91. package/server/utils/sessionIndex.js +2 -0
  92. package/server/utils/skillExpander.js +142 -0
  93. package/server/utils/spawnAsync.js +78 -0
  94. package/shared/modelConstants.js +19 -1
  95. package/dist/assets/channel-Dk-4X8XT.js +0 -1
  96. package/dist/assets/clone-DbPu_xVW.js +0 -1
  97. package/dist/assets/index-1MRmplD1.css +0 -1
  98. package/dist/assets/index-CFbkRzqw.js +0 -664
  99. package/dist/assets/stateDiagram-v2-4FDKWEC3-BzH7qQMD.js +0 -1
@@ -25,6 +25,9 @@ const PROVIDER_INSTALLERS = {
25
25
  darwin: [
26
26
  { bin: 'brew', args: ['install', 'claude-code'], label: 'brew install claude-code' },
27
27
  ],
28
+ linux: [
29
+ { bin: 'npm', args: ['install', '-g', '@anthropic-ai/claude-code'], label: 'npm install -g @anthropic-ai/claude-code' },
30
+ ],
28
31
  win32: [
29
32
  { bin: 'winget', args: ['install', '--id', 'Anthropic.ClaudeCode', '-e'], label: 'winget install Anthropic.ClaudeCode' },
30
33
  ],
@@ -51,6 +54,9 @@ const PROVIDER_INSTALLERS = {
51
54
  darwin: [
52
55
  { bin: 'npm', args: ['install', '-g', '@openai/codex'], label: 'npm install -g @openai/codex' },
53
56
  ],
57
+ linux: [
58
+ { bin: 'npm', args: ['install', '-g', '@openai/codex'], label: 'npm install -g @openai/codex' },
59
+ ],
54
60
  win32: [
55
61
  { bin: 'npm.cmd', args: ['install', '-g', '@openai/codex'], label: 'npm install -g @openai/codex' },
56
62
  ],
@@ -64,11 +70,42 @@ const PROVIDER_INSTALLERS = {
64
70
  darwin: [
65
71
  { bin: 'npm', args: ['install', '-g', '@google/gemini-cli'], label: 'npm install -g @google/gemini-cli' },
66
72
  ],
73
+ linux: [
74
+ { bin: 'npm', args: ['install', '-g', '@google/gemini-cli'], label: 'npm install -g @google/gemini-cli' },
75
+ ],
67
76
  win32: [
68
77
  { bin: 'npm.cmd', args: ['install', '-g', '@google/gemini-cli'], label: 'npm install -g @google/gemini-cli' },
69
78
  ],
70
79
  },
71
80
  },
81
+ nano: {
82
+ displayName: 'Nano Claude Code',
83
+ docsUrl: 'https://github.com/OpenLAIR/nano-claude-code',
84
+ fallbackDownloadUrl: 'https://github.com/OpenLAIR/nano-claude-code',
85
+ commands: {
86
+ darwin: [
87
+ {
88
+ bin: 'pip3',
89
+ args: ['install', '-U', 'git+https://github.com/OpenLAIR/nano-claude-code.git'],
90
+ label: 'pip3 install from GitHub (recommended)',
91
+ },
92
+ ],
93
+ linux: [
94
+ {
95
+ bin: 'pip3',
96
+ args: ['install', '-U', 'git+https://github.com/OpenLAIR/nano-claude-code.git'],
97
+ label: 'pip3 install from GitHub (use a venv if your distro manages system Python)',
98
+ },
99
+ ],
100
+ win32: [
101
+ {
102
+ bin: 'pip',
103
+ args: ['install', '-U', 'git+https://github.com/OpenLAIR/nano-claude-code.git'],
104
+ label: 'pip install from GitHub',
105
+ },
106
+ ],
107
+ },
108
+ },
72
109
  };
73
110
 
74
111
  function buildCliInstallHint(agent) {
@@ -88,6 +125,8 @@ function buildCliInstallHint(agent) {
88
125
  return 'Gemini CLI is not installed. Install it first, then retry login.';
89
126
  case 'openrouter':
90
127
  return 'Set OPENROUTER_API_KEY in your .env file. Get a key at https://openrouter.ai/keys';
128
+ case 'nano':
129
+ return 'nano-claude-code is not on PATH. Install from https://github.com/OpenLAIR/nano-claude-code or set NANO_CLAUDE_CODE_COMMAND to the executable.';
91
130
  default:
92
131
  return 'Required CLI is not installed. Install it first, then retry login.';
93
132
  }
@@ -217,6 +256,30 @@ router.get('/providers', async (_req, res) => {
217
256
  }
218
257
  });
219
258
 
259
+ async function checkNanoCliForInstall() {
260
+ const resolvedCliCommand = await resolveAvailableCliCommand({
261
+ envVarName: 'NANO_CLAUDE_CODE_COMMAND',
262
+ defaultCommands: ['nano-claude-code'],
263
+ args: ['--version'],
264
+ appendWindowsSuffixes: true,
265
+ });
266
+ if (!resolvedCliCommand) {
267
+ return {
268
+ authenticated: false,
269
+ email: null,
270
+ error: 'nano-claude-code CLI not found',
271
+ cliAvailable: false,
272
+ cliCommand: 'nano-claude-code',
273
+ };
274
+ }
275
+ return {
276
+ authenticated: true,
277
+ email: 'Nano Claude Code',
278
+ cliAvailable: true,
279
+ cliCommand: resolvedCliCommand,
280
+ };
281
+ }
282
+
220
283
  router.post('/install/:provider', async (req, res) => {
221
284
  const provider = String(req.params.provider || '').toLowerCase();
222
285
  const definition = getInstallerDefinition(provider);
@@ -230,7 +293,7 @@ router.post('/install/:provider', async (req, res) => {
230
293
  return res.status(400).json({
231
294
  ok: false,
232
295
  provider,
233
- error: `No automatic installer is configured for ${definition.displayName} on ${process.platform}`,
296
+ error: `No automatic installer is configured for ${definition.displayName} on ${process.platform}. See the official docs for manual Linux steps.`,
234
297
  docsUrl: definition.docsUrl,
235
298
  downloadUrl: definition.fallbackDownloadUrl,
236
299
  });
@@ -245,7 +308,9 @@ router.post('/install/:provider', async (req, res) => {
245
308
  ? checkCursorStatus
246
309
  : provider === 'codex'
247
310
  ? checkCodexCredentials
248
- : checkGeminiCredentials;
311
+ : provider === 'nano'
312
+ ? checkNanoCliForInstall
313
+ : checkGeminiCredentials;
249
314
 
250
315
  const latestStatus = await checker();
251
316
 
@@ -371,6 +436,42 @@ router.get('/gemini/status', async (req, res) => {
371
436
  }
372
437
  });
373
438
 
439
+ router.get('/nano/status', async (req, res) => {
440
+ try {
441
+ const resolvedCliCommand = await resolveAvailableCliCommand({
442
+ envVarName: 'NANO_CLAUDE_CODE_COMMAND',
443
+ defaultCommands: ['nano-claude-code'],
444
+ args: ['--version'],
445
+ appendWindowsSuffixes: true,
446
+ });
447
+
448
+ if (!resolvedCliCommand) {
449
+ return res.json(buildStatusPayload({
450
+ authenticated: false,
451
+ email: null,
452
+ error: 'nano-claude-code CLI not found',
453
+ cliAvailable: false,
454
+ cliCommand: 'nano-claude-code',
455
+ installHint: buildCliInstallHint('nano'),
456
+ }, 'nano'));
457
+ }
458
+
459
+ return res.json(buildStatusPayload({
460
+ authenticated: true,
461
+ email: 'Nano Claude Code',
462
+ cliAvailable: true,
463
+ cliCommand: resolvedCliCommand,
464
+ }, 'nano'));
465
+ } catch (error) {
466
+ console.error('Error checking Nano Claude Code status:', error);
467
+ res.status(500).json({
468
+ authenticated: false,
469
+ email: null,
470
+ error: error.message,
471
+ });
472
+ }
473
+ });
474
+
374
475
  async function checkGeminiCredentials() {
375
476
  console.log('[DEBUG] Checking Gemini credentials...');
376
477
  let cliCommand = process.env.GEMINI_CLI_PATH || 'gemini';
@@ -126,6 +126,12 @@ const builtInCommands = [
126
126
  description: 'Rewind the conversation to a previous state',
127
127
  namespace: 'builtin',
128
128
  metadata: { type: 'builtin' }
129
+ },
130
+ {
131
+ name: '/btw',
132
+ description: 'Ask a quick side question in an overlay (Claude only; does not add to chat history)',
133
+ namespace: 'builtin',
134
+ metadata: { type: 'builtin' }
129
135
  }
130
136
  ];
131
137
 
@@ -396,7 +402,25 @@ Custom commands can be created in:
396
402
  message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
397
403
  }
398
404
  };
399
- }
405
+ },
406
+
407
+ '/btw': async (args) => {
408
+ const question = args.join(' ').trim();
409
+ if (!question) {
410
+ return {
411
+ type: 'builtin',
412
+ action: 'btw',
413
+ data: {
414
+ error: 'Usage: /btw <your question>',
415
+ },
416
+ };
417
+ }
418
+ return {
419
+ type: 'builtin',
420
+ action: 'btw',
421
+ data: { question },
422
+ };
423
+ },
400
424
  };
401
425
 
402
426
  /**
@@ -121,27 +121,32 @@ router.post('/configure', async (req, res) => {
121
121
  // ── Step 2: Register MCP server ──
122
122
  if (mcpBackend) {
123
123
  try {
124
- // Check if already registered
125
- let alreadyRegistered = false;
126
- try {
127
- const { stdout } = await spawnAsync('claude', ['mcp', 'list'], { env: EXTENDED_ENV });
128
- alreadyRegistered = stdout.includes(mcpBackend);
129
- } catch {
130
- // claude mcp list failed, proceed with registration
131
- }
132
-
133
- if (alreadyRegistered) {
134
- results.steps.push({ step: 'mcp', status: 'skipped', message: `${mcpBackend} MCP already registered` });
135
- } else {
136
- const mcpCommands = {
137
- codex: ['mcp', 'add', 'codex', '-s', 'user', '--', 'codex', 'mcp-server'],
138
- 'llm-chat': ['mcp', 'add', 'llm-chat', '-s', 'user', '--', 'python3', path.join(resolvedPath, 'skills/aris-infra/mcp-servers/llm-chat/server.py')],
139
- gemini: ['mcp', 'add', 'gemini-review', '-s', 'user', '--', 'python3', path.join(resolvedPath, 'skills/aris-infra/mcp-servers/gemini-review/server.py')],
140
- };
141
-
142
- const args = mcpCommands[mcpBackend];
143
- if (!args) {
124
+ const mcpCommands = {
125
+ codex: ['mcp', 'add', 'codex', '-s', 'user', '--', 'codex', 'mcp-server'],
126
+ 'llm-chat': ['mcp', 'add', 'llm-chat', '-s', 'user', '--', 'python3', path.join(resolvedPath, 'skills/aris-infra/mcp-servers/llm-chat/server.py')],
127
+ gemini: ['mcp', 'add', 'gemini-review', '-s', 'user', '--', 'python3', path.join(resolvedPath, 'skills/aris-infra/mcp-servers/gemini-review/server.py')],
128
+ };
129
+
130
+ const ENV_ONLY_BACKENDS = ['gemini-figure'];
131
+ const args = mcpCommands[mcpBackend];
132
+ if (!args) {
133
+ if (ENV_ONLY_BACKENDS.includes(mcpBackend)) {
134
+ results.steps.push({ step: 'mcp', status: 'skipped', message: `No MCP server needed for ${mcpBackend}` });
135
+ } else {
144
136
  results.errors.push({ step: 'mcp', error: `Unknown backend: ${mcpBackend}` });
137
+ }
138
+ } else {
139
+ // Check if already registered
140
+ let alreadyRegistered = false;
141
+ try {
142
+ const { stdout } = await spawnAsync('claude', ['mcp', 'list'], { env: EXTENDED_ENV });
143
+ alreadyRegistered = stdout.includes(mcpBackend);
144
+ } catch {
145
+ // claude mcp list failed, proceed with registration
146
+ }
147
+
148
+ if (alreadyRegistered) {
149
+ results.steps.push({ step: 'mcp', status: 'skipped', message: `${mcpBackend} MCP already registered` });
145
150
  } else {
146
151
  await spawnAsync('claude', args, { env: EXTENDED_ENV });
147
152
  results.steps.push({ step: 'mcp', status: 'ok', message: `Registered ${mcpBackend} MCP server` });
@@ -200,7 +205,7 @@ router.post('/configure', async (req, res) => {
200
205
  let linked = 0;
201
206
  const entries = await fs.readdir(skillsDir, { withFileTypes: true });
202
207
  for (const entry of entries) {
203
- if (entry.isDirectory() && (entry.name.startsWith('aris-') || entry.name === 'autoresearch')) {
208
+ if (entry.isDirectory() && (entry.name.startsWith('aris-') || entry.name.startsWith('inno-') || entry.name === 'autoresearch')) {
204
209
  const target = path.join(claudeSkillsDir, entry.name);
205
210
  try {
206
211
  await fs.access(target);
@@ -0,0 +1,143 @@
1
+ import express from 'express';
2
+ import { memoryDb } from '../database/db.js';
3
+ import { syncMemoryFiles } from '../utils/memoryPrompt.js';
4
+
5
+ const router = express.Router();
6
+ const MAX_CONTENT_LENGTH = 500;
7
+ const VALID_CATEGORIES = new Set(['general', 'preference', 'context', 'workflow']);
8
+
9
+ /** Parse and validate :id param as a positive integer. */
10
+ function parseId(raw) {
11
+ const id = parseInt(raw, 10);
12
+ return Number.isNaN(id) || id <= 0 ? null : id;
13
+ }
14
+
15
+ // ===============================
16
+ // Memory CRUD
17
+ // ===============================
18
+
19
+ // Get all memories for the authenticated user
20
+ router.get('/', async (req, res) => {
21
+ try {
22
+ const memories = memoryDb.getAll(req.user.id);
23
+ res.json({ memories });
24
+ } catch (error) {
25
+ console.error('Error fetching memories:', error);
26
+ res.status(500).json({ error: 'Failed to fetch memories' });
27
+ }
28
+ });
29
+
30
+ // Create a new memory
31
+ router.post('/', async (req, res) => {
32
+ try {
33
+ const { content, category } = req.body;
34
+ if (!content || !content.trim()) {
35
+ return res.status(400).json({ error: 'Memory content is required' });
36
+ }
37
+ if (content.length > MAX_CONTENT_LENGTH) {
38
+ return res.status(400).json({ error: `Memory content must be ${MAX_CONTENT_LENGTH} characters or less` });
39
+ }
40
+ const cat = VALID_CATEGORIES.has(category) ? category : 'general';
41
+ const memory = memoryDb.create(req.user.id, content.trim(), cat);
42
+ syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
43
+ res.status(201).json({ memory });
44
+ } catch (error) {
45
+ if (error.message?.includes('Maximum of')) {
46
+ return res.status(400).json({ error: error.message });
47
+ }
48
+ console.error('Error creating memory:', error);
49
+ res.status(500).json({ error: 'Failed to create memory' });
50
+ }
51
+ });
52
+
53
+ // Update a memory
54
+ router.put('/:id', async (req, res) => {
55
+ try {
56
+ const id = parseId(req.params.id);
57
+ if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
58
+
59
+ const { content, category } = req.body;
60
+ if (content !== undefined && content.length > MAX_CONTENT_LENGTH) {
61
+ return res.status(400).json({ error: `Memory content must be ${MAX_CONTENT_LENGTH} characters or less` });
62
+ }
63
+ const updates = {};
64
+ if (content !== undefined) updates.content = content.trim();
65
+ if (category !== undefined) updates.category = VALID_CATEGORIES.has(category) ? category : 'general';
66
+
67
+ const memory = memoryDb.update(req.user.id, id, updates);
68
+ if (!memory) {
69
+ return res.status(404).json({ error: 'Memory not found' });
70
+ }
71
+ syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
72
+ res.json({ memory });
73
+ } catch (error) {
74
+ console.error('Error updating memory:', error);
75
+ res.status(500).json({ error: 'Failed to update memory' });
76
+ }
77
+ });
78
+
79
+ // Toggle memory enabled/disabled
80
+ router.patch('/:id/toggle', async (req, res) => {
81
+ try {
82
+ const id = parseId(req.params.id);
83
+ if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
84
+
85
+ const memory = memoryDb.toggle(req.user.id, id);
86
+ if (!memory) {
87
+ return res.status(404).json({ error: 'Memory not found' });
88
+ }
89
+ syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
90
+ res.json({ memory });
91
+ } catch (error) {
92
+ console.error('Error toggling memory:', error);
93
+ res.status(500).json({ error: 'Failed to toggle memory' });
94
+ }
95
+ });
96
+
97
+ // Delete a memory
98
+ router.delete('/:id', async (req, res) => {
99
+ try {
100
+ const id = parseId(req.params.id);
101
+ if (!id) return res.status(400).json({ error: 'Invalid memory ID' });
102
+
103
+ const deleted = memoryDb.delete(req.user.id, id);
104
+ if (!deleted) {
105
+ return res.status(404).json({ error: 'Memory not found' });
106
+ }
107
+ syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
108
+ res.json({ success: true });
109
+ } catch (error) {
110
+ console.error('Error deleting memory:', error);
111
+ res.status(500).json({ error: 'Failed to delete memory' });
112
+ }
113
+ });
114
+
115
+ // ===============================
116
+ // Per-User Memory Settings
117
+ // ===============================
118
+
119
+ // Get memory enabled state for the authenticated user
120
+ router.get('/settings', async (req, res) => {
121
+ try {
122
+ const enabled = memoryDb.getMemoryEnabled(req.user.id);
123
+ res.json({ enabled });
124
+ } catch (error) {
125
+ console.error('Error fetching memory settings:', error);
126
+ res.status(500).json({ error: 'Failed to fetch memory settings' });
127
+ }
128
+ });
129
+
130
+ // Set memory enabled state for the authenticated user
131
+ router.patch('/settings', async (req, res) => {
132
+ try {
133
+ const { enabled } = req.body;
134
+ memoryDb.setMemoryEnabled(req.user.id, !!enabled);
135
+ syncMemoryFiles(req.user.id).catch(err => console.warn('[memory] sync error:', err.message));
136
+ res.json({ enabled: !!enabled });
137
+ } catch (error) {
138
+ console.error('Error updating memory settings:', error);
139
+ res.status(500).json({ error: 'Failed to update memory settings' });
140
+ }
141
+ });
142
+
143
+ export default router;
@@ -488,6 +488,9 @@ router.post('/search/:source', (req, res) => handleSearch(req.params.source, req
488
488
  // GET /api/news/logs/:source — poll search progress logs
489
489
  router.get('/logs/:source', async (req, res) => {
490
490
  try {
491
+ const entry = getSourceEntry(req.params.source);
492
+ if (!entry) return res.status(404).json({ error: `Unknown source: ${req.params.source}` });
493
+
491
494
  const logPath = path.join(DATA_DIR, `news-log-${req.params.source}.json`);
492
495
  const data = await fs.readFile(logPath, 'utf8');
493
496
  res.json({ logs: JSON.parse(data) });
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { extractProjectDirectory, ensureProjectSkillLinks } from '../projects.js';
7
7
  import { FORBIDDEN_PATHS } from './projects.js';
8
+ import { findSkillMdPath } from '../utils/skillExpander.js';
8
9
 
9
10
  const GLOBAL_SKILLS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
10
11
 
@@ -223,6 +224,40 @@ async function validateZipBuffer(buffer) {
223
224
  };
224
225
  }
225
226
 
227
+ // GET /resolve — resolve a skill name to its SKILL.md path and content
228
+ router.get('/resolve', async (req, res) => {
229
+ try {
230
+ const { name, workingDir } = req.query;
231
+ if (!name || typeof name !== 'string') {
232
+ return res.status(400).json({ error: 'name query parameter is required' });
233
+ }
234
+ // Prevent path traversal
235
+ if (/[/\\]|\.\./.test(name)) {
236
+ return res.status(400).json({ error: 'Invalid skill name' });
237
+ }
238
+ const effectiveWorkingDir = (typeof workingDir === 'string' && workingDir.trim()) || process.cwd();
239
+ // Validate workingDir: must be absolute and not a forbidden system path
240
+ if (effectiveWorkingDir !== process.cwd()) {
241
+ if (!path.isAbsolute(effectiveWorkingDir)) {
242
+ return res.status(400).json({ error: 'workingDir must be an absolute path' });
243
+ }
244
+ const normalized = path.normalize(effectiveWorkingDir);
245
+ if (FORBIDDEN_PATHS.some((fp) => normalized === fp || normalized.startsWith(fp + path.sep))) {
246
+ return res.status(400).json({ error: 'Invalid workingDir' });
247
+ }
248
+ }
249
+ const skillMdPath = await findSkillMdPath(name, effectiveWorkingDir);
250
+ if (!skillMdPath) {
251
+ return res.status(404).json({ error: 'Skill not found' });
252
+ }
253
+ const content = await fs.readFile(skillMdPath, 'utf-8');
254
+ res.json({ path: skillMdPath, content });
255
+ } catch (error) {
256
+ console.error('[skills] resolve error:', error.message);
257
+ res.status(500).json({ error: error.message });
258
+ }
259
+ });
260
+
226
261
  // GET / — return the file tree of the global skills/ directory
227
262
  router.get('/', async (req, res) => {
228
263
  try {
@@ -2,10 +2,8 @@ import express from 'express';
2
2
  import { userDb } from '../database/db.js';
3
3
  import { authenticateToken } from '../middleware/auth.js';
4
4
  import { getSystemGitConfig } from '../utils/gitConfig.js';
5
- import { exec } from 'child_process';
6
- import { promisify } from 'util';
5
+ import spawnAsync from '../utils/spawnAsync.js';
7
6
 
8
- const execAsync = promisify(exec);
9
7
  const router = express.Router();
10
8
 
11
9
  router.get('/git-config', authenticateToken, async (req, res) => {
@@ -55,8 +53,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
55
53
  userDb.updateGitConfig(userId, gitName, gitEmail);
56
54
 
57
55
  try {
58
- await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
59
- await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
56
+ await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
57
+ await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
60
58
  console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
61
59
  } catch (gitError) {
62
60
  console.error('Error applying git config:', gitError);
@@ -0,0 +1,93 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import { safePath } from '../safePath.js';
6
+
7
+ // Use a real temporary directory so realpathSync works correctly
8
+ let ROOT;
9
+
10
+ beforeAll(() => {
11
+ // Use realpathSync to normalize macOS /var -> /private/var symlink
12
+ ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'safepath-test-')));
13
+ fs.mkdirSync(path.join(ROOT, 'src'), { recursive: true });
14
+ fs.mkdirSync(path.join(ROOT, 'lib'), { recursive: true });
15
+ fs.writeFileSync(path.join(ROOT, 'src', 'index.js'), '');
16
+ fs.writeFileSync(path.join(ROOT, 'lib', 'utils.js'), '');
17
+ });
18
+
19
+ afterAll(() => {
20
+ fs.rmSync(ROOT, { recursive: true, force: true });
21
+ });
22
+
23
+ describe('safePath', () => {
24
+ it('resolves relative paths within root', () => {
25
+ const result = safePath('src/index.js', ROOT);
26
+ expect(result).toBe(path.join(ROOT, 'src', 'index.js'));
27
+ });
28
+
29
+ it('returns root when path is empty/null/undefined', () => {
30
+ expect(safePath('', ROOT)).toBe(ROOT);
31
+ expect(safePath(null, ROOT)).toBe(ROOT);
32
+ expect(safePath(undefined, ROOT)).toBe(ROOT);
33
+ });
34
+
35
+ it('allows absolute paths that land inside root', () => {
36
+ const absInside = path.join(ROOT, 'src', 'index.js');
37
+ const result = safePath(absInside, ROOT);
38
+ expect(result).toBe(absInside);
39
+ });
40
+
41
+ it('blocks absolute paths outside root', () => {
42
+ expect(() => safePath('/etc/passwd', ROOT)).toThrow(/Path traversal blocked/);
43
+ });
44
+
45
+ it('blocks .. traversal above root', () => {
46
+ expect(() => safePath('../../../etc/shadow', ROOT)).toThrow(/Path traversal blocked/);
47
+ });
48
+
49
+ it('blocks .. traversal disguised in deeper path', () => {
50
+ expect(() => safePath('src/../../../../../../etc/passwd', ROOT)).toThrow(
51
+ /Path traversal blocked/,
52
+ );
53
+ });
54
+
55
+ it('allows .. that stays within root', () => {
56
+ const result = safePath('src/../lib/utils.js', ROOT);
57
+ expect(result).toBe(path.join(ROOT, 'lib', 'utils.js'));
58
+ });
59
+
60
+ it('handles non-existent target gracefully', () => {
61
+ // Non-existent file in existing directory — should work
62
+ const result = safePath('src/newfile.js', ROOT);
63
+ expect(result).toBe(path.join(ROOT, 'src', 'newfile.js'));
64
+ });
65
+
66
+ it('handles non-existent nested path gracefully', () => {
67
+ // Non-existent nested path — should still resolve within root
68
+ const result = safePath('deep/nested/new/file.js', ROOT);
69
+ expect(result.startsWith(ROOT + path.sep)).toBe(true);
70
+ });
71
+
72
+ it('normalizes allowedRoot for falsy input', () => {
73
+ // Pass a non-normalized root (with trailing segments) to verify
74
+ // the falsy-input path returns path.resolve(allowedRoot), not the raw string.
75
+ const nonNormalized = ROOT + path.sep + 'src' + path.sep + '..';
76
+ const result = safePath('', nonNormalized);
77
+ expect(result).toBe(ROOT);
78
+ });
79
+
80
+ it('allows symlinks inside the project that point outside the root', () => {
81
+ // Simulate: project/data -> /tmp (an external location)
82
+ // This should NOT be blocked — legitimate workflow (shared datasets, etc.)
83
+ const linkPath = path.join(ROOT, 'external-data');
84
+ try {
85
+ fs.symlinkSync(os.tmpdir(), linkPath);
86
+ // Logical path is inside root, so safePath should allow it
87
+ const result = safePath('external-data/some-file.csv', ROOT);
88
+ expect(result).toBe(path.join(ROOT, 'external-data', 'some-file.csv'));
89
+ } finally {
90
+ try { fs.unlinkSync(linkPath); } catch { /* ignore cleanup errors */ }
91
+ }
92
+ });
93
+ });
@@ -9,6 +9,7 @@ function isCommandNotFoundExitCode(code) {
9
9
  *
10
10
  * @param {Object} options
11
11
  * @param {string} options.envVarName Environment variable name containing an override command.
12
+ * @param {string[]} [options.legacyEnvVarNames=[]] Older env var names checked after envVarName (migration).
12
13
  * @param {string[]} options.defaultCommands Fallback command names in preference order.
13
14
  * @param {string} [options.platform=process.platform] Runtime platform, used for Windows suffix handling.
14
15
  * @param {boolean} [options.appendWindowsSuffixes=false] Whether to append .cmd/.exe candidates on Windows.
@@ -16,11 +17,19 @@ function isCommandNotFoundExitCode(code) {
16
17
  */
17
18
  function getCliCommandCandidates({
18
19
  envVarName,
20
+ legacyEnvVarNames = [],
19
21
  defaultCommands,
20
22
  platform = process.platform,
21
23
  appendWindowsSuffixes = false
22
24
  }) {
23
- const envCommand = String(process.env[envVarName] || '').trim();
25
+ let envCommand = '';
26
+ for (const key of [envVarName, ...legacyEnvVarNames].filter(Boolean)) {
27
+ const s = String(process.env[key] || '').trim();
28
+ if (s) {
29
+ envCommand = s;
30
+ break;
31
+ }
32
+ }
24
33
  const rawCandidates = [];
25
34
 
26
35
  if (envCommand) {
@@ -131,6 +140,7 @@ function checkCommandAvailable(command, args = ['--help'], { platform = process.
131
140
  *
132
141
  * @param {Object} options
133
142
  * @param {string} options.envVarName Environment variable with command override.
143
+ * @param {string[]} [options.legacyEnvVarNames=[]] Older env vars (after envVarName) for migration.
134
144
  * @param {string[]} options.defaultCommands Fallback command names in preference order.
135
145
  * @param {string[]} [options.args=['--help']] Probe arguments.
136
146
  * @param {string} [options.platform=process.platform] Runtime platform.
@@ -140,6 +150,7 @@ function checkCommandAvailable(command, args = ['--help'], { platform = process.
140
150
  */
141
151
  async function resolveAvailableCliCommand({
142
152
  envVarName,
153
+ legacyEnvVarNames = [],
143
154
  defaultCommands,
144
155
  args = ['--help'],
145
156
  platform = process.platform,
@@ -148,6 +159,7 @@ async function resolveAvailableCliCommand({
148
159
  }) {
149
160
  const candidates = getCliCommandCandidates({
150
161
  envVarName,
162
+ legacyEnvVarNames,
151
163
  defaultCommands,
152
164
  platform,
153
165
  appendWindowsSuffixes