@axhub/genie 0.2.8 → 0.2.10

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 (106) hide show
  1. package/LICENSE +21 -675
  2. package/dist/api-docs.html +2 -2
  3. package/dist/assets/App-CYCCsgwf.js +264 -0
  4. package/dist/assets/ReviewApp-0srHIXwb.js +1 -0
  5. package/dist/assets/{_basePickBy-CqJbRZ9y.js → _basePickBy-DVVb07UV.js} +1 -1
  6. package/dist/assets/{_baseUniq-BS8YH8jO.js → _baseUniq-BtbziL5G.js} +1 -1
  7. package/dist/assets/{arc-BBmKEN-S.js → arc-BsCC8yBD.js} +1 -1
  8. package/dist/assets/{architectureDiagram-2XIMDMQ5-N5lcb82R.js → architectureDiagram-2XIMDMQ5-woFp6eNI.js} +1 -1
  9. package/dist/assets/{blockDiagram-WCTKOSBZ-DTMwHuLn.js → blockDiagram-WCTKOSBZ-ya8VAc2k.js} +1 -1
  10. package/dist/assets/{c4Diagram-IC4MRINW-BTKlkXI9.js → c4Diagram-IC4MRINW-CY1dZmIZ.js} +1 -1
  11. package/dist/assets/channel-BMhScXFe.js +1 -0
  12. package/dist/assets/{chunk-4BX2VUAB-DUdoTxAc.js → chunk-4BX2VUAB-CR1lAd74.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-Bm_92xe4.js → chunk-55IACEB6-CP98WcFC.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-CGW0g62g.js → chunk-FMBD7UC4-D9c7ijAB.js} +1 -1
  15. package/dist/assets/{chunk-JSJVCQXG-DYkTH3w1.js → chunk-JSJVCQXG-DQAGYOn-.js} +1 -1
  16. package/dist/assets/{chunk-KX2RTZJC-C9oTlISU.js → chunk-KX2RTZJC-BbTXiDq7.js} +1 -1
  17. package/dist/assets/{chunk-NQ4KR5QH-CM50ygWP.js → chunk-NQ4KR5QH-BI6AX0dr.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-7dzpYeNJ.js → chunk-QZHKN3VN-DB3V2Ifo.js} +1 -1
  19. package/dist/assets/{chunk-WL4C6EOR-Cm9nQrsr.js → chunk-WL4C6EOR-DhzTthv6.js} +1 -1
  20. package/dist/assets/classDiagram-VBA2DB6C-CMIxlWcT.js +1 -0
  21. package/dist/assets/classDiagram-v2-RAHNMMFH-CMIxlWcT.js +1 -0
  22. package/dist/assets/clone-BPqOt4r3.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-Ccp_p0JZ.js → cose-bilkent-S5V4N54A-BQ09ZE2j.js} +1 -1
  24. package/dist/assets/{dagre-KLK3FWXG-fBwTLUp9.js → dagre-KLK3FWXG-Dc2ueD_R.js} +1 -1
  25. package/dist/assets/{diagram-E7M64L7V-CeNVmFUp.js → diagram-E7M64L7V-DP-LsQoL.js} +1 -1
  26. package/dist/assets/{diagram-IFDJBPK2-CtavyLGa.js → diagram-IFDJBPK2-Cg6r42cB.js} +1 -1
  27. package/dist/assets/{diagram-P4PSJMXO-CpQTjQwc.js → diagram-P4PSJMXO-aHsfoUZE.js} +1 -1
  28. package/dist/assets/{erDiagram-INFDFZHY-B8R5vwhd.js → erDiagram-INFDFZHY-qBXJ4aAz.js} +1 -1
  29. package/dist/assets/{flowDiagram-PKNHOUZH-BvkVVwIQ.js → flowDiagram-PKNHOUZH-D_13emJM.js} +1 -1
  30. package/dist/assets/{ganttDiagram-A5KZAMGK-DOu3hSNa.js → ganttDiagram-A5KZAMGK-BvIcOLwz.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-K3NZZRJ6-C7zT67YE.js → gitGraphDiagram-K3NZZRJ6-ad0vvNcU.js} +1 -1
  32. package/dist/assets/{graph-D11wiwHo.js → graph-CeJCMjan.js} +1 -1
  33. package/dist/assets/{highlighted-body-TPN3WLV5-Babpthg-.js → highlighted-body-TPN3WLV5-B_novwSz.js} +1 -1
  34. package/dist/assets/index-C514cLyb.js +2 -0
  35. package/dist/assets/index-h1DBl_g3.css +1 -0
  36. package/dist/assets/{infoDiagram-LFFYTUFH-BmA7IpQG.js → infoDiagram-LFFYTUFH-lOxAqb3m.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-PHBUUO56-BEquZd3E.js → ishikawaDiagram-PHBUUO56-DIr-51gj.js} +1 -1
  38. package/dist/assets/{journeyDiagram-4ABVD52K-BfemGz7f.js → journeyDiagram-4ABVD52K-CYcIW0ZU.js} +1 -1
  39. package/dist/assets/{kanban-definition-K7BYSVSG-CWja3mln.js → kanban-definition-K7BYSVSG-C1ZK616a.js} +1 -1
  40. package/dist/assets/{layout-BLUNf-PJ.js → layout-CI2RM-v6.js} +1 -1
  41. package/dist/assets/{linear-DukIV_Xv.js → linear-DE7bISck.js} +1 -1
  42. package/dist/assets/{mermaid-O7DHMXV3-SgtM28qI.js → mermaid-O7DHMXV3-XxAJo8EK.js} +6 -6
  43. package/dist/assets/{mindmap-definition-YRQLILUH-4UjqXITU.js → mindmap-definition-YRQLILUH-Dz6EFjmn.js} +1 -1
  44. package/dist/assets/{pieDiagram-SKSYHLDU-8AxqJd0M.js → pieDiagram-SKSYHLDU-DPpEzUed.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-337W2JSQ-D60m8V8r.js → quadrantDiagram-337W2JSQ-xdoXNet7.js} +1 -1
  46. package/dist/assets/{requirementDiagram-Z7DCOOCP-zqh9jBVf.js → requirementDiagram-Z7DCOOCP-DUq8H3CL.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-WA2Y5GQK-CDZILTLI.js → sankeyDiagram-WA2Y5GQK-CmqEUxRu.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-2WXFIKYE-7BReFd0L.js → sequenceDiagram-2WXFIKYE-DhtXRNiH.js} +1 -1
  49. package/dist/assets/{stateDiagram-RAJIS63D-HPTVdIG4.js → stateDiagram-RAJIS63D-Dj0HOlbN.js} +1 -1
  50. package/dist/assets/stateDiagram-v2-FVOUBMTO-C9utf5gv.js +1 -0
  51. package/dist/assets/{timeline-definition-YZTLITO2-CTVllFgr.js → timeline-definition-YZTLITO2-DUuJzZB5.js} +1 -1
  52. package/dist/assets/{treemap-KZPCXAKY-BtyxboJZ.js → treemap-KZPCXAKY-DpYBQ0qr.js} +1 -1
  53. package/dist/assets/vendor-codemirror-CMHSJ_9p.js +9 -0
  54. package/dist/assets/{vendor-react-Cpt6D04s.js → vendor-react-xmA_f8ig.js} +1 -1
  55. package/dist/assets/{vennDiagram-LZ73GAT5-D96ZI6Mg.js → vennDiagram-LZ73GAT5-DpePUyOd.js} +1 -1
  56. package/dist/assets/{xychartDiagram-JWTSCODW-eRk-39YO.js → xychartDiagram-JWTSCODW-Cfp1I4_U.js} +1 -1
  57. package/dist/index.html +5 -5
  58. package/package.json +8 -7
  59. package/server/acp-runtime/client.js +129 -16
  60. package/server/acp-runtime/index.js +54 -0
  61. package/server/acp-runtime/registry.js +2 -2
  62. package/server/acp-runtime/session-store.js +79 -5
  63. package/server/cli.js +55 -10
  64. package/server/database/db.js +20 -0
  65. package/server/external-agent/service.js +24 -6
  66. package/server/external-agent/ws.js +540 -27
  67. package/server/index.js +112 -151
  68. package/server/lan-access/core.js +79 -0
  69. package/server/lan-access/state.js +102 -0
  70. package/server/middleware/auth.js +57 -14
  71. package/server/projects.js +930 -667
  72. package/server/routes/auth.js +24 -4
  73. package/server/routes/cli-auth.js +21 -25
  74. package/server/routes/codex.js +84 -298
  75. package/server/routes/commands.js +322 -407
  76. package/server/routes/lan-access.js +231 -0
  77. package/server/routes/projects.js +154 -158
  78. package/server/routes/session-core.js +160 -91
  79. package/server/routes/settings.js +113 -99
  80. package/server/session-core/eventStore.js +60 -20
  81. package/server/session-core/providerAdapters.js +75 -38
  82. package/server/session-core/runtimeState.js +8 -0
  83. package/server/session-core/sessionListMerge.js +47 -0
  84. package/shared/conversationEvents.js +174 -15
  85. package/shared/modelConstants.js +79 -99
  86. package/dist/assets/App-CTKZtqB1.js +0 -460
  87. package/dist/assets/ReviewApp-DM6BNAzR.js +0 -1
  88. package/dist/assets/channel-1oJBvF-0.js +0 -1
  89. package/dist/assets/classDiagram-VBA2DB6C-d5TeKFM4.js +0 -1
  90. package/dist/assets/classDiagram-v2-RAHNMMFH-d5TeKFM4.js +0 -1
  91. package/dist/assets/clone-CinxIlEu.js +0 -1
  92. package/dist/assets/index-DFxzgWoO.js +0 -2
  93. package/dist/assets/index-YCFGDVKw.css +0 -1
  94. package/dist/assets/stateDiagram-v2-FVOUBMTO-DTUf5_gC.js +0 -1
  95. package/dist/assets/vendor-codemirror-Dz7_EqNA.js +0 -39
  96. package/server/_legacy-providers/README.md +0 -30
  97. package/server/_legacy-providers/claude-sdk.js +0 -956
  98. package/server/_legacy-providers/gemini-cli.js +0 -368
  99. package/server/_legacy-providers/openai-codex.js +0 -705
  100. package/server/_legacy-providers/opencode-cli.js +0 -674
  101. package/server/routes/git.js +0 -1110
  102. package/server/routes/mcp-utils.js +0 -48
  103. package/server/routes/mcp.js +0 -536
  104. package/server/routes/taskmaster.js +0 -1963
  105. package/server/utils/mcp-detector.js +0 -198
  106. package/server/utils/taskmaster-websocket.js +0 -129
