tissues 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +12 -1
  4. package/src/commands/ai.js +149 -173
  5. package/src/commands/config.js +143 -69
  6. package/src/commands/create.js +16 -9
  7. package/src/commands/create.test.js +381 -0
  8. package/src/commands/enhancements.js +282 -0
  9. package/src/commands/flush.test.js +299 -0
  10. package/src/commands/list.js +3 -2
  11. package/src/commands/providers.js +347 -0
  12. package/src/commands/providers.test.js +28 -0
  13. package/src/commands/storage.js +167 -0
  14. package/src/commands/sync.js +225 -0
  15. package/src/daemon/sync.js +189 -0
  16. package/src/lib/ai/adapters/claude-cli.js +55 -0
  17. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  18. package/src/lib/ai/adapters/codex-cli.js +77 -0
  19. package/src/lib/ai/adapters/command.js +23 -13
  20. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  21. package/src/lib/ai/adapters/openclaw.js +91 -0
  22. package/src/lib/ai/agent-actions.js +271 -0
  23. package/src/lib/ai/agent.js +323 -0
  24. package/src/lib/ai/body-template.js +15 -0
  25. package/src/lib/ai/discovery.js +89 -0
  26. package/src/lib/ai/discovery.test.js +74 -0
  27. package/src/lib/ai/enhance.js +48 -11
  28. package/src/lib/ai/enhancement-adapter.js +109 -0
  29. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  30. package/src/lib/ai/index.js +2 -2
  31. package/src/lib/ai/pipeline.js +20 -2
  32. package/src/lib/ai/pipeline.test.js +257 -0
  33. package/src/lib/ai/prompt.test.js +30 -0
  34. package/src/lib/ai/router.js +118 -7
  35. package/src/lib/ai/router.test.js +481 -0
  36. package/src/lib/ai/steps.js +23 -3
  37. package/src/lib/ai/steps.test.js +335 -0
  38. package/src/lib/attribution.test.js +64 -0
  39. package/src/lib/cache.js +408 -0
  40. package/src/lib/db.js +42 -0
  41. package/src/lib/dedup.js +44 -48
  42. package/src/lib/dedup.test.js +227 -0
  43. package/src/lib/defaults.js +37 -1
  44. package/src/lib/defaults.test.js +217 -0
  45. package/src/lib/drafts-perf.test.js +203 -0
  46. package/src/lib/drafts.test.js +300 -0
  47. package/src/lib/enhancements.js +436 -0
  48. package/src/lib/enhancements.test.js +294 -0
  49. package/src/lib/gh.js +76 -10
  50. package/src/lib/safety.test.js +217 -0
  51. package/src/lib/storage.js +298 -0
  52. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Integration tests for the outbox wiring in create.js.
