@geekbeer/minion 2.23.0 → 2.32.0

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 (47) hide show
  1. package/core/lib/platform.js +117 -0
  2. package/{routes → core/routes}/health.js +1 -1
  3. package/{routes → core/routes}/routines.js +44 -4
  4. package/{routes → core/routes}/skills.js +3 -3
  5. package/{routes → core/routes}/workflows.js +4 -4
  6. package/{chat-store.js → core/stores/chat-store.js} +1 -1
  7. package/{execution-store.js → core/stores/execution-store.js} +1 -1
  8. package/{routine-store.js → core/stores/routine-store.js} +1 -1
  9. package/{workflow-store.js → core/stores/workflow-store.js} +1 -1
  10. package/{minion-cli.sh → linux/minion-cli.sh} +245 -4
  11. package/{routes → linux/routes}/chat.js +3 -3
  12. package/{routes → linux/routes}/commands.js +1 -1
  13. package/{routes → linux/routes}/config.js +3 -3
  14. package/{routes → linux/routes}/directives.js +5 -5
  15. package/{routes → linux/routes}/files.js +2 -2
  16. package/{routes → linux/routes}/terminal.js +2 -2
  17. package/{routine-runner.js → linux/routine-runner.js} +4 -4
  18. package/{server.js → linux/server.js} +71 -36
  19. package/{workflow-runner.js → linux/workflow-runner.js} +4 -4
  20. package/package.json +16 -20
  21. package/win/bin/hq-win.js +18 -0
  22. package/win/bin/hq.ps1 +108 -0
  23. package/win/bin/minion-cli-win.js +20 -0
  24. package/win/lib/llm-checker.js +115 -0
  25. package/win/lib/log-manager.js +119 -0
  26. package/win/lib/process-manager.js +112 -0
  27. package/win/minion-cli.ps1 +869 -0
  28. package/win/routes/chat.js +280 -0
  29. package/win/routes/commands.js +101 -0
  30. package/win/routes/config.js +227 -0
  31. package/win/routes/directives.js +136 -0
  32. package/win/routes/files.js +283 -0
  33. package/win/routes/terminal.js +316 -0
  34. package/win/routine-runner.js +324 -0
  35. package/win/server.js +230 -0
  36. package/win/terminal-server.js +234 -0
  37. package/win/workflow-runner.js +380 -0
  38. package/routes/index.js +0 -106
  39. /package/{api.js → core/api.js} +0 -0
  40. /package/{config.js → core/config.js} +0 -0
  41. /package/{lib → core/lib}/auth.js +0 -0
  42. /package/{lib → core/lib}/llm-checker.js +0 -0
  43. /package/{lib → core/lib}/log-manager.js +0 -0
  44. /package/{routes → core/routes}/auth.js +0 -0
  45. /package/{bin → linux/bin}/hq +0 -0
  46. /package/{lib → linux/lib}/process-manager.js +0 -0
  47. /package/{terminal-proxy.js → linux/terminal-proxy.js} +0 -0
@@ -20,8 +20,8 @@ const path = require('path')
20
20
  const net = require('net')
21
21
  const execAsync = promisify(exec)
22
22
 
23
- const { verifyToken } = require('../lib/auth')
24
- const { config } = require('../config')
23
+ const { verifyToken } = require('../../core/lib/auth')
24
+ const { config } = require('../../core/config')
25
25
 
26
26
  // Ensure consistent HOME for tmux socket path
27
27
  const homeDir = config.HOME_DIR
@@ -18,10 +18,10 @@ const path = require('path')
18
18
  const fs = require('fs').promises
19
19
  const execAsync = promisify(exec)
20
20
 
21
- const { config } = require('./config')
22
- const executionStore = require('./execution-store')
23
- const routineStore = require('./routine-store')
24
- const logManager = require('./lib/log-manager')
21
+ const { config } = require('../core/config')
22
+ const executionStore = require('../core/stores/execution-store')
23
+ const routineStore = require('../core/stores/routine-store')
24
+ const logManager = require('../core/lib/log-manager')
25
25
 
26
26
  // Active cron jobs keyed by routine ID
27
27
  const activeJobs = new Map()