@@ -1,375 +1,357 @@
1
1
  import express from 'express';
2
2
  import { promises as fs } from 'fs';
3
+ import os from 'os';
3
4
  import path from 'path';
4
5
  import { fileURLToPath } from 'url';
5
- import os from 'os';
6
6
  import matter from 'gray-matter';
7
- import { CLAUDE_MODELS, CODEX_MODELS, GEMINI_MODELS, OPENCODE_MODELS } from '../../shared/modelConstants.js';
7
+ import {
8
+ CLAUDE_MODELS,
9
+ CODEX_MODELS,
10
+ GEMINI_MODELS,
11
+ OPENCODE_MODELS
12
+ } from '../../shared/modelConstants.js';
8
13
  import { discoverAllProviders } from '../session-core/providerDiscovery.js';
9
14
 
15
+ const router = express.Router();
10
16
  const __filename = fileURLToPath(import.meta.url);
11
17
  const __dirname = path.dirname(__filename);
18
+ const PACKAGE_JSON_PATH = path.join(__dirname, '..', '..', 'package.json');
19
+
20
+ const BUILTIN_COMMANDS = [
21
+ { name: '/help', description: 'Show the built-in and custom slash commands available here.' },
22
+ { name: '/clear', description: 'Clear the current in-memory conversation preview.' },
23
+ { name: '/model', description: 'Show the active model and available provider model lists.' },
24
+ { name: '/memory', description: 'Open the project-level CLAUDE.md memory file.' },
25
+ { name: '/config', description: 'Open the local settings modal.' },
26
+ { name: '/status', description: 'Show runtime metadata for the local Genie server.' },
27
+ { name: '/rewind', description: 'Drop the last N user/assistant turns from the current chat.' }
28
+ ];
12
29
 
