@geekbeer/minion 3.17.0 → 3.22.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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Claude Code stream-json parser.
3
+ *
4
+ * Spawns `claude -p --verbose --output-format stream-json` and translates
5
+ * the CLI's event stream into normalized LlmEvent shapes consumed by
6
+ * chat routes. Extracted from linux/routes/chat.js streamLlmResponse().
7
+ */
8
+
9
+ const { spawn } = require('child_process')
10
+ const { config } = require('../../config')
11
+ const { IS_WINDOWS, buildExtendedPath } = require('../../lib/platform')
12
+ const { resolveBinary } = require('../lib/spawn-helper')
13
+
14
+ /**
15
+ * @param {import('../types').LlmInput} input
16
+ * @param {(event: any) => void} onEvent
17
+ * @param {{ resumeSessionId?: string | null, activeChildRef?: { current: any } }} [ctx]
18
+ * @returns {Promise<import('../types').LlmOutput>}
19
+ */
20
+ function streamClaude(input, onEvent, ctx = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const bin = resolveBinary('claude', config.HOME_DIR)
23
+ const started = Date.now()
24
+
25
+ const args = [
26
+ '-p',
27
+ '--verbose',
28
+ '--model', input.model || 'sonnet',
29
+ '--output-format', 'stream-json',
30
+ ]
31
+ if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId)
32
+
33
+ console.log(`[claude-plugin] spawn: ${bin} ${args.join(' ')} (resume=${ctx.resumeSessionId || 'new'})`)
34
+
35
+ const child = spawn(bin, args, {
36
+ cwd: config.HOME_DIR,
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ timeout: input.timeoutMs || 600000,
39
+ env: {
40
+ ...process.env,
41
+ HOME: config.HOME_DIR,
42
+ ...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
43
+ PATH: buildExtendedPath(config.HOME_DIR),
44
+ },
45
+ })
46
+
47
+ if (ctx.activeChildRef) ctx.activeChildRef.current = child
48
+
49
+ child.stdin.write(input.prompt)
50
+ child.stdin.end()
51
+
52
+ let fullText = ''
53
+ let stderrBuf = ''
54
+ let lineBuf = ''
55
+ let resolvedSessionId = ctx.resumeSessionId || null
56
+ let currentBlockType = null
57
+ let currentToolName = null
58
+ let toolInputBuffer = ''
59
+ let turnCount = 0
60
+
61
+ child.stdout.on('data', chunk => {
62
+ lineBuf += chunk.toString()
63
+ const parts = lineBuf.split('\n')
64
+ lineBuf = parts.pop() || ''
65
+ for (const line of parts) {
66
+ if (!line.trim()) continue
67
+ let parsed
68
+ try { parsed = JSON.parse(line) } catch { continue }
69
+
70
+ if (parsed.type === 'system' && parsed.session_id) {
71
+ resolvedSessionId = parsed.session_id
72
+ onEvent({ type: 'session', sessionId: resolvedSessionId })
73
+ }
74
+
75
+ if (parsed.type === 'content_block_start') {
76
+ const bt = parsed.content_block?.type
77
+ if (bt === 'tool_use') {
78
+ currentBlockType = 'tool_use'
79
+ currentToolName = parsed.content_block.name || 'unknown'
80
+ toolInputBuffer = ''
81
+ onEvent({ type: 'tool_start', tool: currentToolName })
82
+ } else if (bt === 'text') {
83
+ currentBlockType = 'text'
84
+ }
85
+ }
86
+
87
+ if (parsed.type === 'content_block_delta') {
88
+ const dt = parsed.delta?.type
89
+ if (dt === 'input_json_delta' && currentBlockType === 'tool_use') {
90
+ const partial = parsed.delta.partial_json || ''
91
+ if (partial) {
92
+ toolInputBuffer += partial
93
+ onEvent({ type: 'tool_input_delta', partial_json: partial })
94
+ }
95
+ } else {
96
+ const delta = parsed.delta?.text || ''
97
+ if (delta) {
98
+ fullText += delta
99
+ onEvent({ type: 'delta', content: delta })
100
+ }
101
+ }
102
+ }
103
+
104
+ if (parsed.type === 'content_block_stop') {
105
+ if (currentBlockType === 'tool_use') {
106
+ let parsedInput = null
107
+ try { if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer) } catch { /* ignore */ }
108
+ onEvent({ type: 'tool_end', tool: currentToolName, input: parsedInput })
109
+ }
110
+ currentBlockType = null
111
+ currentToolName = null
112
+ toolInputBuffer = ''
113
+ }
114
+
115
+ if (parsed.type === 'assistant' && parsed.message) {
116
+ turnCount++
117
+ for (const block of (parsed.message.content || [])) {
118
+ if (block.type === 'text') {
119
+ fullText += block.text
120
+ onEvent({ type: 'text', content: block.text })
121
+ }
122
+ }
123
+ } else if (parsed.type === 'result') {
124
+ const resultText = parsed.result || ''
125
+ if (resultText) {
126
+ onEvent({ type: 'result', content: resultText })
127
+ if (!fullText) fullText = resultText
128
+ }
129
+ }
130
+ }
131
+ })
132
+
133
+ child.stderr.on('data', chunk => {
134
+ stderrBuf += chunk.toString()
135
+ })
136
+
137
+ child.on('error', err => {
138
+ if (ctx.activeChildRef) ctx.activeChildRef.current = null
139
+ onEvent({ type: 'error', message: err.message })
140
+ reject(err)
141
+ })
142
+
143
+ child.on('close', code => {
144
+ if (ctx.activeChildRef) ctx.activeChildRef.current = null
145
+ const durationMs = Date.now() - started
146
+ if (code !== 0 && !fullText) {
147
+ const msg = stderrBuf.trim() || `claude CLI exited with code ${code}`
148
+ onEvent({ type: 'error', message: msg })
149
+ resolve({
150
+ text: '',
151
+ files: [],
152
+ metadata: { sessionId: resolvedSessionId || undefined, durationMs },
153
+ error: { code: `exit_${code}`, message: msg },
154
+ })
155
+ return
156
+ }
157
+ resolve({
158
+ text: fullText,
159
+ files: [],
160
+ metadata: { sessionId: resolvedSessionId || undefined, durationMs },
161
+ })
162
+ })
163
+ })
164
+ }
165
+
166
+ module.exports = { streamClaude }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Builtin plugin: Codex CLI (OpenAI)
3
+ */
4
+
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const { config } = require('../../config')
8
+ const { resolveBinary, spawnWithStdin } = require('../lib/spawn-helper')
9
+
10
+ const BINARY = 'codex'
11
+
12
+ async function isAuthenticated() {
13
+ if (process.env.OPENAI_API_KEY) return true
14
+ const codexDir = path.join(config.HOME_DIR, '.codex')
15
+ try {
16
+ if (fs.existsSync(codexDir) && fs.statSync(codexDir).isDirectory()) {
17
+ if (fs.readdirSync(codexDir).length > 0) return true
18
+ }
19
+ } catch {
20
+ // continue
21
+ }
22
+ return false
23
+ }
24
+
25
+ function buildArgs(input) {
26
+ const args = []
27
+ if (input.model) args.push('--model', input.model)
28
+ return args
29
+ }
30
+
31
+ async function invoke(input) {
32
+ const bin = resolveBinary(BINARY, config.HOME_DIR)
33
+ const { stdout, stderr, code, durationMs } = await spawnWithStdin({
34
+ binary: bin,
35
+ args: buildArgs(input),
36
+ prompt: input.prompt,
37
+ homeDir: config.HOME_DIR,
38
+ timeoutMs: input.timeoutMs,
39
+ })
40
+ if (code !== 0) {
41
+ return {
42
+ text: stdout,
43
+ files: [],
44
+ metadata: { durationMs },
45
+ error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'codex exited non-zero' },
46
+ }
47
+ }
48
+ return {
49
+ text: stdout.trim(),
50
+ files: [],
51
+ metadata: { durationMs },
52
+ }
53
+ }
54
+
55
+ function buildShellInvocation({ promptFile, model }) {
56
+ const bin = resolveBinary(BINARY, config.HOME_DIR)
57
+ const modelArg = model ? ` --model ${model}` : ''
58
+ return `${bin}${modelArg} < ${promptFile}`
59
+ }
60
+
61
+ function getSkillsDir() {
62
+ return path.join(config.HOME_DIR, '.codex', 'skills')
63
+ }
64
+
65
+ function formatSkillInvocation(skillName, args = []) {
66
+ const argStr = args.length ? ' ' + args.join(' ') : ''
67
+ return `/${skillName}${argStr}`
68
+ }
69
+
70
+ /**
71
+ * Codex stores MCP servers in ~/.codex/config.toml with the format:
72
+ * [mcp_servers.<name>]
73
+ * command = "..."
74
+ * args = ["..."]
75
+ *
76
+ * To avoid pulling in a TOML library we do targeted block replacement:
77
+ * locate `[mcp_servers.<name>]` (and its body up to the next `[` table or EOF)
78
+ * and replace it. If absent, append.
79
+ */
80
+ function configTomlPath() {
81
+ return path.join(config.HOME_DIR, '.codex', 'config.toml')
82
+ }
83
+
84
+ function renderTomlBlock(name, spec) {
85
+ const lines = [`[mcp_servers.${name}]`]
86
+ lines.push(`command = ${JSON.stringify(spec.command)}`)
87
+ if (Array.isArray(spec.args)) {
88
+ lines.push(`args = [${spec.args.map(a => JSON.stringify(a)).join(', ')}]`)
89
+ }
90
+ if (spec.env && typeof spec.env === 'object') {
91
+ for (const [k, v] of Object.entries(spec.env)) {
92
+ lines.push(`env.${k} = ${JSON.stringify(v)}`)
93
+ }
94
+ }
95
+ return lines.join('\n')
96
+ }
97
+
98
+ function addMcpServer(name, spec) {
99
+ const tomlPath = configTomlPath()
100
+ fs.mkdirSync(path.dirname(tomlPath), { recursive: true })
101
+
102
+ let content = ''
103
+ try { content = fs.readFileSync(tomlPath, 'utf-8') } catch { /* new file */ }
104
+
105
+ const block = renderTomlBlock(name, spec)
106
+ const headerRe = new RegExp(`^\\[mcp_servers\\.${name.replace(/[-]/g, '\\-')}\\]\\s*$`, 'm')
107
+ const match = content.match(headerRe)
108
+ let next
109
+ if (match) {
110
+ // Replace from the matched header through to the next [ at column 0 (or EOF)
111
+ const startIdx = match.index
112
+ const after = content.slice(startIdx + match[0].length)
113
+ const nextHeader = after.search(/\n\[[^\n]*\]\s*$/m)
114
+ const endIdx = nextHeader === -1 ? content.length : startIdx + match[0].length + nextHeader
115
+ next = content.slice(0, startIdx) + block + content.slice(endIdx)
116
+ } else {
117
+ const sep = content.length === 0 || content.endsWith('\n\n') ? '' : (content.endsWith('\n') ? '\n' : '\n\n')
118
+ next = content + sep + block + '\n'
119
+ }
120
+
121
+ const tmp = `${tomlPath}.tmp`
122
+ fs.writeFileSync(tmp, next, 'utf-8')
123
+ fs.renameSync(tmp, tomlPath)
124
+ }
125
+
126
+ function removeMcpServer(name) {
127
+ const tomlPath = configTomlPath()
128
+ let content
129
+ try { content = fs.readFileSync(tomlPath, 'utf-8') } catch { return }
130
+ const escaped = name.replace(/[-]/g, '\\-')
131
+ // Match the header and everything until the next [ table at column 0 or EOF
132
+ const headerRe = new RegExp(`(?:^|\\n)\\[mcp_servers\\.${escaped}\\][^\\n]*(?:\\n(?!\\[)[^\\n]*)*`, 'm')
133
+ const next = content.replace(headerRe, '').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '')
134
+ if (next !== content) {
135
+ const tmp = `${tomlPath}.tmp`
136
+ fs.writeFileSync(tmp, next, 'utf-8')
137
+ fs.renameSync(tmp, tomlPath)
138
+ }
139
+ }
140
+
141
+ /** @type {import('../types').LlmPlugin} */
142
+ const plugin = {
143
+ name: 'codex',
144
+ displayName: 'Codex',
145
+ capabilities: {
146
+ toolUse: true,
147
+ streaming: false,
148
+ vision: false,
149
+ imageGeneration: false,
150
+ sessionResume: false,
151
+ },
152
+ isAuthenticated,
153
+ invoke,
154
+ buildShellInvocation,
155
+ getSkillsDir,
156
+ formatSkillInvocation,
157
+ addMcpServer,
158
+ removeMcpServer,
159
+ }
160
+
161
+ module.exports = { plugin }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Builtin plugin: Gemini CLI
3
+ *
4
+ * Note: Gemini CLI flag set is fluid; this plugin targets the common
5
+ * invocation pattern (prompt via stdin, `-p -` to signal stdin input on some
6
+ * builds, or plain stdin on others). The prompt is always passed on stdin to
7
+ * avoid shell quoting issues.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { config } = require('../../config')
13
+ const { IS_WINDOWS } = require('../../lib/platform')
14
+ const { resolveBinary, spawnWithStdin } = require('../lib/spawn-helper')
15
+
16
+ const BINARY = 'gemini'
17
+
18
+ async function isAuthenticated() {
19
+ if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return true
20
+
21
+ // Official @google/gemini-cli stores OAuth credentials at ~/.gemini/oauth_creds.json
22
+ // after `gemini` login. Fall back to ADC locations for gcloud-based setups.
23
+ const candidates = [
24
+ path.join(config.HOME_DIR, '.gemini', 'oauth_creds.json'),
25
+ path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
26
+ ...(IS_WINDOWS ? [
27
+ path.join(config.HOME_DIR, 'AppData', 'Roaming', 'gcloud', 'application_default_credentials.json'),
28
+ ] : []),
29
+ ]
30
+ for (const p of candidates) {
31
+ try {
32
+ if (!fs.existsSync(p)) continue
33
+ if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
34
+ } catch {
35
+ // continue
36
+ }
37
+ }
38
+ return false
39
+ }
40
+
41
+ function buildArgs(input) {
42
+ const args = []
43
+ if (input.model) args.push('--model', input.model)
44
+ return args
45
+ }
46
+
47
+ async function invoke(input) {
48
+ const bin = resolveBinary(BINARY, config.HOME_DIR)
49
+ const { stdout, stderr, code, durationMs } = await spawnWithStdin({
50
+ binary: bin,
51
+ args: buildArgs(input),
52
+ prompt: input.prompt,
53
+ homeDir: config.HOME_DIR,
54
+ timeoutMs: input.timeoutMs,
55
+ })
56
+ if (code !== 0) {
57
+ return {
58
+ text: stdout,
59
+ files: [],
60
+ metadata: { durationMs },
61
+ error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'gemini exited non-zero' },
62
+ }
63
+ }
64
+ return {
65
+ text: stdout.trim(),
66
+ files: [],
67
+ metadata: { durationMs },
68
+ }
69
+ }
70
+
71
+ function buildShellInvocation({ promptFile, model }) {
72
+ const bin = resolveBinary(BINARY, config.HOME_DIR)
73
+ const modelArg = model ? ` --model ${model}` : ''
74
+ return `${bin}${modelArg} < ${promptFile}`
75
+ }
76
+
77
+ function getSkillsDir() {
78
+ return path.join(config.HOME_DIR, '.gemini', 'skills')
79
+ }
80
+
81
+ function formatSkillInvocation(skillName, args = []) {
82
+ const argStr = args.length ? ` with arguments: ${args.join(' ')}` : ''
83
+ return `Please use the "${skillName}" skill${argStr}.`
84
+ }
85
+
86
+ /** @type {import('../types').LlmPlugin} */
87
+ const plugin = {
88
+ name: 'gemini',
89
+ displayName: 'Gemini CLI',
90
+ capabilities: {
91
+ toolUse: true,
92
+ streaming: false,
93
+ vision: true,
94
+ imageGeneration: true,
95
+ sessionResume: false,
96
+ },
97
+ isAuthenticated,
98
+ invoke,
99
+ buildShellInvocation,
100
+ getSkillsDir,
101
+ formatSkillInvocation,
102
+ }
103
+
104
+ module.exports = { plugin }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Runtime helpers for resolving the currently active plugin.
3
+ *
4
+ * These helpers centralize the "plugin enabled vs legacy LLM_COMMAND" branch
5
+ * so callers (chat route, workflow-runner, routine-runner) share the same
6
+ * resolution rule.
7
+ */
8
+
9
+ const { readConfig, getPlugin } = require('../registry')
10
+
11
+ /**
12
+ * Resolve the Primary plugin if the plugin system is enabled, otherwise null.
13
+ * Caller can branch to LLM_COMMAND-legacy path when this returns null.
14
+ *
15
+ * @returns {import('../types').LlmPlugin | null}
16
+ */
17
+ function getActivePrimary() {
18
+ const cfg = readConfig()
19
+ if (!cfg.primary) return null
20
+ return getPlugin(cfg.primary)
21
+ }
22
+
23
+ module.exports = { getActivePrimary }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * MCP server registration helpers.
3
+ *
4
+ * Each LLM CLI stores MCP server configuration in a different file with a
5
+ * different format. This module hides that variation: callers ask a plugin to
6
+ * register a server, and the plugin (or a fallback here) writes to the
7
+ * appropriate location.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { config } = require('../../config')
13
+
14
+ const DISPATCH_SERVER_NAME = 'minion-llm-dispatch'
15
+
16
+ /**
17
+ * Build the spec for the dispatch MCP server used by Primary to reach children.
18
+ * Plugins register this via {@link registerDispatchServer}.
19
+ */
20
+ function buildDispatchServerSpec() {
21
+ const serverScript = path.join(__dirname, '..', '..', 'llm-dispatch', 'mcp-server.js')
22
+ return {
23
+ command: 'node',
24
+ args: [serverScript],
25
+ }
26
+ }
27
+
28
+ /**
29
+ * JSON-config MCP registration (used by Claude Code, Gemini CLI).
30
+ * Reads the target file, merges in the server entry under `mcpServers`,
31
+ * and writes back atomically.
32
+ *
33
+ * @param {string} configPath
34
+ * @param {string} serverName
35
+ * @param {{ command: string, args: string[] }} spec
36
+ */
37
+ function writeJsonMcpEntry(configPath, serverName, spec) {
38
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
39
+ let current = {}
40
+ try {
41
+ const raw = fs.readFileSync(configPath, 'utf-8')
42
+ current = JSON.parse(raw)
43
+ } catch {
44
+ // New file or unparseable — start fresh
45
+ }
46
+ if (!current.mcpServers || typeof current.mcpServers !== 'object') {
47
+ current.mcpServers = {}
48
+ }
49
+ current.mcpServers[serverName] = spec
50
+ const tmp = `${configPath}.tmp`
51
+ fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
52
+ fs.renameSync(tmp, configPath)
53
+ }
54
+
55
+ /**
56
+ * Remove a server entry from a JSON MCP config.
57
+ */
58
+ function removeJsonMcpEntry(configPath, serverName) {
59
+ try {
60
+ const raw = fs.readFileSync(configPath, 'utf-8')
61
+ const current = JSON.parse(raw)
62
+ if (current?.mcpServers && current.mcpServers[serverName]) {
63
+ delete current.mcpServers[serverName]
64
+ const tmp = `${configPath}.tmp`
65
+ fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
66
+ fs.renameSync(tmp, configPath)
67
+ }
68
+ } catch {
69
+ // File missing or unparseable — nothing to remove
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Register the dispatch MCP server with the given Primary plugin.
75
+ * No-op if the plugin does not implement addMcpServer() or when we don't
76
+ * know its MCP config location.
77
+ *
78
+ * @param {import('../types').LlmPlugin} plugin
79
+ */
80
+ function registerDispatchServer(plugin) {
81
+ const spec = buildDispatchServerSpec()
82
+ if (typeof plugin.addMcpServer === 'function') {
83
+ plugin.addMcpServer(DISPATCH_SERVER_NAME, spec)
84
+ return { registered: true, method: 'plugin' }
85
+ }
86
+ // Fallback: well-known locations by plugin name
87
+ const fallbackPath = defaultMcpConfigPath(plugin.name)
88
+ if (fallbackPath) {
89
+ writeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME, spec)
90
+ return { registered: true, method: 'fallback', path: fallbackPath }
91
+ }
92
+ return { registered: false }
93
+ }
94
+
95
+ /**
96
+ * Unregister the dispatch MCP server from all known plugin MCP configs.
97
+ * Used when Primary is cleared.
98
+ */
99
+ function unregisterDispatchServerFromAll(plugins) {
100
+ for (const p of plugins) {
101
+ if (typeof p.removeMcpServer === 'function') {
102
+ try { p.removeMcpServer(DISPATCH_SERVER_NAME) } catch { /* ignore */ }
103
+ continue
104
+ }
105
+ const fallbackPath = defaultMcpConfigPath(p.name)
106
+ if (fallbackPath) removeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME)
107
+ }
108
+ }
109
+
110
+ /** Best-known MCP config path for a given plugin name. Returns null if unknown. */
111
+ function defaultMcpConfigPath(pluginName) {
112
+ switch (pluginName) {
113
+ case 'claude':
114
+ // Claude Code reads ~/.mcp.json (global) and project-local .mcp.json
115
+ return path.join(config.HOME_DIR, '.mcp.json')
116
+ case 'gemini':
117
+ // Gemini CLI settings include mcpServers field
118
+ return path.join(config.HOME_DIR, '.gemini', 'settings.json')
119
+ default:
120
+ return null
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ DISPATCH_SERVER_NAME,
126
+ buildDispatchServerSpec,
127
+ writeJsonMcpEntry,
128
+ removeJsonMcpEntry,
129
+ registerDispatchServer,
130
+ unregisterDispatchServerFromAll,
131
+ defaultMcpConfigPath,
132
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Resolve skill directory paths across enabled LLM plugins.
3
+ *
4
+ * Each LLM CLI loads skills from its own directory:
5
+ * Claude → ~/.claude/skills/
6
+ * Gemini → ~/.gemini/skills/
7
+ * Codex → ~/.codex/skills/
8
+ *
9
+ * To make skill deployment LLM-agnostic, we write each skill to all enabled
10
+ * plugins' skill directories. Reads/deletes operate on the union.
11
+ */
12
+
13
+ const path = require('path')
14
+ const { config } = require('../../config')
15
+ const { readConfig, loadAllPlugins } = require('../registry')
16
+
17
+ /**
18
+ * Return the canonical skill directory paths for all enabled plugins.
19
+ * Falls back to ~/.claude/skills/ when the plugin system is not configured
20
+ * (preserves backward compatibility with the LLM_COMMAND era).
21
+ *
22
+ * @returns {string[]} Absolute directory paths
23
+ */
24
+ function getActiveSkillDirs() {
25
+ const cfg = readConfig()
26
+ const enabled = new Set(cfg.enabled)
27
+
28
+ if (enabled.size === 0) {
29
+ return [path.join(config.HOME_DIR, '.claude', 'skills')]
30
+ }
31
+
32
+ const plugins = loadAllPlugins()
33
+ const dirs = []
34
+ for (const name of enabled) {
35
+ const plugin = plugins.get(name)
36
+ if (plugin && typeof plugin.getSkillsDir === 'function') {
37
+ dirs.push(plugin.getSkillsDir())
38
+ }
39
+ }
40
+ // Safety: if no enabled plugin reported a dir, default to claude
41
+ if (dirs.length === 0) {
42
+ dirs.push(path.join(config.HOME_DIR, '.claude', 'skills'))
43
+ }
44
+ return dirs
45
+ }
46
+
47
+ /**
48
+ * Get the primary plugin's skill directory (used as the canonical
49
+ * read source when listing or pushing skills back to HQ).
50
+ *
51
+ * @returns {string} Absolute directory path
52
+ */
53
+ function getPrimarySkillDir() {
54
+ const cfg = readConfig()
55
+ if (cfg.primary) {
56
+ const plugins = loadAllPlugins()
57
+ const plugin = plugins.get(cfg.primary)
58
+ if (plugin && typeof plugin.getSkillsDir === 'function') {
59
+ return plugin.getSkillsDir()
60
+ }
61
+ }
62
+ // Fallback: first enabled plugin's dir, else claude
63
+ const dirs = getActiveSkillDirs()
64
+ return dirs[0]
65
+ }
66
+
67
+ module.exports = { getActiveSkillDirs, getPrimarySkillDir }