@@ -1,24 +1,60 @@
1
1
  /**
2
- * Minion Agent HTTP Server
2
+ * Minion Agent HTTP Server (Linux)
3
3
  *
4
- * Entry point for the minion agent. Registers all routes and manages lifecycle.
5
- * See routes/index.js for API documentation.
4
+ * Entry point for the minion agent on Linux.
5
+ * Registers shared routes (from core/) and Linux-specific routes.
6
+ *
7
+ * API Overview:
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ * Health & Status: GET/POST /api/health, /api/status
10
+ * Commands: GET /api/commands, POST /api/command
11
+ * Skills: GET /api/list-skills, POST /api/deploy-skill, etc.
12
+ * Workflows: GET/POST/PUT/DELETE /api/workflows, /api/workflows/trigger
13
+ * Routines: GET/POST/PUT/DELETE /api/routines, /api/routines/trigger
14
+ * Terminal: GET/POST /api/terminal/sessions, /send, /kill, /capture
15
+ * Files: GET/POST/DELETE /api/files
16
+ * Directives: POST /api/directive
17
+ * Auth: GET /api/auth/status
18
+ * Chat: POST /api/chat, GET /api/chat/session, POST /api/chat/clear
19
+ * Config: GET /api/config/backup, GET/PUT /api/config/env
20
+ * Executions: GET /api/executions, GET /api/executions/:id, etc.
21
+ * ─────────────────────────────────────────────────────────────────────────────
6
22
  */
7
23
 
8
24
  const fs = require('fs')
9
25
  const path = require('path')
10
26
 
11
27
  const fastify = require('fastify')({ logger: true })
12
- const { config, validate, isHqConfigured } = require('./config')
28
+
29
+ // Package root (one level up from linux/)
30
+ const PACKAGE_ROOT = path.join(__dirname, '..')
31
+
32
+ // Core shared modules
33
+ const { config, validate, isHqConfigured } = require('../core/config')
34
+ const workflowStore = require('../core/stores/workflow-store')
35
+ const routineStore = require('../core/stores/routine-store')
36
+
37
+ // Linux-specific modules
13
38
  const workflowRunner = require('./workflow-runner')
14
- const workflowStore = require('./workflow-store')
15
39
  const routineRunner = require('./routine-runner')
16
- const routineStore = require('./routine-store')
17
-
18
- const { registerRoutes, setOffline, getProcessManager, getAllowedCommands } = require('./routes')
19
40
  const { cleanupTtyd, killStaleTtydProcesses } = require('./routes/terminal')
20
41
  const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
21
42
 
43
+ // Shared routes (from core/)
44
+ const { healthRoutes, setOffline } = require('../core/routes/health')
45
+ const { skillRoutes } = require('../core/routes/skills')
46
+ const { workflowRoutes } = require('../core/routes/workflows')
47
+ const { routineRoutes } = require('../core/routes/routines')
48
+ const { authRoutes } = require('../core/routes/auth')
49
+
50
+ // Linux-specific routes
51
+ const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
52
+ const { terminalRoutes } = require('./routes/terminal')
53
+ const { fileRoutes } = require('./routes/files')
54
+ const { directiveRoutes } = require('./routes/directives')
55
+ const { chatRoutes } = require('./routes/chat')
56
+ const { configRoutes } = require('./routes/config')
57
+
22
58
  // Validate configuration before starting
23
59
  validate()
24
60
  const PROC_MGR = getProcessManager()
@@ -50,11 +86,9 @@ process.on('SIGINT', () => shutdown('SIGINT'))
50
86
 
51
87
  /**
52
88
  * Sync bundled permissions into ~/.claude/settings.json.
53
- * Merges package-defined allow/deny into the existing settings without
54
- * removing user-added entries or non-permission keys (e.g. mcpServers).
55
89
  */
