dr-claw 1.1.2 → 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 (113) hide show
  1. package/README.md +286 -177
  2. package/README.zh-CN.md +286 -174
  3. package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
  4. package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
  5. package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  9. package/dist/assets/channel-BJdHHkhR.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  18. package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  20. package/dist/assets/clone-CwXTbwxl.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  30. package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
  31. package/dist/assets/index-C-sL-ag0.css +1 -0
  32. package/dist/assets/index-VdqxIFPL.js +670 -0
  33. package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  36. package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
  37. package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
  38. package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  50. package/dist/icons/nanoclaude.png +0 -0
  51. package/dist/index.html +2 -2
  52. package/dist/screenshots/screenshot_v1_2.png +0 -0
  53. package/package.json +61 -4
  54. package/public/icons/nanoclaude.png +0 -0
  55. package/public/screenshots/screenshot_v1_2.png +0 -0
  56. package/scripts/build-electron-icons.mjs +40 -0
  57. package/scripts/check-deps.js +28 -0
  58. package/scripts/native-runtime.mjs +128 -0
  59. package/server/__tests__/gemini-api.test.mjs +699 -0
  60. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  61. package/server/__tests__/project-sync-dedup.test.mjs +173 -0
  62. package/server/__tests__/session-delete.test.mjs +152 -0
  63. package/server/claude-sdk.js +175 -14
  64. package/server/cli-chat.js +28 -17
  65. package/server/cli.js +48 -11
  66. package/server/data/news-log-arxiv.json +1 -0
  67. package/server/data/news-log-huggingface.json +1 -0
  68. package/server/data/news-results-arxiv.json +460 -0
  69. package/server/data/news-results-huggingface.json +817 -0
  70. package/server/data/research_interests_arxiv.json +50 -0
  71. package/server/data/research_interests_huggingface.json +4 -0
  72. package/server/database/db.js +101 -5
  73. package/server/database/init.sql +17 -1
  74. package/server/gemini-api.js +1838 -0
  75. package/server/gemini-cli.js +13 -2
  76. package/server/index.js +94 -16
  77. package/server/load-env.js +13 -1
  78. package/server/local-gpu.js +166 -24
  79. package/server/mcp-compute.js +223 -0
  80. package/server/middleware/auth.js +25 -6
  81. package/server/nano-claude-code.js +340 -0
  82. package/server/nanoSessionPaths.js +41 -0
  83. package/server/openai-codex.js +6 -2
  84. package/server/openrouter.js +37 -22
  85. package/server/projects.js +516 -110
  86. package/server/routes/agent.js +32 -7
  87. package/server/routes/auto-research.js +1 -1
  88. package/server/routes/claude-btw.js +55 -0
  89. package/server/routes/cli-auth.js +422 -58
  90. package/server/routes/commands.js +25 -1
  91. package/server/routes/community-tools.js +398 -0
  92. package/server/routes/compute.js +155 -0
  93. package/server/routes/git.js +9 -11
  94. package/server/routes/memory.js +143 -0
  95. package/server/routes/news.js +3 -0
  96. package/server/routes/skills.js +35 -0
  97. package/server/routes/user.js +3 -5
  98. package/server/utils/__tests__/safePath.test.js +93 -0
  99. package/server/utils/cliResolution.js +13 -1
  100. package/server/utils/codexSessionMessages.js +285 -0
  101. package/server/utils/geminiOAuth.js +333 -0
  102. package/server/utils/memoryPrompt.js +78 -0
  103. package/server/utils/safePath.js +51 -0
  104. package/server/utils/sessionIndex.js +2 -0
  105. package/server/utils/skillExpander.js +142 -0
  106. package/server/utils/spawnAsync.js +78 -0
  107. package/shared/modelConstants.js +19 -1
  108. package/dist/assets/channel-BFOE7OyC.js +0 -1
  109. package/dist/assets/clone-BulQSblH.js +0 -1
  110. package/dist/assets/index-B8Yutsnm.css +0 -1
  111. package/dist/assets/index-Cf6gyhc3.js +0 -654
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
  113. package/scripts/install-python-tools.js +0 -64
