tissues 0.5.2 → 0.6.1

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 (40) hide show
  1. package/README.md +94 -40
  2. package/package.json +3 -4
  3. package/src/cli.js +26 -22
  4. package/src/commands/ai.js +268 -0
  5. package/src/commands/auth.js +4 -4
  6. package/src/commands/config.js +1035 -12
  7. package/src/commands/create.js +523 -157
  8. package/src/commands/drafts.js +288 -0
  9. package/src/commands/enhancements.js +282 -0
  10. package/src/commands/list.js +7 -5
  11. package/src/commands/status.js +81 -19
  12. package/src/commands/templates.js +157 -0
  13. package/src/lib/ai/adapters/anthropic.js +52 -0
  14. package/src/lib/ai/adapters/base.js +45 -0
  15. package/src/lib/ai/adapters/command.js +68 -0
  16. package/src/lib/ai/adapters/gemini.js +56 -0
  17. package/src/lib/ai/adapters/ollama.js +60 -0
  18. package/src/lib/ai/adapters/openai-compat.js +51 -0
  19. package/src/lib/ai/adapters/openai.js +44 -0
  20. package/src/lib/ai/body-template.js +75 -0
  21. package/src/lib/ai/enhance.js +107 -0
  22. package/src/lib/ai/enhancement-adapter.js +109 -0
  23. package/src/lib/ai/index.js +122 -0
  24. package/src/lib/ai/pipeline.js +97 -0
  25. package/src/lib/ai/prompt.js +39 -0
  26. package/src/lib/ai/router.js +216 -0
  27. package/src/lib/ai/steps.js +492 -0
  28. package/src/lib/attribution.js +18 -179
  29. package/src/lib/clipboard.js +147 -0
  30. package/src/lib/color.js +9 -0
  31. package/src/lib/dedup.js +67 -32
  32. package/src/lib/defaults.js +54 -2
  33. package/src/lib/drafts.js +439 -0
  34. package/src/lib/enhancements.js +436 -0
  35. package/src/lib/gh.js +102 -21
  36. package/src/lib/repo-picker.js +2 -0
  37. package/src/lib/safety.js +1 -1
  38. package/src/lib/templates.js +8 -12
  39. package/src/lib/theme.js +9 -0
  40. package/src/commands/use.js +0 -19
@@ -1,8 +1,16 @@
1
1
  import { Command } from 'commander'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
- import chalk from 'chalk'
5
- import { loadConfig, userConfigPath, BUILT_IN_DEFAULTS } from '../lib/defaults.js'
4
+ import os from 'node:os'
5
+ import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
6
+ import { select, input, confirm, password } from '@inquirer/prompts'
7
+ import { loadConfig, userConfigPath, findRepoRoot, BUILT_IN_DEFAULTS } from '../lib/defaults.js'
8
+ import { theme } from '../lib/theme.js'
9
+ import { listTemplates } from '../lib/templates.js'
10
+ import { pickRepo } from '../lib/repo-picker.js'
11
+ import { store } from '../lib/config.js'
12
+ import { listProviders, listAllProviders } from '../lib/ai/index.js'
13
+ import { listModels as listOllamaModels } from '../lib/ai/adapters/ollama.js'
6
14
 
7
15
  // ---------------------------------------------------------------------------
8
16
  // Helpers
