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.
- package/README.md +93 -39
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
package/src/commands/config.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
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
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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(
|
|
1014
|
+
console.error(red(`Unknown config key: ${key}`))
|
|
76
1015
|
process.exit(1)
|
|
77
1016
|
}
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
+
const masked = maskKeysForDisplay(cfg)
|
|
1052
|
+
console.log(JSON.stringify(masked, null, 2))
|
|
104
1053
|
})
|