@@ -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
  /**
@@ -0,0 +1,398 @@
1
+ import express from 'express';
2
+ import { spawn } from 'child_process';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { loadAllNodes, loadNodeConfig, ComputeNode } from '../compute-node.js';
7
+
8
+ const router = express.Router();
9
+
10
+ // Ensure ~/.local/bin and common paths are on PATH for spawned processes
11
+ const EXTENDED_ENV = {
12
+ ...process.env,
13
+ HOME: os.homedir(),
14
+ PATH: [
15
+ path.join(os.homedir(), '.local', 'bin'),
16
+ path.join(os.homedir(), '.npm-global', 'bin'),
17
+ '/usr/local/bin',
18
+ process.env.PATH || '',
19
+ ].join(':'),
20
+ };
21
+
22
+ function spawnAsync(command, args, options = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(command, args, {
25
+ ...options,
26
+ shell: false,
27
+ });
28
+
29
+ let stdout = '';
30
+ let stderr = '';
31
+
32
+ child.stdout.on('data', (data) => {
33
+ stdout += data.toString();
34
+ });
35
+
36
+ child.stderr.on('data', (data) => {
37
+ stderr += data.toString();
38
+ });
39
+
40
+ child.on('error', (error) => {
41
+ reject(error);
42
+ });
43
+
44
+ child.on('close', (code) => {
45
+ if (code === 0) {
46
+ resolve({ stdout, stderr });
47
+ return;
48
+ }
49
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
50
+ error.code = code;
51
+ error.stdout = stdout;
52
+ error.stderr = stderr;
53
+ reject(error);
54
+ });
55
+ });
56
+ }
57
+
58
+ // POST /api/community-tools/configure
59
+ // Body: { projectPath, mcpBackend, apiKeys, gpuConfig }
60
+ router.post('/configure', async (req, res) => {
61
+ try {
62
+ if (!req.body || typeof req.body !== 'object') {
63
+ return res.status(400).json({ error: 'Invalid request body' });
64
+ }
65
+ const { projectPath, mcpBackend, apiKeys, gpuConfig } = req.body;
66
+
67
+ // Resolve project path: use provided path, or fall back to server's working directory
68
+ // (which is typically the VibeLab install directory)
69
+ let resolvedPath;
70
+ if (projectPath && projectPath !== '.') {
71
+ resolvedPath = path.resolve(projectPath);
72
+ } else {
73
+ // Use __dirname parent (VibeLab root) as default
74
+ // community-tools.js is in server/routes/ — go up 2 levels to VibeLab root
75
+ resolvedPath = path.resolve(path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..'));
76
+ }
77
+
78
+ const results = { steps: [], errors: [] };
79
+
80
+ // ── Step 1: Write API keys to ~/.openclaw/community-tools.json ──
81
+ // (NOT project .env — writing .env triggers Vite restart and kills the fetch)
82
+ if (apiKeys && Object.keys(apiKeys).length > 0) {
83
+ try {
84
+ const configDir = path.join(os.homedir(), '.openclaw');
85
+ const configPath = path.join(configDir, 'community-tools.json');
86
+ await fs.mkdir(configDir, { recursive: true });
87
+
88
+ let existing = {};
89
+ try {
90
+ existing = JSON.parse(await fs.readFile(configPath, 'utf8'));
91
+ } catch {
92
+ // file doesn't exist yet
93
+ }
94
+
95
+ // Merge keys into config
96
+ const envKeys = existing.envKeys || {};
97
+ for (const [key, value] of Object.entries(apiKeys)) {
98
+ if (value && value.trim()) {
99
+ envKeys[key] = value.trim();
100
+ }
101
+ }
102
+ existing.envKeys = envKeys;
103
+
104
+ await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
105
+
106
+ // Also write a shell-sourceable file for convenience
107
+ // Escape values: replace \ with \\, " with \", $ with \$, ` with \`
108
+ const shellEscape = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
109
+ const rcPath = path.join(configDir, 'community-tools.env');
110
+ const rcContent = Object.entries(envKeys)
111
+ .map(([k, v]) => `export ${k}="${shellEscape(v)}"`)
112
+ .join('\n') + '\n';
113
+ await fs.writeFile(rcPath, rcContent, { mode: 0o600 });
114
+
115
+ results.steps.push({ step: 'env', status: 'ok', message: `Saved ${Object.keys(apiKeys).length} key(s) to ~/.openclaw/community-tools.json` });
116
+ } catch (err) {
117
+ results.errors.push({ step: 'env', error: err.message });
118
+ }
119
+ }
120
+
121
+ // ── Step 2: Register MCP server ──
122
+ if (mcpBackend) {
123
+ try {
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 {
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` });
150
+ } else {
151
+ await spawnAsync('claude', args, { env: EXTENDED_ENV });
152
+ results.steps.push({ step: 'mcp', status: 'ok', message: `Registered ${mcpBackend} MCP server` });
153
+ }
154
+ }
155
+
156
+ // Install codex CLI if needed
157
+ if (mcpBackend === 'codex') {
158
+ try {
159
+ await spawnAsync('which', ['codex']);
160
+ results.steps.push({ step: 'codex-cli', status: 'skipped', message: 'Codex CLI already installed' });
161
+ } catch {
162
+ try {
163
+ await spawnAsync('npm', ['install', '-g', '@openai/codex'], { env: EXTENDED_ENV });
164
+ results.steps.push({ step: 'codex-cli', status: 'ok', message: 'Installed Codex CLI' });
165
+ } catch (err) {
166
+ results.errors.push({ step: 'codex-cli', error: `Failed to install codex: ${err.message}` });
167
+ }
168
+ }
169
+ }
170
+ } catch (err) {
171
+ results.errors.push({ step: 'mcp', error: err.message });
172
+ }
173
+ }
174
+
175
+ // ── Step 3: Write GPU config to CLAUDE.md ──
176
+ if (gpuConfig) {
177
+ try {
178
+ const claudeMdPath = path.join(resolvedPath, 'CLAUDE.md');
179
+ let claudeContent = '';
180
+ try {
181
+ claudeContent = await fs.readFile(claudeMdPath, 'utf8');
182
+ } catch {
183
+ // CLAUDE.md doesn't exist
184
+ }
185
+
186
+ // Check if GPU section already exists
187
+ if (claudeContent.includes('gpu:')) {
188
+ results.steps.push({ step: 'gpu', status: 'skipped', message: 'GPU config already exists in CLAUDE.md' });
189
+ } else {
190
+ const separator = claudeContent.trim() ? '\n\n' : '';
191
+ await fs.writeFile(claudeMdPath, claudeContent + separator + gpuConfig.trim() + '\n', 'utf8');
192
+ results.steps.push({ step: 'gpu', status: 'ok', message: 'Added GPU config to CLAUDE.md' });
193
+ }
194
+ } catch (err) {
195
+ results.errors.push({ step: 'gpu', error: err.message });
196
+ }
197
+ }
198
+
199
+ // ── Step 4: Symlink skills ──
200
+ try {
201
+ const skillsDir = path.join(resolvedPath, 'skills');
202
+ const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
203
+ await fs.mkdir(claudeSkillsDir, { recursive: true });
204
+
205
+ let linked = 0;
206
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
207
+ for (const entry of entries) {
208
+ if (entry.isDirectory() && (entry.name.startsWith('aris-') || entry.name.startsWith('inno-') || entry.name === 'autoresearch')) {
209
+ const target = path.join(claudeSkillsDir, entry.name);
210
+ try {
211
+ await fs.access(target);
212
+ // already exists
213
+ } catch {
214
+ await fs.symlink(path.join(skillsDir, entry.name), target);
215
+ linked++;
216
+ }
217
+ }
218
+ }
219
+ results.steps.push({ step: 'skills', status: 'ok', message: `${linked} new skill(s) linked` });
220
+ } catch (err) {
221
+ results.errors.push({ step: 'skills', error: err.message });
222
+ }
223
+
224
+ const success = results.errors.length === 0;
225
+ res.json({ success, ...results });
226
+ } catch (error) {
227
+ console.error('Community tools configure error:', error);
228
+ res.status(500).json({ error: 'Configuration failed. Check server logs for details.' });
229
+ }
230
+ });
231
+
232
+ // GET /api/community-tools/status
233
+ router.get('/status', async (req, res) => {
234
+ try {
235
+ const projectPath = req.query.path;
236
+ if (!projectPath) {
237
+ return res.status(400).json({ error: 'path query param required' });
238
+ }
239
+
240
+ const resolvedPath = path.resolve(projectPath);
241
+ // Validate path is within home directory to prevent path traversal
242
+ if (!resolvedPath.startsWith(os.homedir())) {
243
+ return res.status(403).json({ error: 'Invalid path' });
244
+ }
245
+ const status = {
246
+ mcpRegistered: {},
247
+ envKeys: {},
248
+ gpuConfigured: false,
249
+ skillsLinked: 0,
250
+ };
251
+
252
+ // Check MCP servers
253
+ try {
254
+ const { stdout } = await spawnAsync('claude', ['mcp', 'list'], { env: EXTENDED_ENV });
255
+ status.mcpRegistered.codex = stdout.includes('codex');
256
+ status.mcpRegistered['llm-chat'] = stdout.includes('llm-chat');
257
+ status.mcpRegistered.gemini = stdout.includes('gemini-review');
258
+ } catch {
259
+ // claude CLI not available
260
+ }
261
+
262
+ // Check .env keys
263
+ try {
264
+ const envContent = await fs.readFile(path.join(resolvedPath, '.env'), 'utf8');
265
+ status.envKeys.OPENAI_API_KEY = envContent.includes('OPENAI_API_KEY=') && !envContent.includes('OPENAI_API_KEY=\n');
266
+ status.envKeys.LLM_API_KEY = envContent.includes('LLM_API_KEY=') && !envContent.includes('LLM_API_KEY=\n');
267
+ status.envKeys.GEMINI_API_KEY = envContent.includes('GEMINI_API_KEY=') && !envContent.includes('GEMINI_API_KEY=\n');
268
+ } catch {
269
+ // no .env
270
+ }
271
+
272
+ // Check CLAUDE.md GPU config
273
+ try {
274
+ const claudeContent = await fs.readFile(path.join(resolvedPath, 'CLAUDE.md'), 'utf8');
275
+ status.gpuConfigured = claudeContent.includes('gpu:');
276
+ } catch {
277
+ // no CLAUDE.md
278
+ }
279
+
280
+ // Check skill symlinks
281
+ try {
282
+ const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
283
+ const entries = await fs.readdir(claudeSkillsDir);
284
+ status.skillsLinked = entries.filter((e) => e.startsWith('aris-')).length;
285
+ } catch {
286
+ // no skills dir
287
+ }
288
+
289
+ res.json(status);
290
+ } catch (error) {
291
+ res.status(500).json({ error: 'Internal server error' });
292
+ }
293
+ });
294
+
295
+ // GET /api/community-tools/compute-nodes — list available compute nodes for GPU config
296
+ router.get('/compute-nodes', async (req, res) => {
297
+ try {
298
+ const config = await loadAllNodes();
299
+ const nodes = (config.nodes || []).map((n) => ({
300
+ id: n.id,
301
+ name: n.name || n.host,
302
+ host: n.host,
303
+ user: n.user,
304
+ port: n.port || 22,
305
+ workDir: n.workDir || '~',
306
+ type: n.type || 'direct',
307
+ gpuInfo: n.gpuInfo || null,
308
+ }));
309
+ res.json({ nodes, activeNodeId: config.activeNodeId });
310
+ } catch (error) {
311
+ res.status(500).json({ error: 'Internal server error' });
312
+ }
313
+ });
314
+
315
+ // POST /api/community-tools/detect-gpu — detect GPU info from a compute node and return CLAUDE.md template
316
+ router.post('/detect-gpu', async (req, res) => {
317
+ try {
318
+ const { nodeId } = req.body;
319
+ if (!nodeId) {
320
+ return res.status(400).json({ error: 'nodeId is required' });
321
+ }
322
+
323
+ const node = await loadNodeConfig(nodeId);
324
+ let gpuOutput = '';
325
+
326
+ try {
327
+ gpuOutput = await ComputeNode.run({
328
+ nodeId,
329
+ command: 'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader 2>/dev/null || echo "NO_GPU"',
330
+ });
331
+ } catch (err) {
332
+ gpuOutput = 'NO_GPU';
333
+ }
334
+
335
+ // Parse GPU info
336
+ let gpuLine = 'No GPU detected';
337
+ const gpuLines = gpuOutput.trim().split('\n').filter((l) => l && !l.includes('NO_GPU'));
338
+
339
+ if (gpuLines.length > 0) {
340
+ // Parse: "0, NVIDIA A100-SXM4-80GB, 81920 MiB"
341
+ const gpus = gpuLines.map((line) => {
342
+ const parts = line.split(',').map((s) => s.trim());
343
+ const name = (parts[1] || 'GPU').replace('NVIDIA ', '');
344
+ const memMb = parseInt(parts[2]) || 0;
345
+ const memGb = Math.round(memMb / 1024);
346
+ return { name, memGb };
347
+ });
348
+ const firstName = gpus[0]?.name || 'GPU';
349
+ const firstMem = gpus[0]?.memGb || 0;
350
+ gpuLine = `${gpus.length}x ${firstName}${firstMem ? ` (${firstMem}GB)` : ''}`;
351
+ }
352
+
353
+ // Detect conda
354
+ let condaEnv = '';
355
+ try {
356
+ const condaOut = await ComputeNode.run({
357
+ nodeId,
358
+ command: 'conda info --envs 2>/dev/null | grep "\\*" | head -1 || echo ""',
359
+ });
360
+ const match = condaOut.trim().match(/^(\S+)/);
361
+ if (match && match[1] !== '') {
362
+ condaEnv = match[1];
363
+ }
364
+ } catch {
365
+ // no conda
366
+ }
367
+
368
+ // Build SSH command — sanitize user/host to prevent shell injection
369
+ const safeStr = (s) => String(s || '').replace(/[^a-zA-Z0-9._@\-]/g, '');
370
+ const sshCmd = node.port && node.port !== 22
371
+ ? `ssh -p ${parseInt(node.port) || 22} ${safeStr(node.user)}@${safeStr(node.host)}`
372
+ : `ssh ${safeStr(node.user)}@${safeStr(node.host)}`;
373
+
374
+ // Generate CLAUDE.md template
375
+ const template = [
376
+ '## Remote Server',
377
+ '- gpu: remote',
378
+ `- SSH: \`${sshCmd}\``,
379
+ `- GPU: ${gpuLine}`,
380
+ condaEnv ? `- Conda: \`conda activate ${condaEnv}\`` : '- Conda: `conda activate your_env`',
381
+ `- Code dir: \`${node.workDir || '~/experiments'}\``,
382
+ '- code_sync: rsync',
383
+ ].join('\n');
384
+
385
+ res.json({
386
+ success: true,
387
+ nodeId: node.id,
388
+ nodeName: node.name || node.host,
389
+ gpuLine,
390
+ template,
391
+ });
392
+ } catch (error) {
393
+ console.error('GPU detect error:', error);
394
+ res.status(500).json({ error: 'Internal server error' });
395
+ }
396
+ });
397
+
398
+ export default router;
@@ -185,6 +185,79 @@ router.post('/nodes/:id/run', async (req, res) => {
185
185
  }
186
186
  });
187
187
 
188
+ // GET /api/compute/nodes/:id/monitor - Get GPU and CPU usage
189
+ router.get('/nodes/:id/monitor', async (req, res) => {
190
+ try {
191
+ const monitorCmd = [
192
+ 'echo "===GPU_START==="',
193
+ '(nvidia-smi --query-gpu=index,name,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits 2>/dev/null || echo "NO_GPU")',
194
+ 'echo "===GPU_END==="',
195
+ 'echo "===CPU_START==="',
196
+ 'nproc 2>/dev/null || echo "0"',
197
+ 'echo "---"',
198
+ // 1-second load averages; top is more portable than mpstat
199
+ '(cat /proc/loadavg 2>/dev/null || uptime | sed "s/.*load average[s]*: //" | cut -d, -f1)',
200
+ 'echo "---"',
201
+ // Memory info in MB
202
+ '(free -m 2>/dev/null | grep Mem || echo "Mem: 0 0 0 0 0 0")',
203
+ 'echo "===CPU_END==="',
204
+ ].join(' && ');
205
+
206
+ const output = await ComputeNode.run({
207
+ nodeId: req.params.id,
208
+ command: monitorCmd,
209
+ skipSync: true,
210
+ });
211
+
212
+ const gpuSection = (output.match(/===GPU_START===\n([\s\S]*?)===GPU_END===/) || [])[1]?.trim() || '';
213
+ const cpuSection = (output.match(/===CPU_START===\n([\s\S]*?)===CPU_END===/) || [])[1]?.trim() || '';
214
+
215
+ const gpus = [];
216
+ if (gpuSection && gpuSection !== 'NO_GPU') {
217
+ for (const line of gpuSection.split('\n')) {
218
+ const parts = line.split(',').map(s => s.trim());
219
+ if (parts.length >= 7) {
220
+ gpus.push({
221
+ index: parseInt(parts[0]) || 0,
222
+ name: parts[1] || 'Unknown',
223
+ gpuUtil: parseFloat(parts[2]) || 0,
224
+ memUtil: parseFloat(parts[3]) || 0,
225
+ memUsedMB: parseFloat(parts[4]) || 0,
226
+ memTotalMB: parseFloat(parts[5]) || 0,
227
+ tempC: parseFloat(parts[6]) || 0,
228
+ powerW: parseFloat(parts[7]) || 0,
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ const cpuParts = cpuSection.split('---').map(s => s.trim());
235
+ const cpuCount = parseInt(cpuParts[0]) || 0;
236
+ const loadAvg = parseFloat(cpuParts[1]) || 0;
237
+ const memLine = cpuParts[2] || '';
238
+ const memTokens = memLine.split(/\s+/);
239
+ const memTotalMB = parseInt(memTokens[1]) || 0;
240
+ const memUsedMB = parseInt(memTokens[2]) || 0;
241
+
242
+ res.json({
243
+ success: true,
244
+ gpus,
245
+ cpu: {
246
+ cores: cpuCount,
247
+ loadAvg,
248
+ utilPercent: cpuCount > 0 ? Math.min(100, Math.round((loadAvg / cpuCount) * 100)) : 0,
249
+ memTotalMB,
250
+ memUsedMB,
251
+ memUtilPercent: memTotalMB > 0 ? Math.round((memUsedMB / memTotalMB) * 100) : 0,
252
+ },
253
+ timestamp: Date.now(),
254
+ });
255
+ } catch (error) {
256
+ console.error('Error monitoring compute node:', error);
257
+ res.json({ success: false, error: error.message, gpus: [], cpu: null, timestamp: Date.now() });
258
+ }
259
+ });
260
+
188
261
  // ─── Slurm endpoints ───
189
262
 
190
263
  // GET /api/compute/nodes/:id/slurm/info - Get partition info
@@ -340,6 +413,88 @@ router.post('/run', async (req, res) => {
340
413
  }
341
414
  });
342
415
 
416
+ // GET /api/compute/local/monitor - Monitor the local machine's CPU and GPU
417
+ router.get('/local/monitor', async (_req, res) => {
418
+ const { exec } = await import('child_process');
419
+ const os = await import('os');
420
+ const { promisify } = await import('util');
421
+ const execAsync = promisify(exec);
422
+
423
+ const platform = os.default.platform();
424
+
425
+ const run = async (cmd) => {
426
+ try {
427
+ const { stdout } = await execAsync(cmd, { timeout: 10000 });
428
+ return stdout.trim();
429
+ } catch {
430
+ return '';
431
+ }
432
+ };
433
+
434
+ try {
435
+ // --- GPU ---
436
+ const gpus = [];
437
+ const gpuRaw = await run(
438
+ 'nvidia-smi --query-gpu=index,name,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits'
439
+ );
440
+ if (gpuRaw) {
441
+ for (const line of gpuRaw.split('\n')) {
442
+ const p = line.split(',').map(s => s.trim());
443
+ if (p.length >= 7) {
444
+ gpus.push({
445
+ index: parseInt(p[0]) || 0,
446
+ name: p[1] || 'Unknown',
447
+ gpuUtil: parseFloat(p[2]) || 0,
448
+ memUtil: parseFloat(p[3]) || 0,
449
+ memUsedMB: parseFloat(p[4]) || 0,
450
+ memTotalMB: parseFloat(p[5]) || 0,
451
+ tempC: parseFloat(p[6]) || 0,
452
+ powerW: parseFloat(p[7]) || 0,
453
+ });
454
+ }
455
+ }
456
+ }
457
+
458
+ // --- CPU ---
459
+ const cpuCores = os.default.cpus().length;
460
+ const loadAvg = os.default.loadavg()[0]; // 1-min
461
+ const totalMemMB = Math.round(os.default.totalmem() / (1024 * 1024));
462
+ const freeMemMB = Math.round(os.default.freemem() / (1024 * 1024));
463
+ const usedMemMB = totalMemMB - freeMemMB;
464
+
465
+ let cpuUtilPercent = Math.min(100, Math.round((loadAvg / cpuCores) * 100));
466
+
467
+ // On macOS, try to get a more accurate CPU usage from top
468
+ if (platform === 'darwin') {
469
+ const topOut = await run("top -l 1 -n 0 | head -4");
470
+ const cpuMatch = topOut.match(/CPU usage:\s+(\d+(?:\.\d+)?)% user,\s+(\d+(?:\.\d+)?)% sys/);
471
+ if (cpuMatch) {
472
+ cpuUtilPercent = Math.round(parseFloat(cpuMatch[1]) + parseFloat(cpuMatch[2]));
473
+ }
474
+ }
475
+
476
+ res.json({
477
+ success: true,
478
+ hostname: os.default.hostname(),
479
+ platform,
480
+ gpus,
481
+ cpu: {
482
+ cores: cpuCores,
483
+ model: os.default.cpus()[0]?.model || 'Unknown',
484
+ loadAvg,
485
+ utilPercent: cpuUtilPercent,
486
+ memTotalMB: totalMemMB,
487
+ memUsedMB: usedMemMB,
488
+ memUtilPercent: totalMemMB > 0 ? Math.round((usedMemMB / totalMemMB) * 100) : 0,
489
+ },
490
+ timestamp: Date.now(),
491
+ });
492
+ } catch (error) {
493
+ console.error('Error monitoring local machine:', error);
494
+ res.json({ success: false, error: error.message, gpus: [], cpu: null, timestamp: Date.now() });
495
+ }
496
+ });
497
+
343
498
  router.get('/status', async (req, res) => {
344
499
  try {
345
500
  const node = await getActiveNode();
@@ -1,6 +1,5 @@
1
1
  import express from 'express';
2
- import { exec, spawn } from 'child_process';
3
- import { promisify } from 'util';
2
+ import { spawn } from 'child_process';
4
3
  import path from 'path';
5
4
  import { promises as fs } from 'fs';
6
5
  import { extractProjectDirectory } from '../projects.js';
@@ -8,7 +7,6 @@ import { queryClaudeSDK } from '../claude-sdk.js';
8
7
  import { spawnCursor } from '../cursor-cli.js';
9
8
 
10
9
  const router = express.Router();
11
- const execAsync = promisify(exec);
12
10
 
13
11
  function spawnAsync(command, args, options = {}) {
14
12
  return new Promise((resolve, reject) => {
@@ -98,14 +96,14 @@ async function validateGitRepository(projectPath) {
98
96
 
99
97
  try {
100
98
  // Allow any directory that is inside a work tree (repo root or nested folder).
101
- const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
99
+ const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
102
100
  const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
103
101
  if (!isInsideWorkTree) {
104
102
  throw new Error('Not inside a git work tree');
105
103
  }
106
104
 
107
105
  // Ensure git can resolve the repository root for this directory.
108
- await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
106
+ await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
109
107
  } catch {
110
108
  throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
111
109
  }
@@ -274,7 +272,7 @@ router.get('/status', async (req, res) => {
274
272
  }
275
273
 
276
274
  // Get git status
277
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
275
+ const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
278
276
 
279
277
  const modified = [];
280
278
  const added = [];
@@ -565,7 +563,7 @@ router.get('/branches', async (req, res) => {
565
563
  }
566
564
 
567
565
  // Get all branches
568
- const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
566
+ const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
569
567
 
570
568
  // Parse branches
571
569
  const branches = stdout
@@ -676,8 +674,8 @@ router.get('/commits', async (req, res) => {
676
674
  // Get stats for each commit
677
675
  for (const commit of commits) {
678
676
  try {
679
- const { stdout: stats } = await execAsync(
680
- `git show --stat --format='' ${commit.hash}`,
677
+ const { stdout: stats } = await spawnAsync(
678
+ 'git', ['show', '--stat', '--format=', commit.hash],
681
679
  { cwd: projectPath }
682
680
  );
683
681
  commit.stats = stats.trim().split('\n').pop(); // Get the summary line
@@ -705,8 +703,8 @@ router.get('/commit-diff', async (req, res) => {
705
703
  const projectPath = await getActualProjectPath(project);
706
704
 
707
705
  // Get diff for the commit
708
- const { stdout } = await execAsync(
709
- `git show ${commit}`,
706
+ const { stdout } = await spawnAsync(
707
+ 'git', ['show', commit],
710
708
  { cwd: projectPath }
711
709
  );
712
710