@uzukko/agentpm 0.1.2 → 0.2.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,121 @@
1
+ /**
2
+ * Builtin manifest fallback.
3
+ * Used when: no cache, server unreachable, or CLI < minCliVersion.
4
+ * Contains a minimal subset of core commands for basic operation.
5
+ */
6
+ import type { Manifest } from './manifest-validator.js'
7
+
8
+ const spaceOpt = {
9
+ flags: '-s, --space-id <uuid>',
10
+ description: 'Space UUID',
11
+ param: 'spaceId',
12
+ resolve: 'spaceId' as const,
13
+ }
14
+
15
+ export const BUILTIN_MANIFEST: Manifest = {
16
+ version: '0.0.0-builtin',
17
+ minCliVersion: '0.0.0',
18
+ generatedAt: '1970-01-01T00:00:00Z',
19
+ checksum: '',
20
+ commands: [
21
+ {
22
+ name: 'task',
23
+ description: 'Task management',
24
+ subcommands: [
25
+ {
26
+ name: 'list',
27
+ description: 'List tasks',
28
+ tool: 'task_list',
29
+ options: [
30
+ spaceOpt,
31
+ { flags: '--ball <side>', description: 'Filter: client|internal', param: 'ball' },
32
+ { flags: '--status <status>', description: 'Filter by status', param: 'status' },
33
+ { flags: '--limit <n>', description: 'Max results', param: 'limit', type: 'int', default: '50' },
34
+ ],
35
+ },
36
+ {
37
+ name: 'get',
38
+ description: 'Get task details',
39
+ tool: 'task_get',
40
+ options: [
41
+ spaceOpt,
42
+ { flags: '--task-id <uuid>', description: 'Task UUID', param: 'taskId', required: true },
43
+ ],
44
+ },
45
+ {
46
+ name: 'create',
47
+ description: 'Create a task',
48
+ tool: 'task_create',
49
+ options: [
50
+ spaceOpt,
51
+ { flags: '--title <title>', description: 'Task title', param: 'title', required: true },
52
+ { flags: '--description <desc>', description: 'Description', param: 'description' },
53
+ { flags: '--type <type>', description: 'task|spec', param: 'type', default: 'task' },
54
+ { flags: '--ball <side>', description: 'client|internal', param: 'ball', default: 'internal' },
55
+ ],
56
+ },
57
+ {
58
+ name: 'update',
59
+ description: 'Update a task',
60
+ tool: 'task_update',
61
+ options: [
62
+ spaceOpt,
63
+ { flags: '--task-id <uuid>', description: 'Task UUID', param: 'taskId', required: true },
64
+ { flags: '--title <title>', description: 'New title', param: 'title' },
65
+ { flags: '--status <status>', description: 'New status', param: 'status' },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ {
71
+ name: 'ball',
72
+ description: 'Ball ownership management',
73
+ subcommands: [
74
+ {
75
+ name: 'pass',
76
+ description: 'Pass ball ownership',
77
+ tool: 'ball_pass',
78
+ options: [
79
+ spaceOpt,
80
+ { flags: '--task-id <uuid>', description: 'Task UUID', param: 'taskId', required: true },
81
+ { flags: '--ball <side>', description: 'New owner', param: 'ball', required: true },
82
+ ],
83
+ },
84
+ {
85
+ name: 'query',
86
+ description: 'Query tasks by ball side',
87
+ tool: 'ball_query',
88
+ options: [
89
+ spaceOpt,
90
+ { flags: '--ball <side>', description: 'Ball side', param: 'ball', required: true },
91
+ { flags: '--limit <n>', description: 'Max results', param: 'limit', type: 'int', default: '50' },
92
+ ],
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ name: 'dashboard',
98
+ description: 'Get project dashboard',
99
+ tool: 'dashboard_get',
100
+ options: [spaceOpt],
101
+ },
102
+ {
103
+ name: 'space',
104
+ description: 'Space management',
105
+ subcommands: [
106
+ {
107
+ name: 'list',
108
+ description: 'List spaces',
109
+ tool: 'space_list',
110
+ options: [],
111
+ },
112
+ {
113
+ name: 'get',
114
+ description: 'Get space details',
115
+ tool: 'space_get',
116
+ options: [spaceOpt],
117
+ },
118
+ ],
119
+ },
120
+ ],
121
+ }
@@ -40,22 +40,27 @@ export function registerConfigCommand(program: Command): void {
40
40
 
41
41
  console.error('AgentPM CLI Login')
42
42
  console.error('─'.repeat(40))
43
- console.error('Get your API Key from: Settings → APIキー管理\n')
44
43
 
45
44
  const config: Record<string, string> = { ...existing }
46
45
 
46
+ const apiUrl = await prompt('API URL', existing.apiUrl || undefined)
47
+ if (apiUrl) config.apiUrl = apiUrl
48
+
49
+ console.error('\nPaste your API Key from Web UI → Settings → API Keys:')
47
50
  const apiKey = await prompt('API Key', existing.apiKey ? maskSecret(existing.apiKey) : undefined)
48
51
  if (apiKey && !apiKey.includes('...')) {
49
52
  config.apiKey = apiKey
50
53
  }
51
54
 
52
- if (!config.apiUrl) {
53
- config.apiUrl = 'https://agentpm.app'
54
- }
55
+ const defaultSpaceId = await prompt('Default Space ID (optional, press Enter to skip)', existing.defaultSpaceId)
56
+ if (defaultSpaceId) config.defaultSpaceId = defaultSpaceId
55
57
 
56
58
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
57
59
  console.error(`\n✓ Config saved to ${configPath}`)
58
- console.error('✓ Run `agentpm space list` to verify your setup')
60
+
61
+ if (config.apiKey || existing.apiKey) {
62
+ console.error('✓ Run `agentpm space list` to verify your setup')
63
+ }
59
64
  })
60
65
 
61
66
  // agentpm config — advanced config management
package/src/config.ts CHANGED
@@ -11,6 +11,17 @@ interface CliConfig {
11
11
  defaultSpaceId?: string
12
12
  }
13
13
 
14
+ export class ConfigError extends Error {
15
+ constructor(message: string) {
16
+ super(message)
17
+ this.name = 'ConfigError'
18
+ }
19
+ }
20
+
21
+ // Store resolved config for getApiConfig()
22
+ let _resolvedApiUrl = ''
23
+ let _resolvedApiKey = ''
24
+
14
25
  export function getConfigPath(): string {
15
26
  return CONFIG_PATH
16
27
  }
@@ -28,15 +39,16 @@ export function loadCliConfig(cliOpts: {
28
39
  }
29
40
  }
30
41
 
31
- // Priority: CLI flags > env vars > config file > default
32
- const apiUrl = process.env.TASKAPP_API_URL || fileConfig.apiUrl || 'https://agentpm.app'
42
+ // Priority: CLI flags > env vars > config file
43
+ const apiUrl = process.env.TASKAPP_API_URL || fileConfig.apiUrl
33
44
  const apiKey = cliOpts.apiKey || process.env.TASKAPP_API_KEY || fileConfig.apiKey
34
45
 
35
- if (!apiKey) {
36
- console.error('Error: Not configured. Run: agentpm login')
37
- process.exit(1)
46
+ if (!apiUrl || !apiKey) {
47
+ throw new ConfigError('Not configured. Run: agentpm login')
38
48
  }
39
49
 
50
+ _resolvedApiUrl = apiUrl
51
+ _resolvedApiKey = apiKey
40
52
  setApiConfig(apiUrl, apiKey)
41
53
 
42
54
  // Set space ID for resolveSpaceId
@@ -46,6 +58,10 @@ export function loadCliConfig(cliOpts: {
46
58
  }
47
59
  }
48
60
 
61
+ export function getApiConfig(): { apiUrl: string; apiKey: string } {
62
+ return { apiUrl: _resolvedApiUrl, apiKey: _resolvedApiKey }
63
+ }
64
+
49
65
  export function resolveSpaceId(opts: { spaceId?: string }): string {
50
66
  const spaceId = opts.spaceId || process.env.TASKAPP_SPACE_ID
51
67
  if (!spaceId) {
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Dynamic command registration from manifest JSON.
3
+ * Converts manifest definitions → Commander.js commands at runtime.
4
+ */
5
+ import { Command, Option } from 'commander'
6
+ import { resolveSpaceId } from './config.js'
7
+ import { callTool } from './api-client.js'
8
+ import { output, outputError } from './output.js'
9
+ import { sanitize } from './manifest-validator.js'
10
+ import type {
11
+ Manifest,
12
+ ManifestCommand,
13
+ ManifestSubcommand,
14
+ ManifestOption,
15
+ } from './manifest-validator.js'
16
+ import chalk from 'chalk'
17
+
18
+ /**
19
+ * Extract the long flag name from a flags string.
20
+ * e.g., "-s, --space-id <uuid>" → "space-id"
21
+ * "--task-id <uuid>" → "task-id"
22
+ * "--no-dry-run" → "no-dry-run"
23
+ */
24
+ function extractLongFlag(flags: string): string {
25
+ const match = flags.match(/--([a-z][a-z0-9-]*)/)
26
+ return match ? match[1] : ''
27
+ }
28
+
29
+ /**
30
+ * Convert kebab-case to camelCase (Commander.js convention).
31
+ * e.g., "space-id" → "spaceId", "no-dry-run" → "noDryRun"
32
+ */
33
+ function camelCase(str: string): string {
34
+ return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
35
+ }
36
+
37
+ /**
38
+ * Convert option value based on type definition.
39
+ */
40
+ function convertType(value: string | boolean, type?: string): unknown {
41
+ if (type === 'bool' || type === 'negatable') {
42
+ return typeof value === 'boolean' ? value : value === 'true'
43
+ }
44
+ if (typeof value !== 'string') return value
45
+ switch (type) {
46
+ case 'int': {
47
+ const n = parseInt(value, 10)
48
+ if (Number.isNaN(n)) throw new Error(`Invalid integer: ${value}`)
49
+ return n
50
+ }
51
+ case 'float': {
52
+ const n = parseFloat(value)
53
+ if (Number.isNaN(n)) throw new Error(`Invalid number: ${value}`)
54
+ return n
55
+ }
56
+ case 'json':
57
+ return JSON.parse(value)
58
+ default:
59
+ return value
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build API params from Commander options + manifest option definitions.
65
+ */
66
+ function buildParams(
67
+ optionDefs: ManifestOption[],
68
+ opts: Record<string, unknown>,
69
+ ): Record<string, unknown> {
70
+ const params: Record<string, unknown> = {}
71
+
72
+ for (const def of optionDefs) {
73
+ // Determine the Commander key for this option
74
+ const longFlag = extractLongFlag(def.flags)
75
+ const key = camelCase(longFlag)
76
+
77
+ // For negatable options (--no-xxx), Commander stores as the positive key
78
+ // e.g., --no-dry-run → opts.dryRun = false, --no-include-invites → opts.includeInvites = false
79
+ let value = opts[key]
80
+
81
+ // Skip stdin pseudo-option (handled separately)
82
+ if (def.param === 'stdin') continue
83
+
84
+ if (value === undefined) continue
85
+
86
+ // Special resolver: spaceId
87
+ if (def.resolve === 'spaceId') {
88
+ params[def.param] = resolveSpaceId(opts as { spaceId?: string })
89
+ continue
90
+ }
91
+
92
+ // Type conversion
93
+ if (def.type === 'string[]') {
94
+ // Commander already provides arrays for variadic options
95
+ params[def.param] = Array.isArray(value) ? value : [value]
96
+ } else if (def.type === 'negatable') {
97
+ // Commander stores boolean for --no-xxx
98
+ params[def.param] = value
99
+ } else {
100
+ params[def.param] = convertType(value as string, def.type)
101
+ }
102
+ }
103
+
104
+ return params
105
+ }
106
+
107
+ /**
108
+ * Resolve a constraint key to a Commander opts key.
109
+ * Handles negatable flags: "no-dry-run" → Commander stores as "dryRun" (boolean false).
110
+ */
111
+ function resolveConstraintKey(
112
+ flagRef: string,
113
+ optionDefs: ManifestOption[],
114
+ ): { key: string; isNegatable: boolean } {
115
+ // Check if flagRef references a negatable option (--no-xxx)
116
+ if (flagRef.startsWith('no-')) {
117
+ const positiveFlag = flagRef.slice(3) // "no-dry-run" → "dry-run"
118
+ const positiveKey = camelCase(positiveFlag) // "dry-run" → "dryRun"
119
+ // Check if the option is actually negatable
120
+ const matchingOpt = optionDefs.find(
121
+ (o) => o.type === 'negatable' && extractLongFlag(o.flags) === `no-${positiveFlag}`,
122
+ )
123
+ if (matchingOpt) {
124
+ return { key: positiveKey, isNegatable: true }
125
+ }
126
+ }
127
+ return { key: camelCase(flagRef), isNegatable: false }
128
+ }
129
+
130
+ /**
131
+ * Validate dependsOn / conflictsWith constraints.
132
+ * Returns error message or null if valid.
133
+ */
134
+ function validateConstraints(
135
+ optionDefs: ManifestOption[],
136
+ opts: Record<string, unknown>,
137
+ ): string | null {
138
+ for (const def of optionDefs) {
139
+ const selfKey = camelCase(extractLongFlag(def.flags))
140
+ if (opts[selfKey] === undefined) continue
141
+
142
+ // dependsOn: requires another option
143
+ if (def.dependsOn) {
144
+ const { key: depKey, isNegatable } = resolveConstraintKey(def.dependsOn, optionDefs)
145
+ // For negatable: "dryRun" will be false when --no-dry-run is passed
146
+ const depPresent = isNegatable
147
+ ? opts[depKey] === false // --no-xxx explicitly passed
148
+ : opts[depKey] !== undefined
149
+ if (!depPresent) {
150
+ return `--${extractLongFlag(def.flags)} requires --${def.dependsOn}`
151
+ }
152
+ }
153
+
154
+ // conflictsWith: mutually exclusive
155
+ if (def.conflictsWith) {
156
+ const { key: conflictKey, isNegatable } = resolveConstraintKey(def.conflictsWith, optionDefs)
157
+ const conflictPresent = isNegatable
158
+ ? opts[conflictKey] === false
159
+ : opts[conflictKey] !== undefined
160
+ if (conflictPresent) {
161
+ return `--${extractLongFlag(def.flags)} conflicts with --${def.conflictsWith}`
162
+ }
163
+ }
164
+ }
165
+
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * Read stdin as JSON (for scheduling create/respond).
171
+ */
172
+ async function readStdin(): Promise<Record<string, unknown>> {
173
+ const chunks: Buffer[] = []
174
+ for await (const chunk of process.stdin) {
175
+ chunks.push(chunk as Buffer)
176
+ }
177
+ return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
178
+ }
179
+
180
+ /**
181
+ * Create an action handler for a subcommand.
182
+ */
183
+ function createAction(
184
+ sub: ManifestSubcommand,
185
+ program: Command,
186
+ ): (opts: Record<string, unknown>) => Promise<void> {
187
+ return async (opts: Record<string, unknown>) => {
188
+ const jsonMode = program.opts().json
189
+
190
+ // Deprecation warning
191
+ if (sub.deprecated) {
192
+ console.error(chalk.yellow(`Warning: "${sub.name}" is deprecated`))
193
+ }
194
+
195
+ // Constraint validation
196
+ const error = validateConstraints(sub.options, opts)
197
+ if (error) {
198
+ outputError(new Error(error), jsonMode)
199
+ return
200
+ }
201
+
202
+ try {
203
+ // stdin mode (scheduling create/respond)
204
+ if (sub.stdinMode && opts.stdin) {
205
+ const stdinParams = await readStdin()
206
+ // Merge CLI options into stdin params (CLI takes precedence for spaceId)
207
+ const spaceOpt = sub.options.find((o) => o.resolve === 'spaceId')
208
+ if (spaceOpt && !stdinParams.spaceId) {
209
+ try {
210
+ stdinParams.spaceId = resolveSpaceId(opts as { spaceId?: string })
211
+ } catch {
212
+ // spaceId not required for all commands
213
+ }
214
+ }
215
+ // Merge other required CLI options
216
+ for (const def of sub.options) {
217
+ if (def.param === 'stdin') continue
218
+ const key = camelCase(extractLongFlag(def.flags))
219
+ if (opts[key] !== undefined && stdinParams[def.param] === undefined) {
220
+ stdinParams[def.param] = opts[key]
221
+ }
222
+ }
223
+ const result = await callTool(sub.tool, stdinParams)
224
+ output(result, jsonMode)
225
+ return
226
+ }
227
+
228
+ // stdin required but not provided
229
+ if (sub.stdinMode && !opts.stdin) {
230
+ console.error(`Error: ${sub.name} requires --stdin with JSON input.`)
231
+ console.error(`Example: echo '{"key":"value"}' | agentpm ... --stdin`)
232
+ process.exit(1)
233
+ }
234
+
235
+ // Normal mode
236
+ const params = buildParams(sub.options, opts)
237
+ const result = await callTool(sub.tool, params)
238
+ output(result, jsonMode)
239
+ } catch (e) {
240
+ outputError(e, jsonMode)
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Register a single option on a Commander command.
247
+ */
248
+ function registerOption(cmd: Command, opt: ManifestOption): void {
249
+ const desc = sanitize(opt.description || '')
250
+ const option = new Option(opt.flags, desc)
251
+ if (opt.required) option.makeOptionMandatory(true)
252
+ if (opt.default !== undefined) option.default(opt.default)
253
+ if (opt.choices) option.choices(opt.choices)
254
+ cmd.addOption(option)
255
+ }
256
+
257
+ /**
258
+ * Register a subcommand on a parent command group.
259
+ */
260
+ function registerSubcommand(
261
+ parent: Command,
262
+ sub: ManifestSubcommand,
263
+ program: Command,
264
+ ): void {
265
+ const subCmd = parent.command(sub.name).description(sanitize(sub.description))
266
+
267
+ if (sub.aliases) {
268
+ for (const alias of sub.aliases) subCmd.alias(alias)
269
+ }
270
+ if (sub.hidden) (subCmd as Command & { hideHelp: (h?: boolean) => Command }).hideHelp()
271
+ if (sub.examples?.length) {
272
+ subCmd.addHelpText(
273
+ 'after',
274
+ '\nExamples:\n' + sub.examples.map((e) => ` $ ${sanitize(e)}`).join('\n'),
275
+ )
276
+ }
277
+
278
+ for (const opt of sub.options) {
279
+ registerOption(subCmd, opt)
280
+ }
281
+
282
+ subCmd.action(createAction(sub, program))
283
+ }
284
+
285
+ /**
286
+ * Register all commands from a manifest onto the program.
287
+ */
288
+ export function registerDynamicCommands(
289
+ program: Command,
290
+ manifest: Manifest,
291
+ ): void {
292
+ for (const cmd of manifest.commands) {
293
+ // Top-level command with direct tool (e.g., dashboard)
294
+ if (cmd.tool && !cmd.subcommands) {
295
+ const topCmd = program.command(cmd.name).description(sanitize(cmd.description))
296
+ if (cmd.aliases) {
297
+ for (const alias of cmd.aliases) topCmd.alias(alias)
298
+ }
299
+ if (cmd.options) {
300
+ for (const opt of cmd.options) {
301
+ registerOption(topCmd, opt)
302
+ }
303
+ }
304
+ // Create a pseudo-subcommand definition for the action handler
305
+ const pseudoSub: ManifestSubcommand = {
306
+ name: cmd.name,
307
+ description: cmd.description,
308
+ tool: cmd.tool,
309
+ options: cmd.options || [],
310
+ }
311
+ topCmd.action(createAction(pseudoSub, program))
312
+ continue
313
+ }
314
+
315
+ // Command group with subcommands
316
+ if (cmd.subcommands) {
317
+ const group = program.command(cmd.name).description(sanitize(cmd.description))
318
+ if (cmd.aliases) {
319
+ for (const alias of cmd.aliases) group.alias(alias)
320
+ }
321
+ for (const sub of cmd.subcommands) {
322
+ registerSubcommand(group, sub, program)
323
+ }
324
+ }
325
+ }
326
+ }
package/src/index.ts CHANGED
@@ -1,51 +1,98 @@
1
1
  import { Command } from 'commander'
2
- import { loadCliConfig } from './config.js'
3
- import { registerTaskCommands } from './commands/task.js'
4
- import { registerBallCommands } from './commands/ball.js'
5
- import { registerMeetingCommands } from './commands/meeting.js'
6
- import { registerReviewCommands } from './commands/review.js'
7
- import { registerMilestoneCommands } from './commands/milestone.js'
8
- import { registerSpaceCommands } from './commands/space.js'
9
- import { registerActivityCommands } from './commands/activity.js'
10
- import { registerClientCommands } from './commands/client.js'
11
- import { registerWikiCommands } from './commands/wiki.js'
12
- import { registerMinutesCommands } from './commands/minutes.js'
13
- import { registerSchedulingCommands } from './commands/scheduling.js'
2
+ import { loadCliConfig, getApiConfig, ConfigError } from './config.js'
14
3
  import { registerConfigCommand } from './commands/config-cmd.js'
4
+ import { registerDynamicCommands } from './dynamic-loader.js'
5
+ import { loadManifest, forceUpdate } from './manifest-cache.js'
6
+ import chalk from 'chalk'
7
+
8
+ const CLI_VERSION = '0.2.0'
15
9
 
16
10
  const program = new Command()
17
11
 
18
12
  program
19
13
  .name('agentpm')
20
- .version('0.1.0')
14
+ .version(CLI_VERSION)
21
15
  .description('AgentPM CLI - AI-first task management')
22
16
  .option('--json', 'Output raw JSON')
23
17
  .option('-s, --space-id <uuid>', 'Override default space ID')
24
18
  .option('--api-key <key>', 'Override API key')
25
19
  .hook('preAction', (thisCommand, actionCommand) => {
26
- // Walk up to find the root command name (handles nested subcommands)
20
+ // Walk up to find the root command name
27
21
  let cmd = actionCommand
28
22
  while (cmd.parent && cmd.parent !== thisCommand) {
29
23
  cmd = cmd.parent
30
24
  }
31
- // config/login subcommands don't need auth
32
- if (cmd.name() === 'config' || cmd.name() === 'login') return
25
+ // config/login/update don't need auth
26
+ const name = cmd.name()
27
+ if (name === 'config' || name === 'login' || name === 'update') return
33
28
 
34
29
  const opts = thisCommand.opts()
35
- loadCliConfig({ apiKey: opts.apiKey, spaceId: opts.spaceId })
30
+ try {
31
+ loadCliConfig({ apiKey: opts.apiKey, spaceId: opts.spaceId })
32
+ } catch (e) {
33
+ if (e instanceof ConfigError) {
34
+ console.error(chalk.red(`Error: ${e.message}`))
35
+ process.exit(1)
36
+ }
37
+ throw e
38
+ }
36
39
  })
37
40
 
38
- registerTaskCommands(program)
39
- registerBallCommands(program)
40
- registerMeetingCommands(program)
41
- registerReviewCommands(program)
42
- registerMilestoneCommands(program)
43
- registerSpaceCommands(program)
44
- registerActivityCommands(program)
45
- registerClientCommands(program)
46
- registerWikiCommands(program)
47
- registerMinutesCommands(program)
48
- registerSchedulingCommands(program)
41
+ // ── Always-builtin commands (needed before auth) ──
49
42
  registerConfigCommand(program)
50
43
 
51
- program.parseAsync()
44
+ // ── Update command (force-fetch manifest) ──
45
+ program
46
+ .command('update')
47
+ .description('Fetch latest command manifest from server')
48
+ .action(async () => {
49
+ const opts = program.opts()
50
+ loadCliConfig({ apiKey: opts.apiKey, spaceId: opts.spaceId })
51
+ const { apiUrl, apiKey } = getApiConfig()
52
+ try {
53
+ const manifest = await forceUpdate(apiUrl, apiKey)
54
+ console.log(chalk.green(`Updated to manifest v${manifest.version}`))
55
+ console.log(chalk.gray(`${manifest.commands.length} command groups, ` +
56
+ `${manifest.commands.reduce((n, c) => n + (c.subcommands?.length || 1), 0)} commands`))
57
+ } catch (e) {
58
+ const msg = e instanceof Error ? e.message : String(e)
59
+ console.error(chalk.red(`Update failed: ${msg}`))
60
+ process.exit(1)
61
+ }
62
+ })
63
+
64
+ // ── Dynamic command registration ──
65
+ async function main() {
66
+ // Try to load config for manifest fetch (non-fatal if not configured)
67
+ let apiUrl = ''
68
+ let apiKey: string | undefined
69
+ try {
70
+ const opts = program.opts()
71
+ // Parse known options without executing actions
72
+ const rawArgs = process.argv.slice(2)
73
+ const apiKeyIdx = rawArgs.indexOf('--api-key')
74
+ const passedApiKey = apiKeyIdx >= 0 ? rawArgs[apiKeyIdx + 1] : undefined
75
+ loadCliConfig({ apiKey: passedApiKey, spaceId: opts.spaceId })
76
+ const config = getApiConfig()
77
+ apiUrl = config.apiUrl
78
+ apiKey = config.apiKey
79
+ } catch {
80
+ // Not configured — will use builtin manifest
81
+ }
82
+
83
+ if (apiUrl) {
84
+ const manifest = await loadManifest(apiUrl, apiKey, CLI_VERSION)
85
+ registerDynamicCommands(program, manifest)
86
+ } else {
87
+ // No config — register builtin for --help to work
88
+ const { BUILTIN_MANIFEST } = await import('./builtin-manifest.js')
89
+ registerDynamicCommands(program, BUILTIN_MANIFEST)
90
+ }
91
+
92
+ await program.parseAsync()
93
+ }
94
+
95
+ main().catch((e) => {
96
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)))
97
+ process.exit(1)
98
+ })