3
+ *
4
+ * These tests verify that the outbox file is written, kept, or deleted
5
+ * correctly across the four critical paths:
6
+ *
7
+ * 1. Safety blocked → file stays pending, process exits 0
8
+ * 2. createIssue() throws → file gets status='failed'
9
+ * 3. verifyIssue() returns false → file stays pending
10
+ * 4. Full success → file deleted
11
+ *
12
+ * Requires: --experimental-test-module-mocks (Node 22+)
13
+ *
14
+ * Run:
15
+ * node --experimental-test-module-mocks --test src/commands/create.test.js
16
+ */
17
+
18
+ import { test, describe, before, after, mock } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import os from 'node:os'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Mutable stubs — each test overrides these before calling runCreate
26
+ // ---------------------------------------------------------------------------
27
+
28
+ let mockSafetyAllowed = true
29
+ let mockSafetyReason = ''
30
+ let mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
31
+ let mockCreateIssueThrows = null // set to an Error to simulate failure
32
+ let mockVerifyResult = true
33
+ let mockDedupResult = { action: 'allow', results: [] }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Mock modules — must be set up before importing the module under test
37
+ // ---------------------------------------------------------------------------
38
+
39
+ mock.module('../lib/gh.js', {
40
+ namedExports: {
41
+ requireAuth: () => {},
42
+ createIssue: (_repo, _opts) => {
43
+ if (mockCreateIssueThrows) throw mockCreateIssueThrows
44
+ return mockCreateIssueResult
45
+ },
46
+ verifyIssue: (_repo, _number) => mockVerifyResult,
47
+ listLabels: () => [],
48
+ createLabel: () => {},
49
+ addLabelsToIssue: () => {},
50
+ listRepos: () => [],
51
+ listIssues: () => [],
52
+ fetchIssuesApi: () => [],
53
+ fetchLabelsApi: () => [],
54
+ uploadImageToRepo: () => ({ url: 'https://raw.githubusercontent.com/test/repo/HEAD/.tissues/images/test.png', path: '.tissues/images/test.png' }),
55
+ },
56
+ })
57
+
58
+ mock.module('../lib/safety.js', {
59
+ namedExports: {
60
+ checkSafety: () => ({
61
+ allowed: mockSafetyAllowed,
62
+ reason: mockSafetyReason || 'rate limited',
63
+ circuitState: 'closed',
64
+ rateInfo: {},
65
+ }),
66
+ recordSuccess: () => {},
67
+ recordFailure: () => {},
68
+ },
69
+ })
70
+
71
+ mock.module('../lib/dedup.js', {
72
+ namedExports: {
73
+ checkDuplicate: async () => mockDedupResult,
74
+ recordCreation: async () => {},
75
+ computeFingerprint: (title, body) => {
76
+ // simple stub fingerprint
77
+ return Buffer.from(title + body).toString('hex').slice(0, 64)
78
+ },
79
+ },
80
+ })
81
+
82
+ // Skip AI enhancement entirely
83
+ let mockCheckAvailable = () => false
84
+ mock.module('../lib/ai/index.js', {
85
+ namedExports: {
86
+ checkAvailable: (...args) => mockCheckAvailable(...args),
87
+ enhance: async (_cfg, _t, _d, body) => body,
88
+ checkBudgets: () => {},
89
+ recordUsage: () => {},
90
+ },
91
+ })
92
+
93
+ let mockPipelineResult = null
94
+ let mockPipelineThrows = null
95
+ mock.module('../lib/ai/enhance.js', {
96
+ namedExports: {
97
+ runEnhancePipeline: async () => {
98
+ if (mockPipelineThrows) throw mockPipelineThrows
99
+ return mockPipelineResult
100
+ },
101
+ },
102
+ })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Import the module under test AFTER mocks are set up
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const { runCreate } = await import('./create.js')
109
+ const { readDrafts, markComplete } = await import('../lib/drafts.js')
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Temp dir setup
113
+ // ---------------------------------------------------------------------------
114
+
115
+ let tmpDir
116
+ let originalCwd
117
+
118
+ before(() => {
119
+ originalCwd = process.cwd()
120
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-create-test-'))
121
+ fs.mkdirSync(path.join(tmpDir, '.git'))
122
+ process.chdir(tmpDir)
123
+ })
124
+
125
+ after(() => {
126
+ try { process.chdir(originalCwd) } catch { /* ignore */ }
127
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
128
+ })
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Helper: base opts that avoid interactive prompts
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function baseOpts(overrides = {}) {
135
+ return {
136
+ repo: 'test/repo',
137
+ title: 'Test issue title',
138
+ body: 'Test body',
139
+ instructions: undefined,
140
+ template: 'default',
141
+ agent: 'human',
142
+ session: null,
143
+ labels: undefined,
144
+ force: true,
145
+ dryRun: false,
146
+ enhance: false,
147
+ ...overrides,
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Helper: intercept process.exit so it doesn't kill the test runner
153
+ // ---------------------------------------------------------------------------
154
+
155
+ class ExitError extends Error {
156
+ constructor(code) {
157
+ super(`process.exit(${code})`)
158
+ this.exitCode = code
159
+ }
160
+ }
161
+
162
+ function trapExit() {
163
+ return mock.method(process, 'exit', (code) => {
164
+ throw new ExitError(code)
165
+ })
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Tests
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('create.js outbox integration', () => {
173
+ // Reset stubs before each test
174
+ before(() => {
175
+ mockSafetyAllowed = true
176
+ mockSafetyReason = ''
177
+ mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
178
+ mockCreateIssueThrows = null
179
+ mockVerifyResult = true
180
+ mockDedupResult = { action: 'allow', results: [] }
181
+ })
182
+
183
+ test('full success path — outbox file deleted after verify', async () => {
184
+ mockSafetyAllowed = true
185
+ mockVerifyResult = true
186
+
187
+ const result = await runCreate(baseOpts())
188
+
189
+ assert.ok(result, 'should return result')
190
+ assert.equal(result.number, 42)
191
+ assert.equal(result.url, 'https://github.com/test/repo/issues/42')
192
+
193
+ // Outbox file should be gone
194
+ const pending = readDrafts(tmpDir)
195
+ const mine = pending.filter((i) => i.title === 'Test issue title' && i.status !== 'failed')
196
+ assert.equal(mine.length, 0, 'outbox file should be deleted after success')
197
+ })
198
+
199
+ test('safety blocked — outbox file stays pending, exits 0', async () => {
200
+ mockSafetyAllowed = false
201
+ mockSafetyReason = 'Burst rate limit reached'
202
+
203
+ const exitTrap = trapExit()
204
+
205
+ let exitCode = null
206
+ try {
207
+ await runCreate(baseOpts({ title: 'Safety blocked issue' }))
208
+ } catch (err) {
209
+ if (err instanceof ExitError) {
210
+ exitCode = err.exitCode
211
+ } else {
212
+ throw err
213
+ }
214
+ } finally {
215
+ exitTrap.mock.restore()
216
+ mockSafetyAllowed = true
217
+ }
218
+
219
+ assert.equal(exitCode, 0, 'should exit 0 on safety block (not a failure)')
220
+
221
+ // Outbox file must still be there
222
+ const pending = readDrafts(tmpDir)
223
+ const mine = pending.filter((i) => i.title === 'Safety blocked issue')
224
+ assert.equal(mine.length, 1, 'outbox file should be kept when safety blocks')
225
+ assert.equal(mine[0].status, 'pending')
226
+
227
+ // Clean up
228
+ markComplete(mine[0].id, {}, tmpDir)
229
+ })
230
+
231
+ test('createIssue() throws — outbox file gets status=failed', async () => {
232
+ mockSafetyAllowed = true
233
+ mockCreateIssueThrows = new Error('API rate limit exceeded')
234
+
235
+ const exitTrap = trapExit()
236
+
237
+ try {
238
+ await runCreate(baseOpts({ title: 'Failed create issue' }))
239
+ } catch (err) {
240
+ if (!(err instanceof ExitError) && !err.message.includes('Failed to create issue')) {
241
+ throw err
242
+ }
243
+ } finally {
244
+ exitTrap.mock.restore()
245
+ mockCreateIssueThrows = null
246
+ }
247
+
248
+ // File should still exist with status=failed
249
+ const allItems = readDrafts(tmpDir)
250
+ const mine = allItems.filter((i) => i.title === 'Failed create issue')
251
+ assert.equal(mine.length, 1, 'outbox file should be kept when createIssue throws')
252
+ assert.equal(mine[0].status, 'failed')
253
+ assert.ok(mine[0].error, 'error field should be set')
254
+ assert.ok(mine[0].failedAt, 'failedAt should be set')
255
+
256
+ // Clean up
257
+ markComplete(mine[0].id, {}, tmpDir)
258
+ })
259
+
260
+ test('verifyIssue() returns false — outbox file kept as pending', async () => {
261
+ mockSafetyAllowed = true
262
+ mockCreateIssueResult = { number: 99, url: 'https://github.com/test/repo/issues/99' }
263
+ mockVerifyResult = false
264
+
265
+ await runCreate(baseOpts({ title: 'Unverified issue' }))
266
+
267
+ const allItems = readDrafts(tmpDir)
268
+ const mine = allItems.filter((i) => i.title === 'Unverified issue')
269
+ assert.equal(mine.length, 1, 'outbox file should remain when verify fails')
270
+ // Status should still be pending (not completed, not failed)
271
+ assert.equal(mine[0].status, 'pending')
272
+
273
+ // Clean up
274
+ markComplete(mine[0].id, {}, tmpDir)
275
+ mockVerifyResult = true
276
+ })
277
+
278
+ test('duplicate detected — no draft file created, throws', async () => {
279
+ const before = readDrafts(tmpDir).length
280
+
281
+ mockDedupResult = {
282
+ action: 'block',
283
+ results: [{ action: 'block', reason: 'Exact duplicate', existingIssue: null }],
284
+ }
285
+
286
+ await assert.rejects(
287
+ () => runCreate(baseOpts({ title: 'Duplicate issue' })),
288
+ /Duplicate detected/,
289
+ )
290
+
291
+ // No draft file should be created for dedup blocks
292
+ const after = readDrafts(tmpDir).length
293
+ assert.equal(after, before, 'no draft file should be created for duplicates')
294
+
295
+ mockDedupResult = { action: 'allow', results: [] }
296
+ })
297
+
298
+ test('dry run — no outbox file written', async () => {
299
+ const before = readDrafts(tmpDir).length
300
+
301
+ const result = await runCreate(baseOpts({ title: 'Dry run issue', dryRun: true }))
302
+
303
+ assert.equal(result, null, 'dry run should return null')
304
+ const after = readDrafts(tmpDir).length
305
+ assert.equal(after, before, 'no outbox file should be written for dry run')
306
+ })
307
+ })
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Pipeline integration tests
311
+ // ---------------------------------------------------------------------------
312
+
313
+ describe('create.js pipeline integration', () => {
314
+ before(() => {
315
+ mockSafetyAllowed = true
316
+ mockSafetyReason = ''
317
+ mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
318
+ mockCreateIssueThrows = null
319
+ mockVerifyResult = true
320
+ mockDedupResult = { action: 'allow', results: [] }
321
+ mockCheckAvailable = () => false
322
+ mockPipelineResult = null
323
+ mockPipelineThrows = null
324
+ })
325
+
326
+ test('pipeline disabled (AI unavailable) — falls back to template', async () => {
327
+ mockCheckAvailable = () => false
328
+ mockPipelineResult = null
329
+
330
+ const result = await runCreate(baseOpts({ title: 'No AI issue' }))
331
+ assert.ok(result)
332
+ assert.equal(result.number, 42)
333
+ // Clean up
334
+ const items = readDrafts(tmpDir).filter((i) => i.title === 'No AI issue')
335
+ for (const i of items) markComplete(i.id, {}, tmpDir)
336
+ })
337
+
338
+ test('--no-pipeline flag forces single-shot even if pipeline enabled', async () => {
339
+ // With AI unavailable, pipeline flag doesn't matter — body stays as template
340
+ mockCheckAvailable = () => false
341
+
342
+ const result = await runCreate(baseOpts({ title: 'No pipeline flag', pipeline: false }))
343
+ assert.ok(result)
344
+ assert.equal(result.number, 42)
345
+ })
346
+
347
+ test('pipeline failure degrades gracefully to template', async () => {
348
+ mockCheckAvailable = () => true
349
+ mockPipelineThrows = new Error('Pipeline exploded')
350
+
351
+ const result = await runCreate(baseOpts({ title: 'Pipeline fail issue' }))
352
+ assert.ok(result)
353
+ assert.equal(result.number, 42)
354
+
355
+ // Clean up
356
+ mockPipelineThrows = null
357
+ mockCheckAvailable = () => false
358
+ })
359
+
360
+ test('pipeline result title is used for GitHub issue', async () => {
361
+ mockCheckAvailable = () => true
362
+ mockPipelineResult = {
363
+ title: 'AI-refined title',
364
+ body: '## Summary\nAI body',
365
+ aiLabels: [],
366
+ dedupScore: null,
367
+ }
368
+
369
+ const result = await runCreate(baseOpts({
370
+ title: 'Original title from splitInput',
371
+ pipeline: true,
372
+ enhance: true,
373
+ }))
374
+ assert.ok(result)
375
+ assert.equal(result.number, 42)
376
+
377
+ // Clean up
378
+ mockPipelineResult = null
379
+ mockCheckAvailable = () => false
380
+ })
381
+ })
@@ -0,0 +1,282 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { dim, red, green } from '../lib/color.js'
6
+ import { select, input, editor } from '@inquirer/prompts'
7
+ import {
8
+ listEnhancements,
9
+ loadEnhancement,
10
+ builtInEnhancementKeys,
11
+ BUILT_IN_ENHANCEMENTS,
12
+ parseEnhancementFile,
13
+ } from '../lib/enhancements.js'
14
+ import { findRepoRoot, loadConfig } from '../lib/defaults.js'
15
+ import { listProviders, listAllProviders } from '../lib/ai/index.js'
16
+ import { theme } from '../lib/theme.js'
17
+
18
+ const PROVIDER_LABELS = {
19
+ anthropic: 'Anthropic',
20
+ openai: 'OpenAI',
21
+ gemini: 'Gemini',
22
+ ollama: 'Ollama',
23
+ 'openai-compat': 'OpenAI Custom',
24
+ command: 'Command',
25
+ }
26
+
27
+ const builtInProviders = new Set(listProviders())
28
+
29
+ function formatProviderName(name) {
30
+ if (!name) return null
31
+ if (builtInProviders.has(name)) return PROVIDER_LABELS[name] || name
32
+ return `${name} ${dim('(custom)')}`
33
+ }
34
+
35
+ function isCancelled(err) {
36
+ return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
37
+ }
38
+
39
+ async function promptOrBack(fn) {
40
+ try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
41
+ }
42
+
43
+ function userEnhancementDir() {
44
+ return path.join(os.homedir(), '.config', 'tissues', 'enhancements')
45
+ }
46
+
47
+ export const enhancementsCommand = new Command('enhancements')
48
+ .description('Manage AI pipeline enhancements (view, edit, create)')
49
+ .action(async () => {
50
+ while (true) {
51
+ const repoRoot = findRepoRoot()
52
+ const enhancements = listEnhancements(repoRoot)
53
+ const builtInKeys = builtInEnhancementKeys()
54
+
55
+ // Deduplicate by key (higher-priority sources shadow lower ones)
56
+ const seen = new Set()
57
+ const choices = []
58
+ for (const enh of enhancements) {
59
+ if (!seen.has(enh.key)) {
60
+ seen.add(enh.key)
61
+ const provLabel = enh.provider ? formatProviderName(enh.provider) : 'default'
62
+ choices.push({
63
+ name: `${enh.key.padEnd(18)} ${dim(provLabel)}`,
64
+ value: enh.key,
65
+ })
66
+ }
67
+ }
68
+ choices.push({ name: green('Create New Enhancement'), value: '_create' })
69
+ choices.push({ name: dim('Done'), value: 'done' })
70
+
71
+ const chosen = await promptOrBack(() => select({ message: 'Enhancements', choices, theme }))
72
+ if (chosen === Symbol.for('back') || chosen === 'done') break
73
+
74
+ if (chosen === '_create') {
75
+ await createNewEnhancement(builtInKeys, seen)
76
+ continue
77
+ }
78
+
79
+ // View / edit existing enhancement
80
+ await viewEnhancement(chosen, repoRoot, builtInKeys)
81
+ console.log()
82
+ }
83
+ })
84
+
85
+ async function createNewEnhancement(builtInKeys, existingKeys) {
86
+ const name = await promptOrBack(() => input({ message: 'Enhancement key (lowercase, no spaces)', theme }))
87
+ if (name === Symbol.for('back') || !name) return
88
+
89
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
90
+ if (!key) return
91
+
92
+ // Block reusing exact built-in names
93
+ if (builtInKeys.includes(key)) {
94
+ console.log(red(` "${key}" is a built-in enhancement. Use edit to customize it instead.`))
95
+ return
96
+ }
97
+
98
+ // Block duplicates
99
+ if (existingKeys.has(key)) {
100
+ console.log(red(` Enhancement "${key}" already exists.`))
101
+ return
102
+ }
103
+
104
+ const displayName = await promptOrBack(() =>
105
+ input({ message: 'Display name', default: key.charAt(0).toUpperCase() + key.slice(1), theme }),
106
+ )
107
+ if (displayName === Symbol.for('back')) return
108
+
109
+ const contextKey = await promptOrBack(() =>
110
+ input({ message: 'Context key (where result is stored)', default: key, theme }),
111
+ )
112
+ if (contextKey === Symbol.for('back')) return
113
+
114
+ const skeleton = [
115
+ '---',
116
+ `name: ${displayName}`,
117
+ 'maxTokens: 1024',
118
+ 'mode: auto',
119
+ 'format: json',
120
+ `contextKey: ${contextKey}`,
121
+ 'order: 50',
122
+ 'requires: []',
123
+ '---',
124
+ `You are an expert at analyzing GitHub issues.`,
125
+ `Assess the ${key} aspects of the proposed change.`,
126
+ 'Return a JSON object with your analysis.',
127
+ 'Return ONLY valid JSON.',
128
+ ].join('\n')
129
+
130
+ const body = await promptOrBack(() =>
131
+ editor({
132
+ message: 'Enhancement file (opens editor)',
133
+ default: skeleton,
134
+ theme,
135
+ }),
136
+ )
137
+ if (body === Symbol.for('back') || !body) return
138
+
139
+ const dir = userEnhancementDir()
140
+ fs.mkdirSync(dir, { recursive: true })
141
+ const filePath = path.join(dir, `${key}.md`)
142
+ fs.writeFileSync(filePath, body, 'utf8')
143
+ console.log(green(` ✔ Enhancement "${key}" created: ${filePath}`))
144
+ }
145
+
146
+ async function viewEnhancement(key, repoRoot, builtInKeys) {
147
+ let enh
148
+ try {
149
+ enh = loadEnhancement(key, repoRoot)
150
+ } catch (err) {
151
+ console.log(red(` ${err.message}`))
152
+ return
153
+ }
154
+
155
+ console.log(`\n ${enh.name} ${dim(`(${enh.source})`)}`)
156
+ console.log(dim(' ─'.repeat(20)))
157
+ console.log(dim(` Mode: ${enh.mode} Format: ${enh.format} Tokens: ${enh.maxTokens} Order: ${enh.order}`))
158
+ if (enh.provider) console.log(dim(` Provider: ${enh.provider}`))
159
+ if (enh.contextKey) console.log(dim(` Context key: ${enh.contextKey}`))
160
+ if (enh.requires?.length) console.log(dim(` Requires: ${enh.requires.join(', ')}`))
161
+ console.log()
162
+
163
+ // Show prompt preview (truncated)
164
+ const preview = enh.prompt.split('\n').slice(0, 8).join('\n')
165
+ console.log(dim(preview))
166
+ if (enh.prompt.split('\n').length > 8) console.log(dim(' ...'))
167
+ console.log()
168
+
169
+ const actionChoices = [
170
+ { name: 'Edit', value: 'edit' },
171
+ { name: `Provider ${formatProviderName(enh.provider) || dim('default')}`, value: 'provider' },
172
+ ]
173
+ if (builtInKeys.includes(key) && enh.source === 'built-in') {
174
+ actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
175
+ }
176
+ // Only allow deleting user enhancements
177
+ const userFile = path.join(userEnhancementDir(), `${key}.md`)
178
+ if (fs.existsSync(userFile)) {
179
+ actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
180
+ }
181
+ actionChoices.push({ name: dim('Back'), value: 'back' })
182
+
183
+ const action = await promptOrBack(() => select({ message: enh.name, choices: actionChoices, theme }))
184
+ if (action === Symbol.for('back') || action === 'back') return
185
+
186
+ if (action === 'provider') {
187
+ const cfg = loadConfig()
188
+ const allProviders = listAllProviders(cfg)
189
+ const providerChoices = [
190
+ { name: 'Default (use pipeline provider)', value: '_default' },
191
+ ...allProviders.map((p) => ({
192
+ name: formatProviderName(p),
193
+ value: p,
194
+ })),
195
+ { name: dim('Back'), value: 'back' },
196
+ ]
197
+
198
+ const chosen = await promptOrBack(() =>
199
+ select({
200
+ message: `Provider for ${enh.name}`,
201
+ choices: providerChoices,
202
+ default: enh.provider || '_default',
203
+ theme,
204
+ }),
205
+ )
206
+ if (chosen === Symbol.for('back') || chosen === 'back') return
207
+
208
+ const newProvider = chosen === '_default' ? null : chosen
209
+
210
+ // Update the .md file frontmatter (creates user copy if built-in)
211
+ const updatedEnh = { ...enh, provider: newProvider }
212
+ const content = buildFileContent(updatedEnh)
213
+ const dir = userEnhancementDir()
214
+ fs.mkdirSync(dir, { recursive: true })
215
+ fs.writeFileSync(path.join(dir, `${key}.md`), content, 'utf8')
216
+
217
+ if (newProvider) {
218
+ console.log(green(` ✔ ${enh.name}: → ${newProvider}`))
219
+ } else {
220
+ console.log(green(` ✔ ${enh.name}: using default provider`))
221
+ }
222
+ if (enh.source === 'built-in') {
223
+ console.log(dim(` User copy created (overrides built-in)`))
224
+ }
225
+ return
226
+ }
227
+
228
+ if (action === 'delete') {
229
+ fs.unlinkSync(userFile)
230
+ console.log(green(` ✔ User copy of "${key}" deleted`))
231
+ if (builtInKeys.includes(key)) {
232
+ console.log(dim(` Built-in "${key}" will be used again.`))
233
+ }
234
+ return
235
+ }
236
+
237
+ // Edit — build the file content from the current enhancement
238
+ const currentContent = buildFileContent(enh)
239
+
240
+ const newBody = await promptOrBack(() =>
241
+ editor({
242
+ message: `Edit ${key}`,
243
+ default: currentContent,
244
+ theme,
245
+ }),
246
+ )
247
+ if (newBody === Symbol.for('back') || !newBody) return
248
+
249
+ // Save as user enhancement
250
+ const dir = userEnhancementDir()
251
+ fs.mkdirSync(dir, { recursive: true })
252
+ fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
253
+
254
+ if (enh.source === 'built-in') {
255
+ console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
256
+ } else {
257
+ console.log(green(` ✔ Enhancement "${key}" updated`))
258
+ }
259
+ }
260
+
261
+ function buildFileContent(enh) {
262
+ const lines = [
263
+ '---',
264
+ `name: ${enh.name}`,
265
+ `maxTokens: ${enh.maxTokens}`,
266
+ `mode: ${enh.mode}`,
267
+ `format: ${enh.format}`,
268
+ `contextKey: ${enh.contextKey}`,
269
+ `order: ${enh.order}`,
270
+ ]
271
+ if (enh.provider) {
272
+ lines.push(`provider: ${enh.provider}`)
273
+ }
274
+ if (enh.requires?.length) {
275
+ lines.push(`requires: [${enh.requires.map((r) => `"${r}"`).join(', ')}]`)
276
+ } else {
277
+ lines.push('requires: []')
278
+ }
279
+ lines.push('---')
280
+ lines.push(enh.prompt)
281
+ return lines.join('\n')
282
+ }