13
- const router = express.Router();
30
+ const FALLBACK_PROVIDER_MODELS = {
31
+ claude: CLAUDE_MODELS.OPTIONS.map((option) => option.value),
32
+ codex: CODEX_MODELS.OPTIONS.map((option) => option.value),
33
+ gemini: GEMINI_MODELS.OPTIONS.map((option) => option.value),
34
+ opencode: OPENCODE_MODELS.OPTIONS.map((option) => option.value)
35
+ };
14
36
 
15
- /**
16
- * Recursively scan directory for command files (.md)
17
- * @param {string} dir - Directory to scan
18
- * @param {string} baseDir - Base directory for relative paths
19
- * @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
20
- * @returns {Promise<Array>} Array of command objects
21
- */
22
- async function scanCommandsDirectory(dir, baseDir, namespace) {
23
- const commands = [];
37
+ function trimText(value) {
38
+ return typeof value === 'string' ? value.trim() : '';
39
+ }
24
40
 
25
- try {
26
- // Check if directory exists
27
- await fs.access(dir);
41
+ function commandRootsFor(projectPath) {
42
+ const roots = [
43
+ {
44
+ namespace: 'user',
45
+ rootPath: path.join(os.homedir(), '.claude', 'commands')
46
+ }
47
+ ];
28
48
 
29
- const entries = await fs.readdir(dir, { withFileTypes: true });
49
+ if (trimText(projectPath)) {
50
+ roots.unshift({
51
+ namespace: 'project',
52
+ rootPath: path.join(projectPath, '.claude', 'commands')
53
+ });
54
+ }
30
55
 
31
- for (const entry of entries) {
32
- const fullPath = path.join(dir, entry.name);
56
+ return roots;
57
+ }
33
58
 
34
- if (entry.isDirectory()) {
35
- // Recursively scan subdirectories
36
- const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
37
- commands.push(...subCommands);
38
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
39
- // Parse markdown file for metadata
40
- try {
41
- const content = await fs.readFile(fullPath, 'utf8');
42
- const { data: frontmatter, content: commandContent } = matter(content);
43
-
44
- // Calculate relative path from baseDir for command name
45
- const relativePath = path.relative(baseDir, fullPath);
46
- // Remove .md extension and convert to command name
47
- const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
48
-
49
- // Extract description from frontmatter or first line of content
50
- let description = frontmatter.description || '';
51
- if (!description) {
52
- const firstLine = commandContent.trim().split('\n')[0];
53
- description = firstLine.replace(/^#+\s*/, '').trim();
54
- }
59
+ function normalizeCommandName(relativeFilePath) {
60
+ const normalized = relativeFilePath.replace(/\\/g, '/').replace(/\.md$/i, '');
61
+ return normalized.startsWith('/') ? normalized : `/${normalized}`;
62
+ }
55
63
 
56
- commands.push({
57
- name: commandName,
58
- path: fullPath,
59
- relativePath,
60
- description,
61
- namespace,
62
- metadata: frontmatter
63
- });
64
- } catch (err) {
65
- console.error(`Error parsing command file ${fullPath}:`, err.message);
66
- }
64
+ function inferDescription(frontmatter, markdownBody) {
65
+ const explicit = trimText(frontmatter?.description);
66
+ if (explicit) {
67
+ return explicit;
68
+ }
69
+
70
+ const firstMeaningfulLine = markdownBody
71
+ .split('\n')
72
+ .map((line) => line.trim())
73
+ .find((line) => line && !line.startsWith('---'));
74
+
75
+ if (!firstMeaningfulLine) {
76
+ return 'Custom slash command';
77
+ }
78
+
79
+ return firstMeaningfulLine.replace(/^#+\s*/, '').trim() || 'Custom slash command';
80
+ }
81
+
82
+ async function walkCommandTree(rootPath, namespace) {
83
+ const collected = [];
84
+
85
+ async function visit(directory) {
86
+ let entries;
87
+ try {
88
+ entries = await fs.readdir(directory, { withFileTypes: true });
89
+ } catch (error) {
90
+ if (error.code === 'ENOENT' || error.code === 'EACCES') {
91
+ return;
67
92
  }
93
+ throw error;
68
94
  }
69
- } catch (err) {
70
- // Directory doesn't exist or can't be accessed - this is okay
71
- if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
72
- console.error(`Error scanning directory ${dir}:`, err.message);
95
+
96
+ for (const entry of entries) {
97
+ const fullPath = path.join(directory, entry.name);
98
+ if (entry.isDirectory()) {
99
+ await visit(fullPath);
100
+ continue;
101
+ }
102
+
103
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
104
+ continue;
105
+ }
106
+
107
+ try {
108
+ const rawDocument = await fs.readFile(fullPath, 'utf8');
109
+ const parsedDocument = matter(rawDocument);
110
+ const relativePath = path.relative(rootPath, fullPath);
111
+ collected.push({
112
+ name: normalizeCommandName(relativePath),
113
+ path: fullPath,
114
+ relativePath,
115
+ description: inferDescription(parsedDocument.data, parsedDocument.content),
116
+ namespace,
117
+ metadata: parsedDocument.data || {}
118
+ });
119
+ } catch (error) {
120
+ console.error(`Error reading slash command ${fullPath}:`, error.message);
121
+ }
73
122
  }
74
123
  }
75
124
 
76
- return commands;
125
+ await visit(rootPath);
126
+ return collected.sort((left, right) => left.name.localeCompare(right.name));
77
127
  }
78
128
 
79
- /**
80
- * Built-in commands that are always available
81
- */
82
- const builtInCommands = [
83
- {
84
- name: '/help',
85
- description: 'Show help documentation for Claude Code',
129
+ function toBuiltinRecord(command) {
130
+ return {
131
+ ...command,
86
132
  namespace: 'builtin',
87
133
  metadata: { type: 'builtin' }
88
- },
89
- {
90
- name: '/clear',
91
- description: 'Clear the conversation history',
92
- namespace: 'builtin',
93
- metadata: { type: 'builtin' }
94
- },
95
- {
96
- name: '/model',
97
- description: 'Switch or view the current AI model',
98
- namespace: 'builtin',
99
- metadata: { type: 'builtin' }
100
- },
101
- {
102
- name: '/cost',
103
- description: 'Display token usage and cost information',
104
- namespace: 'builtin',
105
- metadata: { type: 'builtin' }
106
- },
107
- {
108
- name: '/memory',
109
- description: 'Open CLAUDE.md memory file for editing',
110
- namespace: 'builtin',
111
- metadata: { type: 'builtin' }
112
- },
113
- {
114
- name: '/config',
115
- description: 'Open settings and configuration',
116
- namespace: 'builtin',
117
- metadata: { type: 'builtin' }
118
- },
119
- {
120
- name: '/status',
121
- description: 'Show system status and version information',
122
- namespace: 'builtin',
123
- metadata: { type: 'builtin' }
124
- },
125
- {
126
- name: '/rewind',
127
- description: 'Rewind the conversation to a previous state',
128
- namespace: 'builtin',
129
- metadata: { type: 'builtin' }
130
- }
131
- ];
134
+ };
135
+ }
132
136
 
133
- /**
134
- * Built-in command handlers
135
- * Each handler returns { type: 'builtin', action: string, data: any }
136
- */
137
- const builtInHandlers = {
138
- '/help': async (args, context) => {
139
- const helpText = `# Claude Code Commands
137
+ function isPathInside(basePath, targetPath) {
138
+ const relative = path.relative(basePath, targetPath);
139
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
140
+ }
140
141
 
141
- ## Built-in Commands
142
+ function resolveAllowedCommandPath(commandPath, projectPath) {
143
+ const resolvedPath = path.resolve(commandPath);
144
+ const roots = commandRootsFor(projectPath);
145
+ const matchedRoot = roots.find((entry) => isPathInside(path.resolve(entry.rootPath), resolvedPath));
142
146
 
143
- ${builtInCommands.map(cmd => `### ${cmd.name}
144
- ${cmd.description}
145
- `).join('\n')}
147
+ if (!matchedRoot) {
148
+ const error = new Error('Command must live in a .claude/commands directory');
149
+ error.statusCode = 403;
150
+ throw error;
151
+ }
146
152
 
147
- ## Custom Commands
153
+ return resolvedPath;
154
+ }
148
155
 
149
- Custom commands can be created in:
150
- - Project: \`.claude/commands/\` (project-specific)
151
- - User: \`~/.claude/commands/\` (available in all projects)
156
+ async function loadCommandDocument(commandPath, projectPath) {
157
+ const resolvedPath = resolveAllowedCommandPath(commandPath, projectPath);
158
+ const raw = await fs.readFile(resolvedPath, 'utf8');
159
+ const parsed = matter(raw);
152
160
 
153
- ### Command Syntax
161
+ return {
162
+ path: resolvedPath,
163
+ metadata: parsed.data || {},
164
+ content: parsed.content
165
+ };
166
+ }
154
167
 
155
- - **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
156
- - **File Includes**: Use \`@filename\` to include file contents
157
- - **Bash Commands**: Use \`!command\` to execute bash commands
168
+ function interpolateCommandBody(content, args) {
169
+ const joinedArgs = args.join(' ');
170
+ let nextContent = String(content || '').replace(/\$ARGUMENTS/g, joinedArgs);
158
171
 
159
- ### Examples
172
+ args.forEach((arg, index) => {
173
+ nextContent = nextContent.replace(new RegExp(`\\$${index + 1}\\b`, 'g'), arg);
174
+ });
160
175
 
161
- \`\`\`markdown
162
- /mycommand arg1 arg2
163
- \`\`\`
164
- `;
176
+ return nextContent;
177
+ }
165
178
 
179
+ async function getPackageMetadata() {
180
+ try {
181
+ const raw = await fs.readFile(PACKAGE_JSON_PATH, 'utf8');
182
+ const parsed = JSON.parse(raw);
166
183
  return {
167
- type: 'builtin',
168
- action: 'help',
169
- data: {
170
- content: helpText,
171
- format: 'markdown'
172
- }
184
+ version: parsed.version || 'unknown',
185
+ packageName: parsed.name || '@axhub/genie'
173
186
  };
174
- },
175
-
176
- '/clear': async (args, context) => {
187
+ } catch {
177
188
  return {
178
- type: 'builtin',
179
- action: 'clear',
180
- data: {
181
- message: 'Conversation history cleared'
182
- }
189
+ version: 'unknown',
190
+ packageName: '@axhub/genie'
183
191
  };
184
- },
185
-
186
- '/model': async (args, context) => {
187
- const providerDiscovery = await discoverAllProviders({ projectPath: context?.projectPath });
188
- const availableModels = providerDiscovery.reduce((acc, item) => {
189
- acc[item.provider] = Array.isArray(item.models) && item.models.length > 0
190
- ? item.models.map((entry) => entry.id)
191
- : (item.provider === 'claude'
192
- ? CLAUDE_MODELS.OPTIONS.map(o => o.value)
193
- : item.provider === 'codex'
194
- ? CODEX_MODELS.OPTIONS.map(o => o.value)
195
- : item.provider === 'gemini'
196
- ? GEMINI_MODELS.OPTIONS.map(o => o.value)
197
- : OPENCODE_MODELS.OPTIONS.map(o => o.value));
198
- return acc;
199
- }, {});
200
-
201
- const currentProvider = context?.provider || 'claude';
202
- const discoveredCurrentModel = providerDiscovery.find((item) => item.provider === currentProvider)?.currentModel || null;
203
- const currentModel = context?.model || discoveredCurrentModel || null;
204
- const opencodeDiscovery = providerDiscovery.find((item) => item.provider === 'opencode') || null;
192
+ }
193
+ }
205
194
 
206
- return {
207
- type: 'builtin',
208
- action: 'model',
209
- data: {
210
- current: {
211
- provider: currentProvider,
212
- model: currentModel
213
- },
214
- available: availableModels,
215
- opencodeDiscovery,
216
- message: args.length > 0
217
- ? `Switching to model: ${args[0]}`
218
- : (currentModel ? `Current model: ${currentModel}` : 'Current model is managed by the active provider configuration.')
219
- }
220
- };
221
- },
195
+ async function buildModelPayload(context) {
196
+ const projectPath = trimText(context?.projectPath);
197
+ const discovered = await discoverAllProviders({ projectPath: projectPath || null });
198
+ const available = {};
222
199
 
223
- '/status': async (args, context) => {
224
- // Read version from package.json
225
- const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
226
- let version = 'unknown';
227
- let packageName = '@axhub/genie';
200
+ for (const provider of discovered) {
201
+ available[provider.provider] = Array.isArray(provider.models) && provider.models.length > 0
202
+ ? provider.models.map((entry) => entry.id)
203
+ : (FALLBACK_PROVIDER_MODELS[provider.provider] || []);
204
+ }
228
205
 
229
- try {
230
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
231
- version = packageJson.version;
232
- packageName = packageJson.name;
233
- } catch (err) {
234
- console.error('Error reading package.json:', err);
235
- }
206
+ const currentProvider = trimText(context?.provider) || 'claude';
207
+ const discoveredProvider = discovered.find((entry) => entry.provider === currentProvider);
236
208
 
237
- const uptime = process.uptime();
238
- const uptimeMinutes = Math.floor(uptime / 60);
239
- const uptimeHours = Math.floor(uptimeMinutes / 60);
240
- const uptimeFormatted = uptimeHours > 0
241
- ? `${uptimeHours}h ${uptimeMinutes % 60}m`
242
- : `${uptimeMinutes}m`;
209
+ return {
210
+ current: {
211
+ provider: currentProvider,
212
+ model: trimText(context?.model) || trimText(discoveredProvider?.currentModel) || null
213
+ },
214
+ available
215
+ };
216
+ }
243
217
 
244
- return {
245
- type: 'builtin',
246
- action: 'status',
247
- data: {
248
- version,
249
- packageName,
250
- uptime: uptimeFormatted,
251
- uptimeSeconds: Math.floor(uptime),
252
- model: context?.model || null,
253
- provider: context?.provider || 'claude',
254
- nodeVersion: process.version,
255
- platform: process.platform
218
+ async function runBuiltinCommand(commandName, args, context) {
219
+ switch (commandName) {
220
+ case '/help':
221
+ return {
222
+ type: 'builtin',
223
+ action: 'help',
224
+ data: {
225
+ content: [
226
+ '# Slash Commands',
227
+ '',
228
+ '## Built-in',
229
+ ...BUILTIN_COMMANDS.map((command) => `- \`${command.name}\` — ${command.description}`),
230
+ '',
231
+ '## Custom command folders',
232
+ '- Project scope: `.claude/commands/`',
233
+ '- User scope: `~/.claude/commands/`',
234
+ '',
235
+ 'Use `$ARGUMENTS`, `$1`, `$2`, and friends inside markdown command files.'
236
+ ].join('\n'),
237
+ format: 'markdown'
238
+ }
239
+ };
240
+ case '/clear':
241
+ return {
242
+ type: 'builtin',
243
+ action: 'clear',
244
+ data: { message: 'Cleared the current chat preview.' }
245
+ };
246
+ case '/model':
247
+ return {
248
+ type: 'builtin',
249
+ action: 'model',
250
+ data: await buildModelPayload(context)
251
+ };
252
+ case '/memory': {
253
+ const projectPath = trimText(context?.projectPath);
254
+ if (!projectPath) {
255
+ return {
256
+ type: 'builtin',
257
+ action: 'memory',
258
+ data: {
259
+ error: 'No project selected',
260
+ message: 'Select a project before opening CLAUDE.md.'
261
+ }
262
+ };
256
263
  }
257
- };
258
- },
259
264
 
260
- '/memory': async (args, context) => {
261
- const projectPath = context?.projectPath;
265
+ const memoryPath = path.join(projectPath, 'CLAUDE.md');
266
+ let exists = true;
267
+ try {
268
+ await fs.access(memoryPath);
269
+ } catch {
270
+ exists = false;
271
+ }
262
272
 
263
- if (!projectPath) {
264
273
  return {
265
274
  type: 'builtin',
266
275
  action: 'memory',
267
276
  data: {
268
- error: 'No project selected',
269
- message: 'Please select a project to access its CLAUDE.md file'
277
+ path: memoryPath,
278
+ exists,
279
+ message: exists
280
+ ? `Opening project memory at ${memoryPath}`
281
+ : `No CLAUDE.md found at ${memoryPath}.`
270
282
  }
271
283
  };
272
284
  }
285
+ case '/config':
286
+ return {
287
+ type: 'builtin',
288
+ action: 'config',
289
+ data: { message: 'Opening settings.' }
290
+ };
291
+ case '/status': {
292
+ const metadata = await getPackageMetadata();
293
+ const uptimeSeconds = Math.floor(process.uptime());
294
+ const uptimeMinutes = Math.floor(uptimeSeconds / 60);
295
+ const uptime = uptimeMinutes >= 60
296
+ ? `${Math.floor(uptimeMinutes / 60)}h ${uptimeMinutes % 60}m`
297
+ : `${uptimeMinutes}m`;
273
298
 
274
- const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
275
-
276
- // Check if CLAUDE.md exists
277
- let exists = false;
278
- try {
279
- await fs.access(claudeMdPath);
280
- exists = true;
281
- } catch (err) {
282
- // File doesn't exist
299
+ return {
300
+ type: 'builtin',
301
+ action: 'status',
302
+ data: {
303
+ ...metadata,
304
+ uptime,
305
+ uptimeSeconds,
306
+ provider: trimText(context?.provider) || 'claude',
307
+ model: trimText(context?.model) || null,
308
+ nodeVersion: process.version,
309
+ platform: process.platform
310
+ }
311
+ };
283
312
  }
284
-
285
- return {
286
- type: 'builtin',
287
- action: 'memory',
288
- data: {
289
- path: claudeMdPath,
290
- exists,
291
- message: exists
292
- ? `Opening CLAUDE.md at ${claudeMdPath}`
293
- : `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`
313
+ case '/rewind': {
314
+ const steps = Number.parseInt(String(args[0] || '1'), 10);
315
+ if (!Number.isFinite(steps) || steps < 1) {
316
+ return {
317
+ type: 'builtin',
318
+ action: 'rewind',
319
+ data: {
320
+ error: 'Invalid step count',
321
+ message: 'Usage: /rewind [positive-number]'
322
+ }
323
+ };
294
324
  }
295
- };
296
- },
297
325
 
298
- '/config': async (args, context) => {
299
- return {
300
- type: 'builtin',
301
- action: 'config',
302
- data: {
303
- message: 'Opening settings...'
304
- }
305
- };
306
- },
307
-
308
- '/rewind': async (args, context) => {
309
- const steps = args[0] ? parseInt(args[0]) : 1;
310
-
311
- if (isNaN(steps) || steps < 1) {
312
326
  return {
313
327
  type: 'builtin',
314
328
  action: 'rewind',
315
329
  data: {
316
- error: 'Invalid steps parameter',
317
- message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
330
+ steps,
331
+ message: `Removing the latest ${steps} turn${steps === 1 ? '' : 's'}.`
318
332
  }
319
333
  };
320
334
  }
321
-
322
- return {
323
- type: 'builtin',
324
- action: 'rewind',
325
- data: {
326
- steps,
327
- message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
328
- }
329
- };
335
+ default:
336
+ return null;
330
337
  }
331
- };
338
+ }
332
339
 
333
- /**
334
- * POST /api/commands/list
335
- * List all available commands from project and user directories
336
- */
337
340
  router.post('/list', async (req, res) => {
338
341
  try {
339
- const { projectPath } = req.body;
340
- const allCommands = [...builtInCommands];
341
-
342
- // Scan project-level commands (.claude/commands/)
343
- if (projectPath) {
344
- const projectCommandsDir = path.join(projectPath, '.claude', 'commands');
345
- const projectCommands = await scanCommandsDirectory(
346
- projectCommandsDir,
347
- projectCommandsDir,
348
- 'project'
349
- );
350
- allCommands.push(...projectCommands);
351
- }
342
+ const projectPath = trimText(req.body?.projectPath);
343
+ const builtin = BUILTIN_COMMANDS.map(toBuiltinRecord);
344
+ const discoveredRoots = commandRootsFor(projectPath);
345
+ const custom = [];
352
346
 
353
- // Scan user-level commands (~/.claude/commands/)
354
- const homeDir = os.homedir();
355
- const userCommandsDir = path.join(homeDir, '.claude', 'commands');
356
- const userCommands = await scanCommandsDirectory(
357
- userCommandsDir,
358
- userCommandsDir,
359
- 'user'
360
- );
361
- allCommands.push(...userCommands);
362
-
363
- // Separate built-in and custom commands
364
- const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');
365
-
366
- // Sort commands alphabetically by name
367
- customCommands.sort((a, b) => a.name.localeCompare(b.name));
347
+ for (const root of discoveredRoots) {
348
+ custom.push(...await walkCommandTree(root.rootPath, root.namespace));
349
+ }
368
350
 
369
351
  res.json({
370
- builtIn: builtInCommands,
371
- custom: customCommands,
372
- count: allCommands.length
352
+ builtIn: builtin,
353
+ custom,
354
+ count: builtin.length + custom.length
373
355
  });
374
356
  } catch (error) {
375
357
  console.error('Error listing commands:', error);
@@ -380,151 +362,84 @@ router.post('/list', async (req, res) => {
380
362
  }
381
363
  });
382
364
 
383
- /**
384
- * POST /api/commands/load
385
- * Load a specific command file and return its content and metadata
386
- */
387
365
  router.post('/load', async (req, res) => {
388
- try {
389
- const { commandPath } = req.body;
390
-
391
- if (!commandPath) {
392
- return res.status(400).json({
393
- error: 'Command path is required'
394
- });
395
- }
366
+ const commandPath = trimText(req.body?.commandPath);
367
+ const projectPath = trimText(req.body?.projectPath);
396
368
 
397
- // Security: Prevent path traversal
398
- const resolvedPath = path.resolve(commandPath);
399
- if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
400
- !resolvedPath.includes('.claude/commands')) {
401
- return res.status(403).json({
402
- error: 'Access denied',
403
- message: 'Command must be in .claude/commands directory'
404
- });
405
- }
406
-
407
- // Read and parse the command file
408
- const content = await fs.readFile(commandPath, 'utf8');
409
- const { data: metadata, content: commandContent } = matter(content);
410
-
411
- res.json({
412
- path: commandPath,
413
- metadata,
414
- content: commandContent
369
+ if (!commandPath) {
370
+ return res.status(400).json({
371
+ error: 'Command path is required'
415
372
  });
373
+ }
374
+
375
+ try {
376
+ const document = await loadCommandDocument(commandPath, projectPath);
377
+ res.json(document);
416
378
  } catch (error) {
417
379
  if (error.code === 'ENOENT') {
418
380
  return res.status(404).json({
419
381
  error: 'Command not found',
420
- message: `Command file not found: ${req.body.commandPath}`
382
+ message: `Command file not found: ${commandPath}`
421
383
  });
422
384
  }
423
385
 
424
- console.error('Error loading command:', error);
425
- res.status(500).json({
426
- error: 'Failed to load command',
386
+ res.status(error.statusCode || 500).json({
387
+ error: error.statusCode ? 'Access denied' : 'Failed to load command',
427
388
  message: error.message
428
389
  });
429
390
  }
430
391
  });
431
392
 
432
- /**
433
- * POST /api/commands/execute
434
- * Execute a command with argument replacement
435
- * This endpoint prepares the command content but doesn't execute bash commands yet
436
- * (that will be handled in the command parser utility)
437
- */
438
393
  router.post('/execute', async (req, res) => {
439
- try {
440
- const { commandName, commandPath, args = [], context = {} } = req.body;
394
+ const commandName = trimText(req.body?.commandName);
395
+ const commandPath = trimText(req.body?.commandPath);
396
+ const args = Array.isArray(req.body?.args) ? req.body.args.map((value) => String(value)) : [];
397
+ const context = req.body?.context && typeof req.body.context === 'object' ? req.body.context : {};
398
+
399
+ if (!commandName) {
400
+ return res.status(400).json({
401
+ error: 'Command name is required'
402
+ });
403
+ }
441
404
 
442
- if (!commandName) {
443
- return res.status(400).json({
444
- error: 'Command name is required'
405
+ try {
406
+ const builtinResult = await runBuiltinCommand(commandName, args, context);
407
+ if (builtinResult) {
408
+ return res.json({
409
+ command: commandName,
410
+ ...builtinResult
445
411
  });
446
412
  }
447
413
 
448
- // Handle built-in commands
449
- const handler = builtInHandlers[commandName];
450
- if (handler) {
451
- try {
452
- const result = await handler(args, context);
453
- return res.json({
454
- ...result,
455
- command: commandName
456
- });
457
- } catch (error) {
458
- console.error(`Error executing built-in command ${commandName}:`, error);
459
- return res.status(500).json({
460
- error: 'Command execution failed',
461
- message: error.message,
462
- command: commandName
463
- });
464
- }
465
- }
466
-
467
- // Handle custom commands
468
414
  if (!commandPath) {
469
415
  return res.status(400).json({
470
416
  error: 'Command path is required for custom commands'
471
417
  });
472
418
  }
473
419
 
474
- // Load command content
475
- // Security: validate commandPath is within allowed directories
476
- {
477
- const resolvedPath = path.resolve(commandPath);
478
- const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));
479
- const projectBase = context?.projectPath
480
- ? path.resolve(path.join(context.projectPath, '.claude', 'commands'))
481
- : null;
482
- const isUnder = (base) => {
483
- const rel = path.relative(base, resolvedPath);
484
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
485
- };
486
- if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {
487
- return res.status(403).json({
488
- error: 'Access denied',
489
- message: 'Command must be in .claude/commands directory'
490
- });
491
- }
492
- }
493
- const content = await fs.readFile(commandPath, 'utf8');
494
- const { data: metadata, content: commandContent } = matter(content);
495
- // Basic argument replacement (will be enhanced in command parser utility)
496
- let processedContent = commandContent;
497
-
498
- // Replace $ARGUMENTS with all arguments joined
499
- const argsString = args.join(' ');
500
- processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
501
-
502
- // Replace $1, $2, etc. with positional arguments
503
- args.forEach((arg, index) => {
504
- const placeholder = `$${index + 1}`;
505
- processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
506
- });
420
+ const document = await loadCommandDocument(commandPath, trimText(context.projectPath));
421
+ const preparedContent = interpolateCommandBody(document.content, args);
507
422
 
508
423
  res.json({
509
424
  type: 'custom',
510
425
  command: commandName,
511
- content: processedContent,
512
- metadata,
513
- hasFileIncludes: processedContent.includes('@'),
514
- hasBashCommands: processedContent.includes('!')
426
+ metadata: document.metadata,
427
+ content: preparedContent,
428
+ hasFileIncludes: /(^|\s)@[\w./-]+/m.test(preparedContent),
429
+ hasBashCommands: /(^|\n)\s*!/.test(preparedContent)
515
430
  });
516
431
  } catch (error) {
517
432
  if (error.code === 'ENOENT') {
518
433
  return res.status(404).json({
519
434
  error: 'Command not found',
520
- message: `Command file not found: ${req.body.commandPath}`
435
+ message: `Command file not found: ${commandPath}`
521
436
  });
522
437
  }
523
438
 
524
- console.error('Error executing command:', error);
525
- res.status(500).json({
526
- error: 'Failed to execute command',
527
- message: error.message
439
+ res.status(error.statusCode || 500).json({
440
+ error: error.statusCode ? 'Access denied' : 'Command execution failed',
441
+ message: error.message,
442
+ command: commandName
528
443
  });
529
444
  }
530
445
  });