56
90
  function syncPermissions() {
57
- const bundledPath = path.join(__dirname, 'settings', 'permissions.json')
91
+ const bundledPath = path.join(PACKAGE_ROOT, 'settings', 'permissions.json')
58
92
  const settingsDir = path.join(config.HOME_DIR, '.claude')
59
93
  const settingsPath = path.join(settingsDir, 'settings.json')
60
94
 
@@ -63,13 +97,11 @@ function syncPermissions() {
63
97
 
64
98
  const bundled = JSON.parse(fs.readFileSync(bundledPath, 'utf-8'))
65
99
 
66
- // Read existing settings or start fresh
67
100
  let settings = {}
68
101
  if (fs.existsSync(settingsPath)) {
69
102
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
70
103
  }
71
104
 
72
- // Replace permissions section with bundled values
73
105
  settings.permissions = {
74
106
  allow: bundled.allow || [],
75
107
  deny: bundled.deny || [],
@@ -85,10 +117,9 @@ function syncPermissions() {
85
117
 
86
118
  /**
87
119
  * Sync bundled tmux.conf to ~/.tmux.conf.
88
- * Enables mouse-driven scrollback (copy-mode) for the WebSocket terminal.
89
120
  */
90
121
  function syncTmuxConfig() {
91
- const bundledPath = path.join(__dirname, 'settings', 'tmux.conf')
122
+ const bundledPath = path.join(PACKAGE_ROOT, 'settings', 'tmux.conf')
92
123
  const destPath = path.join(config.HOME_DIR, '.tmux.conf')
93
124
 
94
125
  try {
@@ -103,11 +134,9 @@ function syncTmuxConfig() {
103
134
 
104
135
  /**
105
136
  * Sync bundled rules from the package to ~/.claude/rules/.
106
- * Deploys core.md only. Role context is injected per-execution, not as rules.
107
- * Removes legacy files (minion.md, role-*.md) if present.
108
137
  */
109
138
  function syncBundledRules() {
110
- const bundledRulesDir = path.join(__dirname, 'rules')
139
+ const bundledRulesDir = path.join(PACKAGE_ROOT, 'rules')
111
140
  const targetRulesDir = path.join(config.HOME_DIR, '.claude', 'rules')
112
141
 
113
142
  try {
@@ -115,14 +144,12 @@ function syncBundledRules() {
115
144
 
116
145
  fs.mkdirSync(targetRulesDir, { recursive: true })
117
146
 
118
- // Always deploy core.md
119
147
  const coreSrc = path.join(bundledRulesDir, 'core.md')
120
148
  if (fs.existsSync(coreSrc)) {
121
149
  fs.copyFileSync(coreSrc, path.join(targetRulesDir, 'core.md'))
122
150
  console.log('[Rules] Synced: core.md')
123
151
  }
124
152
 
125
- // Remove legacy files if present
126
153
  for (const legacy of ['minion.md', 'role-pm.md', 'role-engineer.md']) {
127
154
  const legacyPath = path.join(targetRulesDir, legacy)
128
155
  if (fs.existsSync(legacyPath)) {
@@ -137,11 +164,9 @@ function syncBundledRules() {
137
164
 
138
165
  /**
139
166
  * Sync bundled role context files to ~/.minion/roles/.
140
- * These are NOT loaded as Claude Code rules — they are injected
141
- * into the prompt at execution time based on the minion's role.
142
167
  */
143
168
  function syncBundledRoles() {
144
- const bundledRolesDir = path.join(__dirname, 'roles')
169
+ const bundledRolesDir = path.join(PACKAGE_ROOT, 'roles')
145
170
  const targetRolesDir = path.join(config.HOME_DIR, '.minion', 'roles')
146
171
 
147
172
  try {
@@ -163,11 +188,9 @@ function syncBundledRoles() {
163
188
 
164
189
  /**
165
190
  * Sync bundled documentation files to ~/.minion/docs/.
166
- * These are reference documents accessed on-demand by Claude Code,
167
- * NOT loaded automatically as rules.
168
191
  */
169
192
  function syncBundledDocs() {
170
- const bundledDocsDir = path.join(__dirname, 'docs')
193
+ const bundledDocsDir = path.join(PACKAGE_ROOT, 'docs')
171
194
  const targetDocsDir = path.join(config.HOME_DIR, '.minion', 'docs')
172
195
 
173
196
  try {
@@ -187,26 +210,38 @@ function syncBundledDocs() {
187
210
  }
188
211
  }
189
212
 
213
+ /**
214
+ * Register all routes (shared + Linux-specific)
215
+ */
216
+ async function registerAllRoutes(app) {
217
+ // Shared routes (from core/) - inject runners via opts
218
+ await app.register(healthRoutes)
219
+ await app.register(skillRoutes, { workflowRunner })
220
+ await app.register(workflowRoutes, { workflowRunner })
221
+ await app.register(routineRoutes, { routineRunner })
222
+ await app.register(authRoutes)
223
+
224
+ // Linux-specific routes
225
+ await app.register(commandRoutes)
226
+ await app.register(terminalRoutes)
227
+ await app.register(fileRoutes)
228
+ await app.register(directiveRoutes)
229
+ await app.register(chatRoutes)
230
+ await app.register(configRoutes)
231
+ }
232
+
190
233
  // Start server
191
234
  async function start() {
192
235
  try {
193
- // Sync bundled rules to ~/.claude/rules/ (core.md only)
236
+ // Sync bundled assets
194
237
  syncBundledRules()
195
-
196
- // Sync bundled roles to ~/.minion/roles/ (injected per-execution)
197
238
  syncBundledRoles()
198
-
199
- // Sync bundled docs to ~/.minion/docs/ (on-demand reference)
200
239
  syncBundledDocs()
201
-
202
- // Sync bundled permissions to ~/.claude/settings.json (broad allow + deny-list)
203
240
  syncPermissions()
204
-
205
- // Sync tmux.conf for mouse scroll support in WebSocket terminal
206
241
  syncTmuxConfig()
207
242
 
208
243
  // Register all routes
209
- await registerRoutes(fastify)
244
+ await registerAllRoutes(fastify)
210
245
 
211
246
  // Listen on all interfaces
212
247
  await fastify.listen({ port: config.AGENT_PORT, host: '0.0.0.0' })
@@ -18,10 +18,10 @@ const path = require('path')
18
18
  const fs = require('fs').promises
19
19
  const execAsync = promisify(exec)
20
20
 
21
- const { config } = require('./config')
22
- const executionStore = require('./execution-store')
23
- const workflowStore = require('./workflow-store')
24
- const logManager = require('./lib/log-manager')
21
+ const { config } = require('../core/config')
22
+ const executionStore = require('../core/stores/execution-store')
23
+ const workflowStore = require('../core/stores/workflow-store')
24
+ const logManager = require('../core/lib/log-manager')
25
25
 
26
26
  // Active cron jobs keyed by workflow ID
27
27
  const activeJobs = new Map()
package/package.json CHANGED
@@ -1,40 +1,36 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.23.0",
3
+ "version": "2.32.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
- "main": "server.js",
5
+ "main": "linux/server.js",
6
6
  "bin": {
7
- "minion-cli": "./minion-cli.sh",
8
- "hq": "./bin/hq"
7
+ "minion-cli": "./linux/minion-cli.sh",
8
+ "hq": "./linux/bin/hq",
9
+ "minion-cli-win": "./win/bin/minion-cli-win.js",
10
+ "hq-win": "./win/bin/hq-win.js"
9
11
  },
10
12
  "files": [
11
- "server.js",
12
- "config.js",
13
- "api.js",
14
- "terminal-proxy.js",
15
- "workflow-runner.js",
16
- "workflow-store.js",
17
- "routine-runner.js",
18
- "routine-store.js",
19
- "execution-store.js",
20
- "chat-store.js",
21
- "lib/",
22
- "routes/",
13
+ "core/",
14
+ "linux/",
15
+ "win/",
23
16
  "skills/",
24
17
  "rules/",
25
18
  "roles/",
26
19
  "docs/",
27
20
  "settings/",
28
- "bin/",
29
- "minion-cli.sh",
30
21
  ".env.example"
31
22
  ],
32
23
  "scripts": {
33
- "start": "node server.js"
24
+ "start": "node linux/server.js",
25
+ "start:win": "node win/server.js"
34
26
  },
35
27
  "dependencies": {
36
28
  "croner": "^9.0.0",
37
- "fastify": "^5.2.2"
29
+ "fastify": "^5.2.2",
30
+ "ws": "^8.0.0"
31
+ },
32
+ "optionalDependencies": {
33
+ "node-pty": "^1.0.0"
38
34
  },
39
35
  "engines": {
40
36
  "node": ">=20.0.0"
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // Node.js wrapper to launch hq.ps1 via powershell.exe
3
+ // See minion-cli-win.js for rationale.
4
+ const { spawn } = require('child_process');
5
+ const path = require('path');
6
+
7
+ const child = spawn('powershell.exe', [
8
+ '-ExecutionPolicy', 'Bypass',
9
+ '-NoLogo',
10
+ '-File', path.join(__dirname, 'hq.ps1'),
11
+ ...process.argv.slice(2)
12
+ ], { stdio: 'inherit' });
13
+
14
+ child.on('exit', (code) => process.exit(code || 0));
15
+ child.on('error', (err) => {
16
+ console.error('Failed to start PowerShell:', err.message);
17
+ process.exit(1);
18
+ });
package/win/bin/hq.ps1 ADDED
@@ -0,0 +1,108 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ HQ API helper for minion chat context (Windows version)
5
+
6
+ .DESCRIPTION
7
+ Fetches resource details from the HQ server API.
8
+ Used by Claude CLI during chat to retrieve information about
9
+ skills, workflows, and projects that the user is viewing on the dashboard.
10
+
11
+ Environment variables (inherited from minion server):
12
+ HQ_URL - HQ server URL (e.g., https://minion-agent.com)
13
+ API_TOKEN - Minion API token for authentication
14
+
15
+ .EXAMPLE
16
+ .\hq.ps1 fetch skill <name>
17
+ .\hq.ps1 fetch workflow <name>
18
+ .\hq.ps1 fetch project <id>
19
+ .\hq.ps1 fetch project-context <id>
20
+ #>
21
+
22
+ param(
23
+ [Parameter(Position = 0)]
24
+ [string]$Command,
25
+
26
+ [Parameter(Position = 1)]
27
+ [string]$Resource,
28
+
29
+ [Parameter(Position = 2)]
30
+ [string]$Identifier
31
+ )
32
+
33
+ $ErrorActionPreference = 'Stop'
34
+
35
+ # Validate required environment variables
36
+ if (-not $env:HQ_URL) {
37
+ Write-Error "Error: HQ_URL is not set"
38
+ exit 1
39
+ }
40
+ if (-not $env:API_TOKEN) {
41
+ Write-Error "Error: API_TOKEN is not set"
42
+ exit 1
43
+ }
44
+
45
+ $BaseUrl = "$($env:HQ_URL)/api/minion"
46
+ $Headers = @{ 'Authorization' = "Bearer $($env:API_TOKEN)" }
47
+
48
+ function Invoke-HqApi {
49
+ param([string]$Url)
50
+
51
+ try {
52
+ $response = Invoke-RestMethod -Uri $Url -Headers $Headers -Method Get -ErrorAction Stop
53
+ $response | ConvertTo-Json -Depth 10
54
+ }
55
+ catch {
56
+ $statusCode = $_.Exception.Response.StatusCode.value__
57
+ Write-Error "Error: HQ API returned HTTP $statusCode"
58
+ Write-Error $_.ErrorDetails.Message
59
+ exit 1
60
+ }
61
+ }
62
+
63
+ switch ($Command) {
64
+ 'fetch' {
65
+ if (-not $Resource -or -not $Identifier) {
66
+ Write-Error "Usage: hq fetch {skill|workflow|project|project-context} <identifier>"
67
+ exit 1
68
+ }
69
+
70
+ switch ($Resource) {
71
+ 'skill' {
72
+ Invoke-HqApi "$BaseUrl/skills/$Identifier"
73
+ }
74
+ 'workflow' {
75
+ Invoke-HqApi "$BaseUrl/workflows/$Identifier"
76
+ }
77
+ 'project' {
78
+ $response = Invoke-RestMethod -Uri "$BaseUrl/me/projects" -Headers $Headers -Method Get
79
+ $project = $response.projects | Where-Object { $_.id -eq $Identifier }
80
+ if ($project) {
81
+ $project | ConvertTo-Json -Depth 10
82
+ }
83
+ else {
84
+ Write-Error "Project not found: $Identifier"
85
+ exit 1
86
+ }
87
+ }
88
+ 'project-context' {
89
+ Invoke-HqApi "$BaseUrl/me/project/$Identifier/context"
90
+ }
91
+ default {
92
+ Write-Error "Unknown resource: $Resource"
93
+ Write-Error "Usage: hq fetch {skill|workflow|project|project-context} <identifier>"
94
+ exit 1
95
+ }
96
+ }
97
+ }
98
+ default {
99
+ Write-Host "HQ API helper for minion chat" -ForegroundColor Cyan
100
+ Write-Host ""
101
+ Write-Host "Usage:"
102
+ Write-Host " hq fetch skill <name> - Get skill details"
103
+ Write-Host " hq fetch workflow <name> - Get workflow details"
104
+ Write-Host " hq fetch project <id> - Get project info"
105
+ Write-Host " hq fetch project-context <id> - Get project context"
106
+ exit 1
107
+ }
108
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Node.js wrapper to launch minion-cli.ps1 via powershell.exe
3
+ // npm's cmd-shim cannot execute .ps1 files directly from cmd.exe because
4
+ // #Requires is not recognized as a shebang. This wrapper ensures the CLI
5
+ // works from any shell (cmd.exe, PowerShell, Git Bash).
6
+ const { spawn } = require('child_process');
7
+ const path = require('path');
8
+
9
+ const child = spawn('powershell.exe', [
10
+ '-ExecutionPolicy', 'Bypass',
11
+ '-NoLogo',
12
+ '-File', path.join(__dirname, '..', 'minion-cli.ps1'),
13
+ ...process.argv.slice(2)
14
+ ], { stdio: 'inherit' });
15
+
16
+ child.on('exit', (code) => process.exit(code || 0));
17
+ child.on('error', (err) => {
18
+ console.error('Failed to start PowerShell:', err.message);
19
+ process.exit(1);
20
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Windows LLM Service authentication checker
3
+ *
4
+ * Same logic as lib/llm-checker.js but with Windows-compatible paths
5
+ * and PATH separator handling.
6
+ */
7
+
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const { execSync } = require('child_process')
11
+ const { config } = require('../../core/config')
12
+ const { buildExtendedPath } = require('../../core/lib/platform')
13
+
14
+ const CACHE_TTL_MS = 60000
15
+ let cachedResult = null
16
+ let cachedAt = 0
17
+
18
+ function isClaudeAuthenticated() {
19
+ const candidates = [
20
+ path.join(config.HOME_DIR, '.claude', '.credentials.json'),
21
+ path.join(config.HOME_DIR, '.claude', 'credentials.json'),
22
+ ]
23
+ for (const p of candidates) {
24
+ try {
25
+ if (fs.existsSync(p)) {
26
+ const content = fs.readFileSync(p, 'utf-8')
27
+ const parsed = JSON.parse(content)
28
+ if (parsed && Object.keys(parsed).length > 0) return true
29
+ }
30
+ } catch { /* not authenticated */ }
31
+ }
32
+
33
+ try {
34
+ const claudePath = path.join(config.HOME_DIR, '.local', 'bin', 'claude')
35
+ const claudeBin = fs.existsSync(claudePath) ? claudePath : 'claude'
36
+ execSync(`${claudeBin} auth whoami`, {
37
+ encoding: 'utf-8',
38
+ timeout: 5000,
39
+ stdio: 'pipe',
40
+ env: {
41
+ ...process.env,
42
+ HOME: config.HOME_DIR,
43
+ USERPROFILE: config.HOME_DIR,
44
+ PATH: buildExtendedPath(config.HOME_DIR),
45
+ },
46
+ })
47
+ return true
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ function isGeminiAuthenticated() {
54
+ if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return true
55
+
56
+ const possiblePaths = [
57
+ path.join(config.HOME_DIR, '.config', 'gemini'),
58
+ path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
59
+ // Windows-specific locations
60
+ path.join(config.HOME_DIR, 'AppData', 'Roaming', 'gcloud', 'application_default_credentials.json'),
61
+ ]
62
+ for (const p of possiblePaths) {
63
+ try {
64
+ if (!fs.existsSync(p)) continue
65
+ const stat = fs.statSync(p)
66
+ if (stat.isDirectory()) {
67
+ if (fs.readdirSync(p).length > 0) return true
68
+ } else {
69
+ if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
70
+ }
71
+ } catch { /* ignore */ }
72
+ }
73
+ return false
74
+ }
75
+
76
+ function isCodexAuthenticated() {
77
+ if (process.env.OPENAI_API_KEY) return true
78
+ const codexConfig = path.join(config.HOME_DIR, '.codex')
79
+ try {
80
+ if (fs.existsSync(codexConfig) && fs.statSync(codexConfig).isDirectory()) {
81
+ if (fs.readdirSync(codexConfig).length > 0) return true
82
+ }
83
+ } catch { /* ignore */ }
84
+ return false
85
+ }
86
+
87
+ const SERVICE_DEFINITIONS = [
88
+ { name: 'claude', display_name: 'Claude Code', check: isClaudeAuthenticated },
89
+ { name: 'gemini', display_name: 'Gemini CLI', check: isGeminiAuthenticated },
90
+ { name: 'codex', display_name: 'Codex', check: isCodexAuthenticated },
91
+ ]
92
+
93
+ function isLlmCommandConfigured() {
94
+ return !!config.LLM_COMMAND
95
+ }
96
+
97
+ function getLlmServices() {
98
+ const now = Date.now()
99
+ if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
100
+ return cachedResult
101
+ }
102
+ const services = SERVICE_DEFINITIONS.map(({ name, display_name, check }) => ({
103
+ name, display_name, authenticated: check(),
104
+ }))
105
+ cachedResult = services
106
+ cachedAt = now
107
+ return services
108
+ }
109
+
110
+ function clearLlmCache() {
111
+ cachedResult = null
112
+ cachedAt = 0
113
+ }
114
+
115
+ module.exports = { getLlmServices, clearLlmCache, isLlmCommandConfigured }