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.
- package/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -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 +68 -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 +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -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 +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- 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, 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
|
-
|
|
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}`)
|
|
@@ -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(
|
|
1088
|
+
console.error(red(`Unknown config key: ${key}`))
|
|
76
1089
|
process.exit(1)
|
|
77
1090
|
}
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1125
|
+
const masked = maskKeysForDisplay(cfg)
|
|
1126
|
+
console.log(JSON.stringify(masked, null, 2))
|
|
104
1127
|
})
|