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