@@ -18,8 +26,8 @@ function readUserConfig() {
18
26
 
19
27
  function writeUserConfig(obj) {
20
28
  const filePath = userConfigPath()
21
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
22
- fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8')
29
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
30
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
23
31
  }
24
32
 
25
33
  function getNestedValue(obj, dotKey) {
@@ -40,10 +48,15 @@ function setNestedValue(obj, dotKey, value) {
40
48
  }
41
49
 
42
50
  function coerceValue(dotKey, raw) {
51
+ if (raw.startsWith('[') || raw.startsWith('{')) {
52
+ try { return JSON.parse(raw) } catch { /* fall through */ }
53
+ }
43
54
  const parts = dotKey.split('.')
44
- if (parts.length !== 2) return raw
45
- const [section, field] = parts
46
- const defaultVal = BUILT_IN_DEFAULTS[section]?.[field]
55
+ let defaultVal = BUILT_IN_DEFAULTS
56
+ for (const part of parts) {
57
+ if (defaultVal == null || typeof defaultVal !== 'object') { defaultVal = undefined; break }
58
+ defaultVal = defaultVal[part]
59
+ }
47
60
  if (typeof defaultVal === 'number') {
48
61
  const n = Number(raw)
49
62
  if (isNaN(n)) throw new Error(`Expected a number for ${dotKey}, got: ${raw}`)
@@ -55,15 +68,1015 @@ function coerceValue(dotKey, raw) {
55
68
  throw new Error(`Expected true/false for ${dotKey}, got: ${raw}`)
56
69
  }
57
70
  if (raw === 'null') return null
71
+ // Auto-coerce pure numeric strings when no default type is known
72
+ if (defaultVal === undefined && /^\d+$/.test(raw)) return Number(raw)
58
73
  return raw
59
74
  }
60
75
 
76
+ function maskKey(value) {
77
+ if (!value || typeof value !== 'string') return value
78
+ if (value.length <= 4) return '****'
79
+ return '****' + value.slice(-4)
80
+ }
81
+
82
+ function maskKeysForDisplay(obj) {
83
+ const result = JSON.parse(JSON.stringify(obj))
84
+ if (result.ai?.keys) {
85
+ for (const provider of Object.keys(result.ai.keys)) {
86
+ if (result.ai.keys[provider]) result.ai.keys[provider] = maskKey(result.ai.keys[provider])
87
+ }
88
+ }
89
+ return result
90
+ }
91
+
92
+ function formatBudget(val) {
93
+ if (val === 0 || val === null || val === undefined) return dim('unlimited')
94
+ return cyan(val.toLocaleString())
95
+ }
96
+
97
+ function isCancelled(err) {
98
+ return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
99
+ }
100
+
101
+ /** Wrap a prompt call so Esc/Ctrl+C returns a sentinel instead of throwing. */
102
+ async function promptOrBack(fn) {
103
+ try {
104
+ return await fn()
105
+ } catch (err) {
106
+ if (isCancelled(err)) return Symbol.for('back')
107
+ throw err
108
+ }
109
+ }
110
+
111
+ function backupsDir() {
112
+ return path.join(os.homedir(), '.config', 'tissues', 'backups')
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Submenu: Active repo
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function menuActiveRepo() {
120
+ await pickRepo()
121
+ console.log(green(` ✔ Default repo: ${store.get('activeRepo')}`))
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Submenu: Active provider
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const PROVIDER_LABELS = {
129
+ anthropic: 'Anthropic',
130
+ openai: 'OpenAI',
131
+ gemini: 'Gemini',
132
+ ollama: 'Ollama',
133
+ 'openai-compat': 'OpenAI Custom',
134
+ command: 'Command',
135
+ }
136
+
137
+ async function menuActiveProvider() {
138
+ const cfg = loadConfig()
139
+ const current = readUserConfig()
140
+ const allProviders = listAllProviders(cfg)
141
+ const builtIn = listProviders()
142
+
143
+ const defaultProvider = BUILT_IN_DEFAULTS.ai.provider
144
+ const choices = [
145
+ ...allProviders.map((p) => {
146
+ const isCustom = !builtIn.includes(p)
147
+ const label = isCustom ? `${p} ${dim('(custom)')}` : (PROVIDER_LABELS[p] || p)
148
+ return { name: label, value: p }
149
+ }),
150
+ { name: 'None (disable AI)', value: 'none' },
151
+ { name: dim(`Restore default (${defaultProvider})`), value: 'restore' },
152
+ { name: dim('Back'), value: 'back' },
153
+ ]
154
+
155
+ const provider = await promptOrBack(() =>
156
+ select({ message: 'Default Provider', choices, default: cfg.ai?.provider, theme }),
157
+ )
158
+ if (provider === Symbol.for('back') || provider === 'back') return
159
+
160
+ let updated = current
161
+ if (provider === 'restore') {
162
+ updated = setNestedValue(updated, 'ai.enabled', BUILT_IN_DEFAULTS.ai.enabled)
163
+ updated = setNestedValue(updated, 'ai.provider', defaultProvider)
164
+ writeUserConfig(updated)
165
+ console.log(green(` ✔ Restored default: ${defaultProvider}`))
166
+ } else if (provider === 'none') {
167
+ updated = setNestedValue(updated, 'ai.enabled', false)
168
+ writeUserConfig(updated)
169
+ console.log(green(' ✔ AI disabled'))
170
+ } else if (provider === 'ollama') {
171
+ updated = await configureOllama(updated)
172
+ } else if (provider === 'openai-compat') {
173
+ updated = await configureOpenAICompat(updated)
174
+ } else if (provider === 'command') {
175
+ updated = await configureCommand(updated)
176
+ } else if (!builtIn.includes(provider)) {
177
+ // Named CLI command — just set as default provider
178
+ updated = setNestedValue(updated, 'ai.enabled', true)
179
+ updated = setNestedValue(updated, 'ai.provider', provider)
180
+ writeUserConfig(updated)
181
+ const cmd = cfg.ai?.providers?.[provider]?.command || cfg.ai?.commands?.[provider]?.command || '(not set)'
182
+ console.log(green(` ✔ Default provider: ${provider} ${dim(`→ ${cmd}`)}`))
183
+ } else {
184
+ updated = setNestedValue(updated, 'ai.enabled', true)
185
+ updated = setNestedValue(updated, 'ai.provider', provider)
186
+ writeUserConfig(updated)
187
+ console.log(green(` ✔ Default provider: ${provider}`))
188
+ }
189
+ }
190
+
191
+ async function configureOllama(config) {
192
+ const cfg = loadConfig()
193
+ const baseUrl = cfg.ai?.ollama?.url || BUILT_IN_DEFAULTS.ai.ollama.url
194
+
195
+ let updated = config
196
+ updated = setNestedValue(updated, 'ai.enabled', true)
197
+ updated = setNestedValue(updated, 'ai.provider', 'ollama')
198
+
199
+ // Probe Ollama server for models
200
+ let models = []
201
+ try {
202
+ models = await listOllamaModels(baseUrl)
203
+ } catch {
204
+ console.log(yellow(` ⚠ Could not reach Ollama at ${baseUrl}`))
205
+ console.log(dim(' Start Ollama later and it will work.'))
206
+ }
207
+
208
+ if (models.length > 0) {
209
+ const currentModel = cfg.ai?.models?.ollama || BUILT_IN_DEFAULTS.ai.models.ollama
210
+ const modelChoices = models.map((m) => ({ name: m, value: m }))
211
+ modelChoices.push({ name: dim('Back'), value: 'back' })
212
+
213
+ const model = await promptOrBack(() =>
214
+ select({ message: 'Ollama model', choices: modelChoices, default: currentModel, theme }),
215
+ )
216
+ if (model !== Symbol.for('back') && model !== 'back') {
217
+ updated = setNestedValue(updated, 'ai.models.ollama', model)
218
+ }
219
+ } else if (models.length === 0) {
220
+ const model = await promptOrBack(() =>
221
+ input({ message: 'Model name', default: cfg.ai?.models?.ollama || 'llama3.2', theme }),
222
+ )
223
+ if (model !== Symbol.for('back') && model) {
224
+ updated = setNestedValue(updated, 'ai.models.ollama', model)
225
+ }
226
+ }
227
+
228
+ writeUserConfig(updated)
229
+ console.log(green(` ✔ Default provider: ollama`))
230
+ return updated
231
+ }
232
+
233
+ async function configureOpenAICompat(config) {
234
+ const cfg = loadConfig()
235
+ let updated = config
236
+ updated = setNestedValue(updated, 'ai.enabled', true)
237
+ updated = setNestedValue(updated, 'ai.provider', 'openai-compat')
238
+
239
+ const baseUrl = await promptOrBack(() =>
240
+ input({ message: 'Base URL', default: cfg.ai?.custom?.url || '', theme }),
241
+ )
242
+ if (baseUrl === Symbol.for('back')) return config
243
+ if (baseUrl) {
244
+ updated = setNestedValue(updated, 'ai.custom.url', baseUrl)
245
+ }
246
+
247
+ const apiKey = await promptOrBack(() =>
248
+ password({ message: 'API key (optional, enter to skip)', mask: '*', theme }),
249
+ )
250
+ if (apiKey !== Symbol.for('back') && apiKey) {
251
+ updated = setNestedValue(updated, 'ai.keys.openai-compat', apiKey)
252
+ }
253
+
254
+ const model = await promptOrBack(() =>
255
+ input({ message: 'Model name', default: cfg.ai?.models?.['openai-compat'] || '', theme }),
256
+ )
257
+ if (model !== Symbol.for('back') && model) {
258
+ updated = setNestedValue(updated, 'ai.models.openai-compat', model)
259
+ }
260
+
261
+ writeUserConfig(updated)
262
+ console.log(green(` ✔ Default provider: openai-compat`))
263
+ return updated
264
+ }
265
+
266
+ async function configureCommand(config) {
267
+ const cfg = loadConfig()
268
+ let updated = config
269
+ updated = setNestedValue(updated, 'ai.enabled', true)
270
+ updated = setNestedValue(updated, 'ai.provider', 'command')
271
+
272
+ const command = await promptOrBack(() =>
273
+ input({
274
+ message: 'Command to run',
275
+ default: cfg.ai?.command || '',
276
+ theme,
277
+ }),
278
+ )
279
+ if (command === Symbol.for('back')) return config
280
+ if (command) {
281
+ updated = setNestedValue(updated, 'ai.command', command)
282
+ }
283
+
284
+ writeUserConfig(updated)
285
+ console.log(green(` ✔ Default provider: command`))
286
+ return updated
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Submenu: API keys
291
+ // ---------------------------------------------------------------------------
292
+
293
+ // Providers that use API keys (ollama and command don't)
294
+ const KEY_PROVIDERS = ['anthropic', 'openai', 'gemini', 'openai-compat']
295
+
296
+ async function menuAPIKeys() {
297
+ let cfg = loadConfig()
298
+
299
+ while (true) {
300
+ cfg = loadConfig()
301
+ const choices = KEY_PROVIDERS.map((p) => {
302
+ const key = cfg.ai?.keys?.[p]
303
+ const status = key ? maskKey(key) : dim('not set')
304
+ const label = PROVIDER_LABELS[p] || p
305
+ return { name: `${label.padEnd(20)} ${status}`, value: p }
306
+ })
307
+ choices.push({ name: dim('Back'), value: 'back' })
308
+
309
+ const provider = await promptOrBack(() => select({ message: 'API Keys', choices, theme }))
310
+ if (provider === Symbol.for('back') || provider === 'back') return
311
+
312
+ const existingKey = cfg.ai?.keys?.[provider]
313
+ const actionChoices = [{ name: 'Set new key', value: 'set' }]
314
+ if (existingKey) actionChoices.push({ name: red('Delete key'), value: 'delete' })
315
+ actionChoices.push({ name: dim('Back'), value: 'back' })
316
+
317
+ const label = PROVIDER_LABELS[provider] || provider
318
+ const action = await promptOrBack(() => select({ message: `${label} key`, choices: actionChoices, theme }))
319
+ if (action === Symbol.for('back') || action === 'back') continue
320
+
321
+ const current = readUserConfig()
322
+ if (action === 'delete') {
323
+ const updated = setNestedValue(current, `ai.keys.${provider}`, null)
324
+ writeUserConfig(updated)
325
+ console.log(green(` ✔ ${label} key deleted`))
326
+ } else {
327
+ const apiKey = await promptOrBack(() => password({ message: `API key for ${label}`, mask: '*', theme }))
328
+ if (apiKey === Symbol.for('back')) continue
329
+ if (apiKey) {
330
+ const updated = setNestedValue(current, `ai.keys.${provider}`, apiKey)
331
+ writeUserConfig(updated)
332
+ console.log(green(` ✔ ${label} key saved`))
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Submenu: Token budgets
340
+ // ---------------------------------------------------------------------------
341
+
342
+ async function menuTokenBudgets() {
343
+ const budgetKeys = [
344
+ { key: 'maxTokensPerRequest', label: 'Per request', desc: 'max tokens the AI can generate per issue' },
345
+ { key: 'maxTokensPerHour', label: 'Per hour', desc: 'rolling hourly cap across all requests' },
346
+ { key: 'maxTokensPerDay', label: 'Per day', desc: 'rolling daily cap across all requests' },
347
+ ]
348
+
349
+ while (true) {
350
+ const cfg = loadConfig()
351
+ const budgets = cfg.ai?.budgets || BUILT_IN_DEFAULTS.ai.budgets
352
+
353
+ const choices = budgetKeys.map((b) => ({
354
+ name: `${b.label.padEnd(14)} ${formatBudget(budgets[b.key])} ${dim(b.desc)}`,
355
+ value: b.key,
356
+ }))
357
+ choices.push({ name: dim('Restore defaults'), value: 'restore' })
358
+ choices.push({ name: dim('Back'), value: 'back' })
359
+
360
+ const chosen = await promptOrBack(() => select({ message: 'Token Budgets', choices, theme }))
361
+ if (chosen === Symbol.for('back') || chosen === 'back') return
362
+
363
+ if (chosen === 'restore') {
364
+ const current = readUserConfig()
365
+ const updated = setNestedValue(current, 'ai.budgets', BUILT_IN_DEFAULTS.ai.budgets)
366
+ writeUserConfig(updated)
367
+ console.log(green(' ✔ Token budgets restored to defaults'))
368
+ continue
369
+ }
370
+
371
+ const info = budgetKeys.find((b) => b.key === chosen)
372
+ const currentVal = budgets[chosen]
373
+
374
+ const value = await promptOrBack(() =>
375
+ input({ message: `${info.label} (0 = unlimited)`, default: String(currentVal ?? 0), theme }),
376
+ )
377
+ if (value === Symbol.for('back')) continue
378
+
379
+ const num = Number(value)
380
+ if (isNaN(num) || num < 0) {
381
+ console.log(red(' Must be a non-negative number (0 for unlimited)'))
382
+ continue
383
+ }
384
+
385
+ const current = readUserConfig()
386
+ const updated = setNestedValue(current, `ai.budgets.${chosen}`, num)
387
+ writeUserConfig(updated)
388
+ console.log(green(` ✔ ${info.label}: ${num === 0 ? 'unlimited' : num.toLocaleString()}`))
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Submenu: Default template (selectable list)
394
+ // ---------------------------------------------------------------------------
395
+
396
+ async function menuDefaultTemplate() {
397
+ const cfg = loadConfig()
398
+ const repoRoot = findRepoRoot()
399
+ const templates = listTemplates(repoRoot)
400
+
401
+ // Deduplicate by key (higher-priority sources shadow lower ones)
402
+ const seen = new Set()
403
+ const choices = []
404
+ for (const tpl of templates) {
405
+ if (!seen.has(tpl.key)) {
406
+ seen.add(tpl.key)
407
+ const current = tpl.key === (cfg.templates?.default || 'default') ? green(' (current)') : ''
408
+ choices.push({
409
+ name: `${tpl.key.padEnd(14)} ${dim(tpl.name)} ${dim(`(${tpl.source})`)}${current}`,
410
+ value: tpl.key,
411
+ })
412
+ }
413
+ }
414
+ choices.push({ name: 'Create new template', value: '_create' })
415
+ const defaultTpl = BUILT_IN_DEFAULTS.templates.default
416
+ choices.push({ name: dim(`Restore default (${defaultTpl})`), value: '_restore' })
417
+ choices.push({ name: dim('Back'), value: 'back' })
418
+
419
+ const chosen = await promptOrBack(() =>
420
+ select({ message: 'Default Template', choices, default: cfg.templates?.default, theme }),
421
+ )
422
+ if (chosen === Symbol.for('back') || chosen === 'back') return
423
+
424
+ if (chosen === '_restore') {
425
+ const current = readUserConfig()
426
+ const updated = setNestedValue(current, 'templates.default', defaultTpl)
427
+ writeUserConfig(updated)
428
+ console.log(green(` ✔ Restored default: ${defaultTpl}`))
429
+ return
430
+ }
431
+
432
+ if (chosen === '_create') {
433
+ const name = await promptOrBack(() => input({ message: 'Template name (lowercase, no spaces)', theme }))
434
+ if (name === Symbol.for('back') || !name) return
435
+
436
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
437
+ if (!key) return
438
+
439
+ // Create in user templates dir
440
+ const templateDir = path.join(os.homedir(), '.config', 'tissues', 'templates')
441
+ fs.mkdirSync(templateDir, { recursive: true })
442
+ const templatePath = path.join(templateDir, `${key}.md`)
443
+
444
+ if (fs.existsSync(templatePath)) {
445
+ console.log(yellow(` Template "${key}" already exists at ${templatePath}`))
446
+ return
447
+ }
448
+
449
+ const body = `name: ${key.charAt(0).toUpperCase() + key.slice(1)}\n\n## Summary\n\n{{description}}\n\n## Details\n\n`
450
+ fs.writeFileSync(templatePath, body, 'utf8')
451
+ console.log(green(` ✔ Template created: ${templatePath}`))
452
+
453
+ const setDefault = await promptOrBack(() =>
454
+ confirm({ message: `Set "${key}" as default?`, default: true, theme }),
455
+ )
456
+ if (setDefault === true) {
457
+ const current = readUserConfig()
458
+ const updated = setNestedValue(current, 'templates.default', key)
459
+ writeUserConfig(updated)
460
+ console.log(green(` ✔ Default template: ${key}`))
461
+ }
462
+ return
463
+ }
464
+
465
+ // Set chosen template as default
466
+ const current = readUserConfig()
467
+ const updated = setNestedValue(current, 'templates.default', chosen)
468
+ writeUserConfig(updated)
469
+ console.log(green(` ✔ Default template: ${chosen}`))
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Submenu: Safety limits (with descriptions)
474
+ // ---------------------------------------------------------------------------
475
+
476
+ async function menuSafety() {
477
+ const safetyKeys = [
478
+ { key: 'maxPerHour', label: 'Max per hour', desc: 'issues one agent can create per hour' },
479
+ { key: 'burstLimit', label: 'Burst limit', desc: 'max issues in a short burst window' },
480
+ { key: 'burstWindowMinutes', label: 'Burst window (min)', desc: 'how many minutes the burst window spans' },
481
+ { key: 'tripThreshold', label: 'Trip threshold', desc: 'consecutive failures before circuit opens' },
482
+ { key: 'cooldownMinutes', label: 'Cooldown (min)', desc: 'minutes circuit stays open after tripping' },
483
+ { key: 'globalMaxPerHour', label: 'Global max/hour', desc: 'total issues across all agents per hour' },
484
+ ]
485
+
486
+ while (true) {
487
+ const cfg = loadConfig()
488
+ const safety = cfg.safety || BUILT_IN_DEFAULTS.safety
489
+
490
+ const choices = safetyKeys.map((s) => ({
491
+ name: `${s.label.padEnd(20)} ${cyan(String(safety[s.key]).padEnd(4))} ${dim(s.desc)}`,
492
+ value: s.key,
493
+ }))
494
+ choices.push({ name: dim('Restore defaults'), value: 'restore' })
495
+ choices.push({ name: dim('Back'), value: 'back' })
496
+
497
+ const chosen = await promptOrBack(() => select({ message: 'Safety Limits', choices, theme }))
498
+ if (chosen === Symbol.for('back') || chosen === 'back') return
499
+
500
+ if (chosen === 'restore') {
501
+ const current = readUserConfig()
502
+ const updated = setNestedValue(current, 'safety', BUILT_IN_DEFAULTS.safety)
503
+ writeUserConfig(updated)
504
+ console.log(green(' ✔ Safety limits restored to defaults'))
505
+ continue
506
+ }
507
+
508
+ const info = safetyKeys.find((s) => s.key === chosen)
509
+ const value = await promptOrBack(() =>
510
+ input({ message: `${info.label} ${dim(`— ${info.desc}`)}`, default: String(safety[chosen]), theme }),
511
+ )
512
+ if (value === Symbol.for('back')) continue
513
+
514
+ const num = Number(value)
515
+ if (isNaN(num) || num < 0) {
516
+ console.log(red(' Must be a non-negative number'))
517
+ continue
518
+ }
519
+
520
+ const current = readUserConfig()
521
+ const updated = setNestedValue(current, `safety.${chosen}`, num)
522
+ writeUserConfig(updated)
523
+ console.log(green(` ✔ ${info.label}: ${num}`))
524
+ }
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Submenu: Routing rules (full CRUD)
529
+ // ---------------------------------------------------------------------------
530
+
531
+ function formatRoute(rule) {
532
+ const match = Object.entries(rule.match || {})
533
+ .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(',') : v}`)
534
+ .join(', ')
535
+ const enhTag = rule.enhancements?.length ? dim(` [${rule.enhancements.join(',')}]`) : ''
536
+ return `${match} ${dim('->')} ${rule.provider || 'default'}${rule.model ? dim(` (${rule.model})`) : ''}${enhTag}`
537
+ }
538
+
539
+ async function editRoute(rule) {
540
+ const cfg = loadConfig()
541
+ const repoRoot = findRepoRoot()
542
+ const providers = listProviders()
543
+ const templates = listTemplates(repoRoot)
544
+ const seen = new Set()
545
+ const templateKeys = []
546
+ for (const tpl of templates) {
547
+ if (!seen.has(tpl.key)) { seen.add(tpl.key); templateKeys.push(tpl.key) }
548
+ }
549
+
550
+ // Match type
551
+ const matchType = await promptOrBack(() =>
552
+ select({
553
+ message: 'Match on',
554
+ choices: [
555
+ { name: 'Template', value: 'template' },
556
+ { name: 'Labels', value: 'labels' },
557
+ ],
558
+ default: rule?.match?.template ? 'template' : rule?.match?.labels ? 'labels' : 'template',
559
+ theme,
560
+ }),
561
+ )
562
+ if (matchType === Symbol.for('back')) return null
563
+
564
+ let match = {}
565
+ if (matchType === 'template') {
566
+ const tplChoices = templateKeys.map((k) => ({ name: k, value: k }))
567
+ const tpl = await promptOrBack(() =>
568
+ select({ message: 'Template', choices: tplChoices, default: rule?.match?.template, theme }),
569
+ )
570
+ if (tpl === Symbol.for('back')) return null
571
+ match = { template: tpl }
572
+ } else {
573
+ const labelsRaw = await promptOrBack(() =>
574
+ input({
575
+ message: 'Labels (comma-separated, any match)',
576
+ default: rule?.match?.labels?.join(', ') || '',
577
+ theme,
578
+ }),
579
+ )
580
+ if (labelsRaw === Symbol.for('back')) return null
581
+ match = { labels: labelsRaw.split(',').map((l) => l.trim()).filter(Boolean) }
582
+ if (match.labels.length === 0) {
583
+ console.log(red(' At least one label required'))
584
+ return null
585
+ }
586
+ }
587
+
588
+ // Provider
589
+ const allProviders = listAllProviders(cfg)
590
+ const builtInSet = new Set(listProviders())
591
+ const providerChoices = allProviders.map((p) => ({
592
+ name: builtInSet.has(p) ? (p.charAt(0).toUpperCase() + p.slice(1)) : `${p} ${dim('(custom)')}`,
593
+ value: p,
594
+ }))
595
+ const provider = await promptOrBack(() =>
596
+ select({ message: 'Provider', choices: providerChoices, default: rule?.provider, theme }),
597
+ )
598
+ if (provider === Symbol.for('back')) return null
599
+
600
+ // Model
601
+ const defaultModels = cfg.ai?.models || BUILT_IN_DEFAULTS.ai.models
602
+ const model = await promptOrBack(() =>
603
+ input({
604
+ message: 'Model (enter for provider default)',
605
+ default: rule?.model || defaultModels[provider] || '',
606
+ theme,
607
+ }),
608
+ )
609
+ if (model === Symbol.for('back')) return null
610
+
611
+ // Enhancements filter (optional)
612
+ const enhRaw = await promptOrBack(() =>
613
+ input({
614
+ message: 'Enhancements (comma-separated, enter to skip)',
615
+ default: rule?.enhancements?.join(', ') || '',
616
+ theme,
617
+ }),
618
+ )
619
+ if (enhRaw === Symbol.for('back')) return null
620
+
621
+ const enhancements = enhRaw
622
+ ? enhRaw.split(',').map((s) => s.trim()).filter(Boolean)
623
+ : undefined
624
+
625
+ return { match, provider, model: model || undefined, enhancements: enhancements?.length ? enhancements : undefined }
626
+ }
627
+
628
+ async function menuRoutingRules() {
629
+ while (true) {
630
+ const cfg = loadConfig()
631
+ const routes = cfg.ai?.routes || []
632
+
633
+ const choices = routes.map((rule, i) => ({
634
+ name: `${String(i + 1).padEnd(3)} ${formatRoute(rule)}`,
635
+ value: String(i),
636
+ }))
637
+ choices.push({ name: green('+ Add rule'), value: 'add' })
638
+ choices.push({ name: dim('Back'), value: 'back' })
639
+
640
+ const chosen = await promptOrBack(() => select({ message: 'Routing Rules', choices, theme }))
641
+ if (chosen === Symbol.for('back') || chosen === 'back') return
642
+
643
+ if (chosen === 'add') {
644
+ const newRule = await editRoute(null)
645
+ if (newRule) {
646
+ const current = readUserConfig()
647
+ const existingRoutes = current.ai?.routes || []
648
+ existingRoutes.push(newRule)
649
+ const updated = setNestedValue(current, 'ai.routes', existingRoutes)
650
+ writeUserConfig(updated)
651
+ console.log(green(` ✔ Route added: ${formatRoute(newRule)}`))
652
+ }
653
+ continue
654
+ }
655
+
656
+ // Edit existing route
657
+ const idx = Number(chosen)
658
+ const rule = routes[idx]
659
+
660
+ const action = await promptOrBack(() =>
661
+ select({
662
+ message: `Rule ${idx + 1}: ${formatRoute(rule)}`,
663
+ choices: [
664
+ { name: 'Edit', value: 'edit' },
665
+ { name: red('Delete'), value: 'delete' },
666
+ { name: dim('Back'), value: 'back' },
667
+ ],
668
+ theme,
669
+ }),
670
+ )
671
+ if (action === Symbol.for('back') || action === 'back') continue
672
+
673
+ const current = readUserConfig()
674
+ const existingRoutes = [...(current.ai?.routes || routes)]
675
+
676
+ if (action === 'delete') {
677
+ existingRoutes.splice(idx, 1)
678
+ const updated = setNestedValue(current, 'ai.routes', existingRoutes)
679
+ writeUserConfig(updated)
680
+ console.log(green(` ✔ Route ${idx + 1} deleted`))
681
+ } else {
682
+ const edited = await editRoute(rule)
683
+ if (edited) {
684
+ existingRoutes[idx] = edited
685
+ const updated = setNestedValue(current, 'ai.routes', existingRoutes)
686
+ writeUserConfig(updated)
687
+ console.log(green(` ✔ Route ${idx + 1} updated`))
688
+ }
689
+ }
690
+ }
691
+ }
692
+
693
+ // ---------------------------------------------------------------------------
694
+ // Submenu: Custom Providers (named command providers)
695
+ // ---------------------------------------------------------------------------
696
+
697
+ /** Migrate ai.commands → ai.providers on disk if needed. */
698
+ function migrateProvidersOnDisk(current) {
699
+ if (!current.ai) return current
700
+ let updated = current
701
+ if (current.ai.commands) {
702
+ const existing = current.ai.providers || {}
703
+ updated = setNestedValue(updated, 'ai.providers', { ...current.ai.commands, ...existing })
704
+ const ai = { ...updated.ai }
705
+ delete ai.commands
706
+ updated = { ...updated, ai }
707
+ }
708
+ return updated
709
+ }
710
+
711
+ async function menuProviders() {
712
+ // One-time disk migration when user opens the menu
713
+ {
714
+ const current = readUserConfig()
715
+ if (current.ai?.commands) {
716
+ const migrated = migrateProvidersOnDisk(current)
717
+ writeUserConfig(migrated)
718
+ }
719
+ }
720
+
721
+ while (true) {
722
+ const cfg = loadConfig()
723
+ const providers = cfg.ai?.providers || cfg.ai?.commands || {}
724
+ const names = Object.keys(providers)
725
+
726
+ const choices = names.map((name) => {
727
+ const entry = providers[name]
728
+ const cmd = entry.command || dim('(no command)')
729
+ const timeout = entry.timeout ? dim(` ${entry.timeout}ms`) : ''
730
+ return { name: `${name.padEnd(18)} ${dim(cmd)}${timeout}`, value: name }
731
+ })
732
+ choices.push({ name: green('+ Add provider'), value: 'add' })
733
+ choices.push({ name: dim('Back'), value: 'back' })
734
+
735
+ const chosen = await promptOrBack(() => select({ message: 'Custom Providers', choices, theme }))
736
+ if (chosen === Symbol.for('back') || chosen === 'back') return
737
+
738
+ if (chosen === 'add') {
739
+ const name = await promptOrBack(() =>
740
+ input({ message: 'Provider name (e.g. my-gemini)', theme }),
741
+ )
742
+ if (name === Symbol.for('back') || !name) continue
743
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
744
+ if (!key) continue
745
+ if (providers[key]) {
746
+ console.log(yellow(` "${key}" already exists — select it to edit`))
747
+ continue
748
+ }
749
+ const cmd = await promptOrBack(() =>
750
+ input({ message: 'Shell command to run', theme }),
751
+ )
752
+ if (cmd === Symbol.for('back') || !cmd) continue
753
+ const timeoutRaw = await promptOrBack(() =>
754
+ input({ message: 'Timeout ms (enter for default 60s)', default: '', theme }),
755
+ )
756
+ if (timeoutRaw === Symbol.for('back')) continue
757
+
758
+ const current = readUserConfig()
759
+ let updated = setNestedValue(current, `ai.providers.${key}.command`, cmd)
760
+ if (timeoutRaw) {
761
+ const ms = Number(timeoutRaw)
762
+ if (!isNaN(ms) && ms > 0) {
763
+ updated = setNestedValue(updated, `ai.providers.${key}.timeout`, ms)
764
+ }
765
+ }
766
+ writeUserConfig(updated)
767
+ console.log(green(` ✔ Added: ${key} → ${cmd}`))
768
+ continue
769
+ }
770
+
771
+ // Edit/delete existing provider
772
+ const entry = providers[chosen]
773
+ const action = await promptOrBack(() =>
774
+ select({
775
+ message: `${chosen}: ${entry.command || '(no command)'}`,
776
+ choices: [
777
+ { name: 'Edit', value: 'edit' },
778
+ { name: red('Delete'), value: 'delete' },
779
+ { name: dim('Back'), value: 'back' },
780
+ ],
781
+ theme,
782
+ }),
783
+ )
784
+ if (action === Symbol.for('back') || action === 'back') continue
785
+
786
+ const current = readUserConfig()
787
+ if (action === 'delete') {
788
+ const provs = { ...(current.ai?.providers || {}) }
789
+ delete provs[chosen]
790
+ const updated = setNestedValue(current, 'ai.providers', provs)
791
+ writeUserConfig(updated)
792
+ console.log(green(` ✔ Deleted: ${chosen}`))
793
+ } else {
794
+ const cmd = await promptOrBack(() =>
795
+ input({ message: 'Shell command', default: entry.command || '', theme }),
796
+ )
797
+ if (cmd === Symbol.for('back')) continue
798
+ const timeoutRaw = await promptOrBack(() =>
799
+ input({ message: 'Timeout ms (enter for default)', default: entry.timeout ? String(entry.timeout) : '', theme }),
800
+ )
801
+ if (timeoutRaw === Symbol.for('back')) continue
802
+
803
+ let updated = current
804
+ if (cmd) updated = setNestedValue(updated, `ai.providers.${chosen}.command`, cmd)
805
+ if (timeoutRaw) {
806
+ const ms = Number(timeoutRaw)
807
+ if (!isNaN(ms) && ms > 0) {
808
+ updated = setNestedValue(updated, `ai.providers.${chosen}.timeout`, ms)
809
+ }
810
+ } else {
811
+ // Clear timeout if empty
812
+ const provs = { ...(updated.ai?.providers || {}) }
813
+ if (provs[chosen]) { delete provs[chosen].timeout; updated = setNestedValue(updated, 'ai.providers', provs) }
814
+ }
815
+ writeUserConfig(updated)
816
+ console.log(green(` ✔ Updated: ${chosen}`))
817
+ }
818
+ }
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // Backup / Restore
823
+ // ---------------------------------------------------------------------------
824
+
825
+ function getBackupFiles() {
826
+ const dir = backupsDir()
827
+ try {
828
+ return fs.readdirSync(dir)
829
+ .filter((f) => f.endsWith('.json'))
830
+ .sort()
831
+ .reverse()
832
+ } catch {
833
+ return []
834
+ }
835
+ }
836
+
837
+ async function menuBackupRestore() {
838
+ while (true) {
839
+ const backups = getBackupFiles()
840
+ const choices = [
841
+ { name: 'Save current config', value: 'save' },
842
+ ]
843
+ if (backups.length > 0) {
844
+ choices.push({ name: `Restore from backup ${dim(`(${backups.length} saved)`)}`, value: 'restore' })
845
+ choices.push({ name: 'Delete a backup', value: 'delete' })
846
+ }
847
+ choices.push({ name: dim('Back'), value: 'back' })
848
+
849
+ const action = await promptOrBack(() => select({ message: 'Backup & Restore', choices, theme }))
850
+ if (action === Symbol.for('back') || action === 'back') return
851
+
852
+ if (action === 'save') {
853
+ const label = await promptOrBack(() =>
854
+ input({ message: 'Backup name (optional)', default: '', theme }),
855
+ )
856
+ if (label === Symbol.for('back')) continue
857
+
858
+ const dir = backupsDir()
859
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
860
+
861
+ // Warn if backup will contain API keys
862
+ const cfg = readUserConfig()
863
+ const hasKeys = cfg.ai?.keys && Object.values(cfg.ai.keys).some(Boolean)
864
+ if (hasKeys) {
865
+ const proceed = await promptOrBack(() =>
866
+ confirm({ message: yellow('Backup will contain unencrypted API keys. Continue?'), default: true, theme }),
867
+ )
868
+ if (proceed === Symbol.for('back') || !proceed) continue
869
+ }
870
+
871
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
872
+ const safeName = label ? label.trim().replace(/[^a-zA-Z0-9_-]/g, '_') : ''
873
+ const filename = safeName ? `${timestamp}_${safeName}.json` : `${timestamp}.json`
874
+
875
+ // Bundle user config + templates
876
+ const bundle = {
877
+ _version: 1,
878
+ _createdAt: new Date().toISOString(),
879
+ _label: label || undefined,
880
+ config: cfg,
881
+ activeRepo: store.get('activeRepo') || null,
882
+ }
883
+
884
+ // Include user templates if they exist
885
+ const templateDir = path.join(os.homedir(), '.config', 'tissues', 'templates')
886
+ try {
887
+ const files = fs.readdirSync(templateDir).filter((f) => f.endsWith('.md'))
888
+ if (files.length > 0) {
889
+ bundle.templates = {}
890
+ for (const f of files) {
891
+ bundle.templates[f] = fs.readFileSync(path.join(templateDir, f), 'utf8')
892
+ }
893
+ }
894
+ } catch { /* no templates dir */ }
895
+
896
+ const filePath = path.join(dir, filename)
897
+ fs.writeFileSync(filePath, JSON.stringify(bundle, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
898
+ console.log(green(` ✔ Saved: ${filename}`))
899
+ continue
900
+ }
901
+
902
+ if (action === 'restore') {
903
+ const backupChoices = backups.map((f) => {
904
+ let label = f.replace('.json', '')
905
+ try {
906
+ const data = JSON.parse(fs.readFileSync(path.join(backupsDir(), f), 'utf8'))
907
+ if (data._label) label = `${label} ${dim(`"${data._label}"`)}`
908
+ } catch { /* ignore */ }
909
+ return { name: label, value: f }
910
+ })
911
+ backupChoices.push({ name: dim('Back'), value: 'back' })
912
+
913
+ const file = await promptOrBack(() => select({ message: 'Restore from', choices: backupChoices, theme }))
914
+ if (file === Symbol.for('back') || file === 'back') continue
915
+
916
+ const filePath = path.join(backupsDir(), file)
917
+ let bundle
918
+ try {
919
+ bundle = JSON.parse(fs.readFileSync(filePath, 'utf8'))
920
+ } catch (err) {
921
+ console.log(red(` Failed to read backup: ${err.message}`))
922
+ continue
923
+ }
924
+
925
+ const proceed = await promptOrBack(() =>
926
+ confirm({ message: 'This will overwrite your current config. Continue?', default: false, theme }),
927
+ )
928
+ if (proceed !== true) continue
929
+
930
+ // Restore config
931
+ if (bundle.config) writeUserConfig(bundle.config)
932
+ if (bundle.activeRepo) store.set('activeRepo', bundle.activeRepo)
933
+
934
+ // Restore templates
935
+ if (bundle.templates) {
936
+ const templateDir = path.join(os.homedir(), '.config', 'tissues', 'templates')
937
+ fs.mkdirSync(templateDir, { recursive: true })
938
+ for (const [filename, content] of Object.entries(bundle.templates)) {
939
+ fs.writeFileSync(path.join(templateDir, filename), content, 'utf8')
940
+ }
941
+ console.log(green(` ✔ Restored ${Object.keys(bundle.templates).length} template(s)`))
942
+ }
943
+
944
+ console.log(green(` ✔ Config restored from ${file}`))
945
+ continue
946
+ }
947
+
948
+ if (action === 'delete') {
949
+ const deleteChoices = backups.map((f) => ({ name: f.replace('.json', ''), value: f }))
950
+ deleteChoices.push({ name: dim('Back'), value: 'back' })
951
+
952
+ const file = await promptOrBack(() => select({ message: 'Delete backup', choices: deleteChoices, theme }))
953
+ if (file === Symbol.for('back') || file === 'back') continue
954
+
955
+ fs.unlinkSync(path.join(backupsDir(), file))
956
+ console.log(green(` ✔ Deleted: ${file}`))
957
+ }
958
+ }
959
+ }
960
+
961
+ // ---------------------------------------------------------------------------
962
+ // Main menu loop
963
+ // ---------------------------------------------------------------------------
964
+
965
+ async function runWizard() {
966
+ console.log(bold('\ntissues config\n'))
967
+
968
+ while (true) {
969
+ const cfg = loadConfig()
970
+ const providers = listProviders()
971
+ const budgets = cfg.ai?.budgets || {}
972
+
973
+ const summaries = {
974
+ repo: store.get('activeRepo') || dim('not set'),
975
+ provider: cfg.ai?.enabled === false
976
+ ? dim('disabled')
977
+ : (cfg.ai?.provider || dim('not set')),
978
+ keys: KEY_PROVIDERS
979
+ .filter((p) => cfg.ai?.keys?.[p])
980
+ .join(', ') || dim('none'),
981
+ budgets: [budgets.maxTokensPerRequest, budgets.maxTokensPerHour, budgets.maxTokensPerDay]
982
+ .map((v) => (v === 0 || !v) ? '\u221E' : v.toLocaleString())
983
+ .join('/'),
984
+ template: cfg.templates?.default || 'default',
985
+ safety: `${cfg.safety?.maxPerHour || '?'}/hr, burst ${cfg.safety?.burstLimit || '?'}`,
986
+ routes: `${(cfg.ai?.routes || []).length} rule${(cfg.ai?.routes || []).length === 1 ? '' : 's'}`,
987
+ providers: `${Object.keys(cfg.ai?.providers || cfg.ai?.commands || {}).length} registered`,
988
+ backups: `${getBackupFiles().length} saved`,
989
+ }
990
+
991
+ const choices = [
992
+ { name: `${'Default Repo'.padEnd(18)} ${dim(summaries.repo)}`, value: 'repo' },
993
+ { name: `${'Default Provider'.padEnd(18)} ${dim(summaries.provider)}`,value: 'provider' },
994
+ { name: `${'Default Template'.padEnd(18)} ${dim(summaries.template)}`,value: 'template' },
995
+ { name: `${'API Keys'.padEnd(18)} ${dim(summaries.keys)}`, value: 'keys' },
996
+ { name: `${'Token Budgets'.padEnd(18)} ${dim(summaries.budgets)}`, value: 'budgets' },
997
+ { name: `${'Safety Limits'.padEnd(18)} ${dim(summaries.safety)}`, value: 'safety' },
998
+ { name: `${'Routing Rules'.padEnd(18)} ${dim(summaries.routes)}`, value: 'routes' },
999
+ { name: `${'Custom Providers'.padEnd(18)} ${dim(summaries.providers)}`, value: 'providers' },
1000
+ { name: `${'Backup & Restore'.padEnd(18)} ${dim(summaries.backups)}`, value: 'backup' },
1001
+ { name: dim('Done'), value: 'done' },
1002
+ ]
1003
+
1004
+ let choice
1005
+ try {
1006
+ choice = await select({ message: 'Configure', choices, theme })
1007
+ } catch (err) {
1008
+ if (isCancelled(err)) break
1009
+ throw err
1010
+ }
1011
+ if (choice === 'done') break
1012
+
1013
+ try {
1014
+ switch (choice) {
1015
+ case 'repo': await menuActiveRepo(); break
1016
+ case 'provider': await menuActiveProvider(); break
1017
+ case 'keys': await menuAPIKeys(); break
1018
+ case 'budgets': await menuTokenBudgets(); break
1019
+ case 'template': await menuDefaultTemplate(); break
1020
+ case 'safety': await menuSafety(); break
1021
+ case 'routes': await menuRoutingRules(); break
1022
+ case 'providers': await menuProviders(); break
1023
+ case 'backup': await menuBackupRestore(); break
1024
+ }
1025
+ } catch (err) {
1026
+ if (isCancelled(err)) continue
1027
+ console.error(red(` Error: ${err.message}`))
1028
+ }
1029
+
1030
+ console.log()
1031
+ }
1032
+ }
1033
+
61
1034
  // ---------------------------------------------------------------------------
62
1035
  // Command
63
1036
  // ---------------------------------------------------------------------------
64
1037
 
65
1038
  export const configCommand = new Command('config')
66
1039
  .description('Get or set persistent configuration values')
1040
+ .argument('[key]', 'Config key (dot notation, e.g. ai.provider)')
1041
+ .argument('[value]', 'Value to set (omit to get)')
1042
+ .action(async (key, rawValue) => {
1043
+ if (!key) {
1044
+ await runWizard()
1045
+ return
1046
+ }
1047
+
1048
+ if (rawValue === undefined) {
1049
+ const cfg = loadConfig()
1050
+ const value = getNestedValue(cfg, key)
1051
+ if (value === undefined) {
1052
+ console.error(red(`Unknown config key: ${key}`))
1053
+ process.exit(1)
1054
+ }
1055
+ if (key.startsWith('ai.keys.') && value && typeof value === 'string') {
1056
+ console.log(maskKey(value))
1057
+ } else if (typeof value === 'object') {
1058
+ console.log(JSON.stringify(value, null, 2))
1059
+ } else {
1060
+ console.log(value)
1061
+ }
1062
+ return
1063
+ }
1064
+
1065
+ let value
1066
+ try {
1067
+ value = coerceValue(key, rawValue)
1068
+ } catch (err) {
1069
+ console.error(red(err.message))
1070
+ process.exit(1)
1071
+ }
1072
+ const current = readUserConfig()
1073
+ const updated = setNestedValue(current, key, value)
1074
+ writeUserConfig(updated)
1075
+ const display = key.startsWith('ai.keys.') && typeof value === 'string'
1076
+ ? maskKey(value)
1077
+ : typeof value === 'object' ? JSON.stringify(value) : value
1078
+ console.log(green(`✔ Set ${key} = ${display}`))
1079
+ })
67
1080
 
68
1081
  configCommand
69
1082
  .command('get <key>')
@@ -72,10 +1085,16 @@ configCommand
72
1085
  const cfg = loadConfig()
73
1086
  const value = getNestedValue(cfg, key)
74
1087
  if (value === undefined) {
75
- console.error(chalk.red(`Unknown config key: ${key}`))
1088
+ console.error(red(`Unknown config key: ${key}`))
76
1089
  process.exit(1)
77
1090
  }
78
- console.log(value)
1091
+ if (key.startsWith('ai.keys.') && value && typeof value === 'string') {
1092
+ console.log(maskKey(value))
1093
+ } else if (typeof value === 'object') {
1094
+ console.log(JSON.stringify(value, null, 2))
1095
+ } else {
1096
+ console.log(value)
1097
+ }
79
1098
  })
80
1099
 
81
1100
  configCommand
@@ -86,13 +1105,16 @@ configCommand
86
1105
  try {
87
1106
  value = coerceValue(key, rawValue)
88
1107
  } catch (err) {
89
- console.error(chalk.red(err.message))
1108
+ console.error(red(err.message))
90
1109
  process.exit(1)
91
1110
  }
92
1111
  const current = readUserConfig()
93
1112
  const updated = setNestedValue(current, key, value)
94
1113
  writeUserConfig(updated)
95
- console.log(chalk.green(`✔ Set ${key} = ${value}`))
1114
+ const display = key.startsWith('ai.keys.') && typeof value === 'string'
1115
+ ? maskKey(value)
1116
+ : typeof value === 'object' ? JSON.stringify(value) : value
1117
+ console.log(green(`✔ Set ${key} = ${display}`))
96
1118
  })
97
1119
 
98
1120
  configCommand
@@ -100,5 +1122,6 @@ configCommand
100
1122
  .description('Show all resolved config values')
101
1123
  .action(() => {
102
1124
  const cfg = loadConfig()
103
- console.log(JSON.stringify(cfg, null, 2))
1125
+ const masked = maskKeysForDisplay(cfg)
1126
+ console.log(JSON.stringify(masked, null, 2))
104
1127
  })