@axhub/genie 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-docs.html +351 -909
- package/dist/assets/index-CVjMty4a.js +902 -0
- package/dist/assets/index-eo5scY_Z.css +32 -0
- package/dist/index.html +5 -5
- package/dist/manifest.json +2 -2
- package/package.json +8 -2
- package/server/channels/core/ChannelManager.js +399 -0
- package/server/channels/core/PluginManager.js +59 -0
- package/server/channels/index.js +3 -0
- package/server/channels/plugins/BasePlugin.js +46 -0
- package/server/channels/plugins/dingtalk/DingTalkAdapter.js +156 -0
- package/server/channels/plugins/dingtalk/DingTalkPlugin.js +592 -0
- package/server/channels/plugins/dingtalk/index.js +2 -0
- package/server/channels/plugins/lark/LarkAdapter.js +100 -0
- package/server/channels/plugins/lark/LarkCards.js +43 -0
- package/server/channels/plugins/lark/LarkPlugin.js +260 -0
- package/server/channels/runtime/AgentRuntimeAdapter.js +179 -0
- package/server/channels/runtime/DingTalkStreamWriter.js +105 -0
- package/server/channels/runtime/LarkStreamWriter.js +99 -0
- package/server/channels/store/ChannelStore.js +236 -0
- package/server/database/db.js +109 -1
- package/server/database/init.sql +47 -1
- package/server/gemini-cli.js +280 -0
- package/server/index.js +230 -11
- package/server/openai-codex.js +104 -8
- package/server/opencode-cli.js +673 -0
- package/server/projects.js +645 -5
- package/server/routes/agent.js +40 -12
- package/server/routes/channels.js +221 -0
- package/server/routes/cli-auth.js +317 -0
- package/server/routes/commands.js +29 -3
- package/server/routes/git.js +15 -5
- package/server/routes/opencode.js +72 -0
- package/shared/modelConstants.js +62 -17
- package/dist/assets/index-CtRxrKDm.css +0 -32
- package/dist/assets/index-OENtErNy.js +0 -1249
- package/server/database/auth.db +0 -0
package/server/routes/agent.js
CHANGED
|
@@ -9,8 +9,10 @@ import { addProjectManually } from '../projects.js';
|
|
|
9
9
|
import { queryClaudeSDK } from '../claude-sdk.js';
|
|
10
10
|
import { spawnCursor } from '../cursor-cli.js';
|
|
11
11
|
import { queryCodex } from '../openai-codex.js';
|
|
12
|
+
import { queryGemini } from '../gemini-cli.js';
|
|
13
|
+
import { queryOpencode } from '../opencode-cli.js';
|
|
12
14
|
import { Octokit } from '@octokit/rest';
|
|
13
|
-
import {
|
|
15
|
+
import { CODEX_MODELS, GEMINI_MODELS, OPENCODE_MODELS } from '../../shared/modelConstants.js';
|
|
14
16
|
import { IS_PLATFORM } from '../constants/config.js';
|
|
15
17
|
|
|
16
18
|
const router = express.Router();
|
|
@@ -19,7 +21,11 @@ function buildSessionNavigation(sessionId) {
|
|
|
19
21
|
const normalizedSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
|
20
22
|
const sessionPath = normalizedSessionId ? `/session/${normalizedSessionId}` : null;
|
|
21
23
|
const frontendPort = process.env.VITE_PORT || '5173';
|
|
22
|
-
const
|
|
24
|
+
const configuredFrontendUrl = typeof process.env.FRONTEND_URL === 'string'
|
|
25
|
+
? process.env.FRONTEND_URL.trim().replace(/\/+$/, '')
|
|
26
|
+
: '';
|
|
27
|
+
const frontendBaseUrl = configuredFrontendUrl || `http://localhost:${frontendPort}`;
|
|
28
|
+
const sessionUrl = normalizedSessionId ? `${frontendBaseUrl}${sessionPath}` : null;
|
|
23
29
|
|
|
24
30
|
return {
|
|
25
31
|
sessionPath,
|
|
@@ -633,7 +639,7 @@ class ResponseCollector {
|
|
|
633
639
|
/**
|
|
634
640
|
* POST /api/agent
|
|
635
641
|
*
|
|
636
|
-
* Trigger an AI agent (Claude or
|
|
642
|
+
* Trigger an AI agent (Claude, Cursor, Codex, or Gemini) to work on a project.
|
|
637
643
|
* Supports automatic GitHub branch and pull request creation after successful completion.
|
|
638
644
|
*
|
|
639
645
|
* ================================================================================================
|
|
@@ -661,7 +667,7 @@ class ResponseCollector {
|
|
|
661
667
|
* @param {string} sessionId - (Optional) Existing session ID to resume.
|
|
662
668
|
* If provided, the request continues that session instead of creating a new one.
|
|
663
669
|
*
|
|
664
|
-
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
|
|
670
|
+
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
|
|
665
671
|
* Default: 'claude'
|
|
666
672
|
*
|
|
667
673
|
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
|
@@ -677,6 +683,7 @@ class ResponseCollector {
|
|
|
677
683
|
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
|
678
684
|
* 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
|
|
679
685
|
* Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
|
|
686
|
+
* Gemini models: 'gemini-2.5-pro' (default), 'gemini-2.5-flash', 'gemini-2.5-flash-lite'
|
|
680
687
|
*
|
|
681
688
|
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
|
|
682
689
|
* Default: true
|
|
@@ -779,7 +786,7 @@ class ResponseCollector {
|
|
|
779
786
|
* Input Validations (400 Bad Request):
|
|
780
787
|
* - Either githubUrl OR projectPath must be provided (not neither)
|
|
781
788
|
* - message must be non-empty string
|
|
782
|
-
* - provider must be 'claude' or '
|
|
789
|
+
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
|
|
783
790
|
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
|
784
791
|
* - branchName must pass Git naming rules (if provided)
|
|
785
792
|
*
|
|
@@ -872,7 +879,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
872
879
|
|
|
873
880
|
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
|
|
874
881
|
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
|
|
875
|
-
const
|
|
882
|
+
const requestedCleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
|
|
876
883
|
|
|
877
884
|
// If branchName is provided, automatically enable createBranch
|
|
878
885
|
const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
|
|
@@ -881,10 +888,13 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
881
888
|
const normalizedMessage = typeof message === 'string' ? message.trim() : '';
|
|
882
889
|
const requestedOpenOnly = openOnly === true || openOnly === 'true';
|
|
883
890
|
const isOpenOnly = requestedOpenOnly || (!normalizedMessage && !!normalizedSessionId);
|
|
891
|
+
// Keep openOnly requests backward-compatible with existing docs/examples:
|
|
892
|
+
// cleanup is irrelevant when we only generate session navigation.
|
|
893
|
+
const cleanup = isOpenOnly ? false : requestedCleanup;
|
|
884
894
|
|
|
885
895
|
// Validate inputs
|
|
886
|
-
if (!['claude', 'cursor', 'codex'].includes(provider)) {
|
|
887
|
-
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "
|
|
896
|
+
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
|
|
897
|
+
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
|
|
888
898
|
}
|
|
889
899
|
|
|
890
900
|
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId.trim())) {
|
|
@@ -911,10 +921,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
911
921
|
return res.status(400).json({ error: 'createBranch and createPR are not supported when openOnly=true' });
|
|
912
922
|
}
|
|
913
923
|
|
|
914
|
-
if (isOpenOnly && cleanup) {
|
|
915
|
-
return res.status(400).json({ error: 'cleanup is not supported when openOnly=true' });
|
|
916
|
-
}
|
|
917
|
-
|
|
918
924
|
// Validate GitHub branch/PR creation requirements
|
|
919
925
|
// Allow branch/PR creation with projectPath as long as it has a GitHub remote
|
|
920
926
|
if ((createBranch || createPR) && !githubUrl && !projectPath) {
|
|
@@ -1062,6 +1068,28 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
1062
1068
|
model: model || CODEX_MODELS.DEFAULT,
|
|
1063
1069
|
permissionMode: 'bypassPermissions'
|
|
1064
1070
|
}, writer);
|
|
1071
|
+
} else if (provider === 'gemini') {
|
|
1072
|
+
console.log('💎 Starting Gemini CLI session');
|
|
1073
|
+
|
|
1074
|
+
await queryGemini(normalizedMessage, {
|
|
1075
|
+
projectPath: finalProjectPath,
|
|
1076
|
+
cwd: finalProjectPath,
|
|
1077
|
+
sessionId: normalizedSessionId,
|
|
1078
|
+
resume: !!normalizedSessionId,
|
|
1079
|
+
model: model || GEMINI_MODELS.DEFAULT,
|
|
1080
|
+
permissionMode: 'bypassPermissions'
|
|
1081
|
+
}, writer);
|
|
1082
|
+
} else if (provider === 'opencode') {
|
|
1083
|
+
console.log('🧠 Starting OpenCode CLI session');
|
|
1084
|
+
|
|
1085
|
+
await queryOpencode(normalizedMessage, {
|
|
1086
|
+
projectPath: finalProjectPath,
|
|
1087
|
+
cwd: finalProjectPath,
|
|
1088
|
+
sessionId: normalizedSessionId,
|
|
1089
|
+
resume: !!normalizedSessionId,
|
|
1090
|
+
model: model || OPENCODE_MODELS.DEFAULT,
|
|
1091
|
+
permissionMode: 'bypassPermissions'
|
|
1092
|
+
}, writer);
|
|
1065
1093
|
}
|
|
1066
1094
|
|
|
1067
1095
|
// Handle GitHub branch and PR creation after successful agent completion
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
import { getChannelManager } from '../channels/index.js';
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
const SUPPORTED_PLATFORMS = new Set(['lark', 'dingtalk']);
|
|
8
|
+
|
|
9
|
+
function parseBoolean(value) {
|
|
10
|
+
if (value === true || value === 'true' || value === 1 || value === '1') {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (value === false || value === 'false' || value === 0 || value === '0') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizePlatform(value) {
|
|
20
|
+
const platform = String(value || '').trim().toLowerCase();
|
|
21
|
+
return SUPPORTED_PLATFORMS.has(platform) ? platform : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function resolveManager(req, res) {
|
|
25
|
+
const platform = normalizePlatform(req.params.platform);
|
|
26
|
+
if (!platform) {
|
|
27
|
+
res.status(400).json({ success: false, error: 'unsupported platform' });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const manager = getChannelManager();
|
|
32
|
+
await manager.initialize();
|
|
33
|
+
return { manager, platform };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
router.get('/:platform/config', async (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const resolved = await resolveManager(req, res);
|
|
39
|
+
if (!resolved) return;
|
|
40
|
+
|
|
41
|
+
const { manager, platform } = resolved;
|
|
42
|
+
res.json({ success: true, config: manager.getConfig(platform) });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
res.status(500).json({ success: false, error: error.message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.put('/:platform/config', async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const resolved = await resolveManager(req, res);
|
|
51
|
+
if (!resolved) return;
|
|
52
|
+
|
|
53
|
+
const { manager, platform } = resolved;
|
|
54
|
+
const config = manager.updateConfig(platform, req.body || {});
|
|
55
|
+
res.json({ success: true, config });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
res.status(500).json({ success: false, error: error.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
router.post('/:platform/test', async (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const resolved = await resolveManager(req, res);
|
|
64
|
+
if (!resolved) return;
|
|
65
|
+
|
|
66
|
+
const { manager, platform } = resolved;
|
|
67
|
+
const result =
|
|
68
|
+
platform === 'dingtalk'
|
|
69
|
+
? await manager.testConnection(platform, {
|
|
70
|
+
clientId: String(req.body?.clientId || '').trim(),
|
|
71
|
+
clientSecret: String(req.body?.clientSecret || '').trim(),
|
|
72
|
+
})
|
|
73
|
+
: await manager.testConnection(platform, {
|
|
74
|
+
appId: String(req.body?.appId || '').trim(),
|
|
75
|
+
appSecret: String(req.body?.appSecret || '').trim(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
return res.status(400).json(result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
res.json(result);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
res.status(500).json({ success: false, error: error.message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.post('/:platform/start', async (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const resolved = await resolveManager(req, res);
|
|
91
|
+
if (!resolved) return;
|
|
92
|
+
|
|
93
|
+
const { manager, platform } = resolved;
|
|
94
|
+
const status = await manager.startPlugin(platform);
|
|
95
|
+
res.json({ success: true, status });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.status(400).json({ success: false, error: error.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.post('/:platform/stop', async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
const resolved = await resolveManager(req, res);
|
|
104
|
+
if (!resolved) return;
|
|
105
|
+
|
|
106
|
+
const { manager, platform } = resolved;
|
|
107
|
+
const status = await manager.stopPlugin(platform);
|
|
108
|
+
res.json({ success: true, status });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
res.status(500).json({ success: false, error: error.message });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
router.get('/:platform/status', async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
const resolved = await resolveManager(req, res);
|
|
117
|
+
if (!resolved) return;
|
|
118
|
+
|
|
119
|
+
const { manager, platform } = resolved;
|
|
120
|
+
res.json({ success: true, status: manager.getStatus(platform) });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
res.status(500).json({ success: false, error: error.message });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
router.get('/:platform/allowed-users', async (req, res) => {
|
|
127
|
+
try {
|
|
128
|
+
const resolved = await resolveManager(req, res);
|
|
129
|
+
if (!resolved) return;
|
|
130
|
+
|
|
131
|
+
const { manager, platform } = resolved;
|
|
132
|
+
res.json({ success: true, users: manager.listAllowedUsers(platform) });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
res.status(500).json({ success: false, error: error.message });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
router.post('/:platform/allowed-users', async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const userId = String(req.body?.userId || '').trim();
|
|
141
|
+
const displayName = req.body?.displayName;
|
|
142
|
+
const note = req.body?.note;
|
|
143
|
+
|
|
144
|
+
if (!userId) {
|
|
145
|
+
return res.status(400).json({ success: false, error: 'userId is required' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const resolved = await resolveManager(req, res);
|
|
149
|
+
if (!resolved) return;
|
|
150
|
+
|
|
151
|
+
const { manager, platform } = resolved;
|
|
152
|
+
|
|
153
|
+
const user = manager.addAllowedUser(platform, {
|
|
154
|
+
userId,
|
|
155
|
+
displayName,
|
|
156
|
+
note,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
res.json({ success: true, user });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (String(error.message || '').includes('UNIQUE')) {
|
|
162
|
+
return res.status(409).json({ success: false, error: '该用户ID已存在' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
res.status(500).json({ success: false, error: error.message });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
router.patch('/:platform/allowed-users/:id/toggle', async (req, res) => {
|
|
170
|
+
try {
|
|
171
|
+
const id = Number(req.params.id);
|
|
172
|
+
const isActive = parseBoolean(req.body?.isActive);
|
|
173
|
+
|
|
174
|
+
if (!Number.isFinite(id)) {
|
|
175
|
+
return res.status(400).json({ success: false, error: 'invalid id' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isActive === null) {
|
|
179
|
+
return res.status(400).json({ success: false, error: 'isActive must be boolean' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const resolved = await resolveManager(req, res);
|
|
183
|
+
if (!resolved) return;
|
|
184
|
+
|
|
185
|
+
const { manager, platform } = resolved;
|
|
186
|
+
|
|
187
|
+
const ok = manager.toggleAllowedUser(platform, id, isActive);
|
|
188
|
+
if (!ok) {
|
|
189
|
+
return res.status(404).json({ success: false, error: 'user not found' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
res.json({ success: true });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
res.status(500).json({ success: false, error: error.message });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
router.delete('/:platform/allowed-users/:id', async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const id = Number(req.params.id);
|
|
201
|
+
if (!Number.isFinite(id)) {
|
|
202
|
+
return res.status(400).json({ success: false, error: 'invalid id' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const resolved = await resolveManager(req, res);
|
|
206
|
+
if (!resolved) return;
|
|
207
|
+
|
|
208
|
+
const { manager, platform } = resolved;
|
|
209
|
+
|
|
210
|
+
const ok = manager.removeAllowedUser(platform, id);
|
|
211
|
+
if (!ok) {
|
|
212
|
+
return res.status(404).json({ success: false, error: 'user not found' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
res.json({ success: true });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
res.status(500).json({ success: false, error: error.message });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export default router;
|
|
@@ -5,6 +5,218 @@ import path from 'path';
|
|
|
5
5
|
import os from 'os';
|
|
6
6
|
|
|
7
7
|
const router = express.Router();
|
|
8
|
+
const INSTALLATION_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
9
|
+
const SUPPORTED_PROVIDER_COMMANDS = {
|
|
10
|
+
claude: 'claude',
|
|
11
|
+
cursor: 'cursor-agent',
|
|
12
|
+
codex: 'codex',
|
|
13
|
+
gemini: 'gemini',
|
|
14
|
+
opencode: 'opencode'
|
|
15
|
+
};
|
|
16
|
+
const PROVIDER_INSTALL_HINTS = {
|
|
17
|
+
claude: 'Install Claude Code CLI: npm i -g @anthropic-ai/claude-code',
|
|
18
|
+
cursor: 'Install Cursor Agent CLI: npm i -g @cursor-ai/cursor-agent',
|
|
19
|
+
codex: 'Install Codex CLI: npm i -g @openai/codex',
|
|
20
|
+
gemini: 'Install Gemini CLI: npm i -g @google/gemini-cli',
|
|
21
|
+
opencode: 'Install OpenCode CLI: npm i -g opencode-ai'
|
|
22
|
+
};
|
|
23
|
+
const installationStatusCache = new Map(); // provider -> status
|
|
24
|
+
const installationStatusInFlight = new Map(); // provider -> Promise
|
|
25
|
+
|
|
26
|
+
function getLocatorCommand() {
|
|
27
|
+
return process.platform === 'win32' ? 'where' : 'which';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveCommandPath(command) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
let childProcess;
|
|
33
|
+
let stdout = '';
|
|
34
|
+
let stderr = '';
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
childProcess = spawn(getLocatorCommand(), [command], {
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
resolve({
|
|
42
|
+
found: false,
|
|
43
|
+
resolvedPath: null,
|
|
44
|
+
reason: `Failed to run command locator: ${error.message}`
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
childProcess.stdout.on('data', (data) => {
|
|
50
|
+
stdout += data.toString();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
childProcess.stderr.on('data', (data) => {
|
|
54
|
+
stderr += data.toString();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
childProcess.on('close', (code) => {
|
|
58
|
+
const lines = stdout
|
|
59
|
+
.split(/\r?\n/)
|
|
60
|
+
.map(line => line.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const resolvedPath = lines[0] || null;
|
|
63
|
+
|
|
64
|
+
if (code === 0 && resolvedPath) {
|
|
65
|
+
resolve({
|
|
66
|
+
found: true,
|
|
67
|
+
resolvedPath,
|
|
68
|
+
reason: null
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resolve({
|
|
74
|
+
found: false,
|
|
75
|
+
resolvedPath: null,
|
|
76
|
+
reason: `${command} not found in PATH${stderr?.trim() ? ` (${stderr.trim()})` : ''}`
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
childProcess.on('error', (error) => {
|
|
81
|
+
resolve({
|
|
82
|
+
found: false,
|
|
83
|
+
resolvedPath: null,
|
|
84
|
+
reason: `Failed to detect ${command}: ${error.message}`
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildInstallationStatus(provider, command, installed, resolvedPath, reason, cacheHit = false) {
|
|
91
|
+
return {
|
|
92
|
+
success: true,
|
|
93
|
+
provider,
|
|
94
|
+
command,
|
|
95
|
+
installed,
|
|
96
|
+
reason,
|
|
97
|
+
installHint: PROVIDER_INSTALL_HINTS[provider] || null,
|
|
98
|
+
resolvedPath,
|
|
99
|
+
checkedAt: new Date().toISOString(),
|
|
100
|
+
cacheHit,
|
|
101
|
+
ttlMs: INSTALLATION_CACHE_TTL_MS
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function detectProviderInstallationStatus(provider) {
|
|
106
|
+
const command = SUPPORTED_PROVIDER_COMMANDS[provider];
|
|
107
|
+
if (!command) {
|
|
108
|
+
throw new Error(`Unsupported provider: ${provider}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const cached = installationStatusCache.get(provider);
|
|
113
|
+
if (cached && cached.expiresAt > now) {
|
|
114
|
+
return {
|
|
115
|
+
...cached.status,
|
|
116
|
+
cacheHit: true
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (installationStatusInFlight.has(provider)) {
|
|
121
|
+
return installationStatusInFlight.get(provider);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const detectionPromise = (async () => {
|
|
125
|
+
let status;
|
|
126
|
+
|
|
127
|
+
if (provider === 'opencode') {
|
|
128
|
+
const [opencodeCheck, npxCheck] = await Promise.all([
|
|
129
|
+
resolveCommandPath('opencode'),
|
|
130
|
+
resolveCommandPath('npx')
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
if (opencodeCheck.found) {
|
|
134
|
+
status = buildInstallationStatus(
|
|
135
|
+
provider,
|
|
136
|
+
'opencode',
|
|
137
|
+
true,
|
|
138
|
+
opencodeCheck.resolvedPath,
|
|
139
|
+
null,
|
|
140
|
+
false
|
|
141
|
+
);
|
|
142
|
+
} else if (npxCheck.found) {
|
|
143
|
+
status = buildInstallationStatus(
|
|
144
|
+
provider,
|
|
145
|
+
'npx (fallback: opencode-ai@latest)',
|
|
146
|
+
true,
|
|
147
|
+
npxCheck.resolvedPath,
|
|
148
|
+
'OpenCode binary not found in PATH. Axhub Genie will use npx fallback.',
|
|
149
|
+
false
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
status = buildInstallationStatus(
|
|
153
|
+
provider,
|
|
154
|
+
'opencode',
|
|
155
|
+
false,
|
|
156
|
+
null,
|
|
157
|
+
opencodeCheck.reason || npxCheck.reason || 'OpenCode and npx are not available in PATH',
|
|
158
|
+
false
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
const commandCheck = await resolveCommandPath(command);
|
|
163
|
+
const installed = commandCheck.found;
|
|
164
|
+
status = buildInstallationStatus(
|
|
165
|
+
provider,
|
|
166
|
+
command,
|
|
167
|
+
installed,
|
|
168
|
+
commandCheck.resolvedPath,
|
|
169
|
+
commandCheck.reason,
|
|
170
|
+
false
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (status.installed) {
|
|
175
|
+
installationStatusCache.set(provider, {
|
|
176
|
+
status,
|
|
177
|
+
expiresAt: now + INSTALLATION_CACHE_TTL_MS
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
installationStatusCache.delete(provider);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return status;
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
installationStatusInFlight.set(provider, detectionPromise);
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
return await detectionPromise;
|
|
190
|
+
} finally {
|
|
191
|
+
installationStatusInFlight.delete(provider);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
router.get('/providers/:provider/installation-status', async (req, res) => {
|
|
196
|
+
try {
|
|
197
|
+
const provider = String(req.params.provider || '').toLowerCase();
|
|
198
|
+
if (!Object.prototype.hasOwnProperty.call(SUPPORTED_PROVIDER_COMMANDS, provider)) {
|
|
199
|
+
return res.status(400).json({
|
|
200
|
+
success: false,
|
|
201
|
+
error: `Unsupported provider: ${provider}`
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const forceRefresh = String(req.query.force || '').toLowerCase() === 'true';
|
|
206
|
+
if (forceRefresh) {
|
|
207
|
+
installationStatusCache.delete(provider);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const status = await detectProviderInstallationStatus(provider);
|
|
211
|
+
return res.json(status);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error('Error checking provider installation status:', error);
|
|
214
|
+
return res.status(500).json({
|
|
215
|
+
success: false,
|
|
216
|
+
error: error.message
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
8
220
|
|
|
9
221
|
router.get('/claude/status', async (req, res) => {
|
|
10
222
|
try {
|
|
@@ -74,6 +286,26 @@ router.get('/codex/status', async (req, res) => {
|
|
|
74
286
|
}
|
|
75
287
|
});
|
|
76
288
|
|
|
289
|
+
router.get('/gemini/status', async (req, res) => {
|
|
290
|
+
try {
|
|
291
|
+
const result = await checkGeminiCredentials();
|
|
292
|
+
|
|
293
|
+
res.json({
|
|
294
|
+
authenticated: result.authenticated,
|
|
295
|
+
email: result.email,
|
|
296
|
+
error: result.error
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error('Error checking Gemini auth status:', error);
|
|
301
|
+
res.status(500).json({
|
|
302
|
+
authenticated: false,
|
|
303
|
+
email: null,
|
|
304
|
+
error: error.message
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
77
309
|
async function checkClaudeCredentials() {
|
|
78
310
|
try {
|
|
79
311
|
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
@@ -260,4 +492,89 @@ async function checkCodexCredentials() {
|
|
|
260
492
|
}
|
|
261
493
|
}
|
|
262
494
|
|
|
495
|
+
async function checkGeminiCredentials() {
|
|
496
|
+
try {
|
|
497
|
+
const geminiDir = path.join(os.homedir(), '.gemini');
|
|
498
|
+
const oauthCredPath = path.join(geminiDir, 'oauth_creds.json');
|
|
499
|
+
const content = await fs.readFile(oauthCredPath, 'utf8');
|
|
500
|
+
const creds = JSON.parse(content);
|
|
501
|
+
|
|
502
|
+
if (creds?.access_token || creds?.refresh_token || creds?.token) {
|
|
503
|
+
return {
|
|
504
|
+
authenticated: true,
|
|
505
|
+
email: creds?.email || 'Authenticated'
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
authenticated: false,
|
|
511
|
+
email: null,
|
|
512
|
+
error: 'No valid Gemini credentials found'
|
|
513
|
+
};
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code === 'ENOENT') {
|
|
516
|
+
return {
|
|
517
|
+
authenticated: false,
|
|
518
|
+
email: null,
|
|
519
|
+
error: 'Gemini not configured'
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
authenticated: false,
|
|
525
|
+
email: null,
|
|
526
|
+
error: error.message
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// OpenCode status helper
|
|
532
|
+
async function checkOpencodeCredentials() {
|
|
533
|
+
try {
|
|
534
|
+
const candidates = [
|
|
535
|
+
path.join(os.homedir(), '.config', 'opencode', 'auth.json'),
|
|
536
|
+
path.join(os.homedir(), '.opencode', 'auth.json')
|
|
537
|
+
];
|
|
538
|
+
let anyFileAccessible = false;
|
|
539
|
+
for (const p of candidates) {
|
|
540
|
+
try {
|
|
541
|
+
await fs.access(p);
|
|
542
|
+
anyFileAccessible = true;
|
|
543
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
544
|
+
const data = JSON.parse(raw);
|
|
545
|
+
const token = data?.access_token || data?.token || data?.api_key;
|
|
546
|
+
if (token) {
|
|
547
|
+
const email = data.email || 'Authenticated';
|
|
548
|
+
return { authenticated: true, email, error: null };
|
|
549
|
+
}
|
|
550
|
+
// else try next candidate
|
|
551
|
+
} catch (err) {
|
|
552
|
+
// ignore and try next
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (anyFileAccessible) {
|
|
557
|
+
return { authenticated: false, email: null, error: 'Invalid OpenCode credentials format' };
|
|
558
|
+
}
|
|
559
|
+
return { authenticated: false, email: null, error: 'OpenCode not configured' };
|
|
560
|
+
} catch (error) {
|
|
561
|
+
return { authenticated: false, email: null, error: 'OpenCode not configured' };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Route: GET /opencode/status
|
|
566
|
+
router.get('/opencode/status', async (req, res) => {
|
|
567
|
+
try {
|
|
568
|
+
const result = await checkOpencodeCredentials();
|
|
569
|
+
res.json({
|
|
570
|
+
authenticated: result.authenticated,
|
|
571
|
+
email: result.email,
|
|
572
|
+
error: result.error
|
|
573
|
+
});
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error('Error checking OpenCode auth status:', error);
|
|
576
|
+
res.status(500).json({ authenticated: false, email: null, error: error.message });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
263
580
|
export default router;
|