tissues 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +94 -40
  2. package/package.json +3 -4
  3. package/src/cli.js +26 -22
  4. package/src/commands/ai.js +268 -0
  5. package/src/commands/auth.js +4 -4
  6. package/src/commands/config.js +1035 -12
  7. package/src/commands/create.js +523 -157
  8. package/src/commands/drafts.js +288 -0
  9. package/src/commands/enhancements.js +282 -0
  10. package/src/commands/list.js +7 -5
  11. package/src/commands/status.js +81 -19
  12. package/src/commands/templates.js +157 -0
  13. package/src/lib/ai/adapters/anthropic.js +52 -0
  14. package/src/lib/ai/adapters/base.js +45 -0
  15. package/src/lib/ai/adapters/command.js +68 -0
  16. package/src/lib/ai/adapters/gemini.js +56 -0
  17. package/src/lib/ai/adapters/ollama.js +60 -0
  18. package/src/lib/ai/adapters/openai-compat.js +51 -0
  19. package/src/lib/ai/adapters/openai.js +44 -0
  20. package/src/lib/ai/body-template.js +75 -0
  21. package/src/lib/ai/enhance.js +107 -0
  22. package/src/lib/ai/enhancement-adapter.js +109 -0
  23. package/src/lib/ai/index.js +122 -0
  24. package/src/lib/ai/pipeline.js +97 -0
  25. package/src/lib/ai/prompt.js +39 -0
  26. package/src/lib/ai/router.js +216 -0
  27. package/src/lib/ai/steps.js +492 -0
  28. package/src/lib/attribution.js +18 -179
  29. package/src/lib/clipboard.js +147 -0
  30. package/src/lib/color.js +9 -0
  31. package/src/lib/dedup.js +67 -32
  32. package/src/lib/defaults.js +54 -2
  33. package/src/lib/drafts.js +439 -0
  34. package/src/lib/enhancements.js +436 -0
  35. package/src/lib/gh.js +102 -21
  36. package/src/lib/repo-picker.js +2 -0
  37. package/src/lib/safety.js +1 -1
  38. package/src/lib/templates.js +8 -12
  39. package/src/lib/theme.js +9 -0
  40. package/src/commands/use.js +0 -19
@@ -0,0 +1,288 @@
1
+ import { Command } from 'commander'
2
+ import { store } from '../lib/config.js'
3
+ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
4
+ import { readDrafts, markComplete, markDuplicate, markFailed, countPending } from '../lib/drafts.js'
5
+ import { checkSafety, recordSuccess, recordFailure } from '../lib/safety.js'
6
+ import { checkDuplicate, recordCreation } from '../lib/dedup.js'
7
+ import { createIssue, verifyIssue } from '../lib/gh.js'
8
+ import { bold, dim, red } from '../lib/color.js'
9
+ import ora from 'ora'
10
+ import { select, confirm } from '@inquirer/prompts'
11
+ import { theme } from '../lib/theme.js'
12
+
13
+ function isCancelled(err) {
14
+ return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
15
+ }
16
+
17
+ async function promptOrBack(fn) {
18
+ try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
19
+ }
20
+
21
+ function timeAgo(isoString) {
22
+ const ts = new Date(isoString)
23
+ const diffMs = Date.now() - ts.getTime()
24
+ if (diffMs < 0) return 'just now'
25
+ const seconds = Math.floor(diffMs / 1000)
26
+ if (seconds < 60) return `${seconds}s ago`
27
+ const minutes = Math.floor(diffMs / 60_000)
28
+ if (minutes < 60) return `${minutes}m ago`
29
+ const hours = Math.floor(diffMs / 3_600_000)
30
+ if (hours < 24) return `${hours}h ago`
31
+ const days = Math.floor(diffMs / 86_400_000)
32
+ return `${days}d ago`
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Publish logic — creates GitHub issues from pending drafts
37
+ // ---------------------------------------------------------------------------
38
+
39
+ async function publishItems(repo, repoRoot, config, agent) {
40
+ const allItems = readDrafts(repoRoot)
41
+ const flushable = new Set(['pending', 'draft'])
42
+ const items = allItems.filter((item) => item.repo === repo && flushable.has(item.status))
43
+
44
+ if (items.length === 0) {
45
+ const total = countPending(repoRoot)
46
+ if (total > 0) {
47
+ console.log(dim(`No pending items for ${repo}.`))
48
+ console.log(dim(`(${total} item(s) belong to other repos or have failed)`))
49
+ } else {
50
+ console.log(dim('No drafts to publish.'))
51
+ }
52
+ return
53
+ }
54
+
55
+ console.log(bold(`Publishing ${items.length} issue${items.length === 1 ? '' : 's'}...\n`))
56
+
57
+ const flushed = []
58
+ const skipped = []
59
+
60
+ for (const item of items) {
61
+ const safetyResult = checkSafety(repo, agent, config.safety)
62
+ if (!safetyResult.allowed) {
63
+ const remaining = items.length - flushed.length - skipped.length
64
+ console.log()
65
+ console.log(dim(` ⏸ Sent ${flushed.length}/${items.length}. Rate limit reached — ${remaining} still in drafts.`))
66
+ return
67
+ }
68
+
69
+ const dedupSpinner = ora(`Checking: "${item.title}"`).start()
70
+ let dedupResult
71
+ try {
72
+ dedupResult = await checkDuplicate(repo, { title: item.title, body: item.body, agent })
73
+ dedupSpinner.stop()
74
+ } catch (err) {
75
+ dedupSpinner.warn(`Dedup failed (skipping): ${err.message}`)
76
+ dedupResult = { action: 'allow', results: [] }
77
+ }
78
+
79
+ if (dedupResult.action === 'block') {
80
+ recordFailure(repo, agent, config.safety)
81
+ console.log(dim(` ⊘ Skipping "${item.title}" — duplicate`))
82
+ const reason = dedupResult.results.filter((r) => r.action === 'block').map((r) => r.reason).join('; ')
83
+ markDuplicate(item.id, reason, repoRoot)
84
+ skipped.push(item)
85
+ continue
86
+ }
87
+
88
+ const createSpinner = ora(`Creating: "${item.title}"`).start()
89
+ let issue
90
+ try {
91
+ issue = createIssue(repo, { title: item.title, body: item.body, labels: item.labels ?? [] })
92
+ createSpinner.succeed(`Created: ${issue.url}`)
93
+ } catch (err) {
94
+ createSpinner.fail(`Failed: "${item.title}" — ${err.message}`)
95
+ markFailed(item.id, err.message, repoRoot)
96
+ continue
97
+ }
98
+
99
+ const verified = verifyIssue(repo, issue.number)
100
+ if (!verified) {
101
+ console.warn(dim(` #${issue.number} could not be verified — keeping in drafts`))
102
+ continue
103
+ }
104
+
105
+ markComplete(item.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
106
+ try {
107
+ await recordCreation(repo, { title: item.title, body: item.body, issueNumber: issue.number, url: issue.url, agent })
108
+ } catch { /* non-fatal */ }
109
+ recordSuccess(repo, agent)
110
+ flushed.push({ title: item.title, number: issue.number, url: issue.url })
111
+ }
112
+
113
+ console.log()
114
+ if (flushed.length > 0) {
115
+ console.log(dim(` ✓ Published ${flushed.length} issue${flushed.length === 1 ? '' : 's'}`))
116
+ }
117
+ if (skipped.length > 0) {
118
+ console.log(dim(` ⊘ Skipped ${skipped.length} duplicate${skipped.length === 1 ? '' : 's'}`))
119
+ }
120
+ const stillPending = countPending(repoRoot)
121
+ if (stillPending === 0 && flushed.length > 0) {
122
+ console.log(dim(' No drafts remaining.'))
123
+ } else if (stillPending > 0) {
124
+ console.log(dim(` ${stillPending} item(s) still in drafts.`))
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Interactive drafts management
130
+ // ---------------------------------------------------------------------------
131
+
132
+ async function manageDrafts(repo, repoRoot) {
133
+ while (true) {
134
+ const allItems = readDrafts(repoRoot)
135
+ const repoItems = allItems.filter((item) => item.repo === repo)
136
+ const flushable = new Set(['pending', 'draft'])
137
+ const pending = repoItems.filter((item) => flushable.has(item.status))
138
+ const failed = repoItems.filter((item) => item.status === 'failed')
139
+ const duplicates = repoItems.filter((item) => item.status === 'duplicate')
140
+ const aborted = repoItems.filter((item) => item.status === 'aborted')
141
+ const otherRepoCount = allItems.length - repoItems.length
142
+
143
+ console.log()
144
+ if (repoItems.length === 0 && otherRepoCount === 0) {
145
+ console.log(dim(' No drafts.'))
146
+ return
147
+ } else if (repoItems.length === 0) {
148
+ console.log(dim(` No items for ${repo}.`))
149
+ if (otherRepoCount > 0) console.log(dim(` ${otherRepoCount} in other repos`))
150
+ } else {
151
+ if (pending.length > 0) console.log(dim(` ${pending.length} pending for ${repo}`))
152
+ if (failed.length > 0) console.log(dim(` ${failed.length} failed for ${repo}`))
153
+ if (duplicates.length > 0) console.log(dim(` ${duplicates.length} duplicate for ${repo}`))
154
+ if (aborted.length > 0) console.log(dim(` ${aborted.length} aborted for ${repo}`))
155
+ if (otherRepoCount > 0) console.log(dim(` ${otherRepoCount} in other repos`))
156
+ }
157
+
158
+ const choices = []
159
+ if (pending.length > 0) choices.push({ name: `Publish ${pending.length} pending`, value: 'publish' })
160
+ if (repoItems.length > 0) choices.push({ name: 'View items', value: 'view' })
161
+ if (failed.length > 0) choices.push({ name: `Retry ${failed.length} failed`, value: 'retry' })
162
+ if (repoItems.length > 0) choices.push({ name: 'Delete all', value: 'delete-all' })
163
+ choices.push({ name: 'Done', value: 'done' })
164
+
165
+ const action = await promptOrBack(() => select({ message: 'Drafts', choices, theme }))
166
+ if (action === Symbol.for('back') || action === 'done') return
167
+
168
+ if (action === 'publish') {
169
+ const config = loadConfig(repoRoot)
170
+ const agent = config.attribution?.defaultAgent ?? 'human'
171
+ await publishItems(repo, repoRoot, config, agent)
172
+ continue
173
+ }
174
+
175
+ if (action === 'retry') {
176
+ const config = loadConfig(repoRoot)
177
+ const agent = config.attribution?.defaultAgent ?? 'human'
178
+ console.log(bold(`\nRetrying ${failed.length} failed item(s)...\n`))
179
+ for (const item of failed) {
180
+ const safetyResult = checkSafety(repo, agent, config.safety)
181
+ if (!safetyResult.allowed) {
182
+ console.log(dim(' ⏸ Rate limit — try again later.'))
183
+ break
184
+ }
185
+ const spinner = ora(`Creating: "${item.title}"`).start()
186
+ try {
187
+ const issue = createIssue(repo, { title: item.title, body: item.body, labels: item.labels ?? [] })
188
+ spinner.succeed(`Created: ${issue.url}`)
189
+ markComplete(item.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
190
+ recordSuccess(repo, agent)
191
+ } catch (err) {
192
+ spinner.fail(`Failed again: ${err.message}`)
193
+ markFailed(item.id, err.message, repoRoot)
194
+ }
195
+ }
196
+ continue
197
+ }
198
+
199
+ if (action === 'view') {
200
+ const viewChoices = repoItems.map((item) => {
201
+ const statusMap = { failed: 'FAIL', duplicate: 'DUP ', aborted: dim('ABRT'), draft: 'DRFT' }
202
+ const status = statusMap[item.status] ?? 'PEND'
203
+ return {
204
+ name: `${status} ${item.title.slice(0, 40).padEnd(40)} ${dim(timeAgo(item.createdAt))}`,
205
+ value: item.id,
206
+ }
207
+ })
208
+ viewChoices.push({ name: dim('Back'), value: 'back' })
209
+
210
+ const itemId = await promptOrBack(() => select({ message: 'Draft items', choices: viewChoices, theme }))
211
+ if (itemId === Symbol.for('back') || itemId === 'back') continue
212
+
213
+ const item = repoItems.find((i) => i.id === itemId)
214
+ if (item) {
215
+ console.log(`\n ${bold(item.title)}`)
216
+ console.log(` Status: ${item.status} Created: ${item.createdAt}`)
217
+ if (item.error) console.log(` Error: ${item.error}`)
218
+ if (item.labels?.length) console.log(` Labels: ${item.labels.join(', ')}`)
219
+ console.log(dim(' ─'.repeat(20)))
220
+ console.log(dim(item.body?.slice(0, 300)))
221
+ if (item.body?.length > 300) console.log(dim('...'))
222
+
223
+ const itemAction = await promptOrBack(() =>
224
+ select({
225
+ message: 'Action',
226
+ choices: [
227
+ { name: 'Delete this item', value: 'delete' },
228
+ { name: dim('Back'), value: 'back' },
229
+ ],
230
+ theme,
231
+ }),
232
+ )
233
+ if (itemAction === 'delete') {
234
+ markComplete(item.id, {}, repoRoot)
235
+ console.log(dim(` Removed "${item.title}"`))
236
+ }
237
+ }
238
+ continue
239
+ }
240
+
241
+ if (action === 'delete-all') {
242
+ const ok = await promptOrBack(() =>
243
+ confirm({ message: `Delete all ${repoItems.length} drafts for ${repo}?`, default: false, theme }),
244
+ )
245
+ if (ok === true) {
246
+ for (const item of repoItems) markComplete(item.id, {}, repoRoot)
247
+ console.log(dim(` Deleted ${repoItems.length} items`))
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Command
255
+ // ---------------------------------------------------------------------------
256
+
257
+ export const draftsCommand = new Command('drafts')
258
+ .description('Manage issue drafts (view, publish, retry, delete)')
259
+ .option('--repo <repo>', 'Repository override (owner/name)')
260
+ .action(async (opts) => {
261
+ let repo = opts.repo || store.get('activeRepo')
262
+ if (!repo) {
263
+ console.error(red('No active repo. Set one with: tissues config'))
264
+ process.exit(1)
265
+ }
266
+
267
+ const repoRoot = findRepoRoot()
268
+ await manageDrafts(repo, repoRoot)
269
+ })
270
+
271
+ // publish subcommand of drafts
272
+ draftsCommand.addCommand(
273
+ new Command('publish')
274
+ .description('Publish pending drafts as GitHub issues')
275
+ .option('--repo <repo>', 'Repository override')
276
+ .option('--agent <name>', 'Agent identifier (default: human)')
277
+ .action(async (opts) => {
278
+ let repo = opts.repo || store.get('activeRepo')
279
+ if (!repo) {
280
+ console.error(red('No active repo. Set one with: tissues config'))
281
+ process.exit(1)
282
+ }
283
+ const repoRoot = findRepoRoot()
284
+ const config = loadConfig(repoRoot)
285
+ const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
286
+ await publishItems(repo, repoRoot, config, agent)
287
+ }),
288
+ )
@@ -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
+ }
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander'
2
2
  import { search } from '@inquirer/prompts'
3
3
  import { store } from '../lib/config.js'
4
+ import { theme } from '../lib/theme.js'
4
5
  import { pickRepo } from '../lib/repo-picker.js'
5
6
  import { requireAuth, listIssues } from '../lib/gh.js'
6
- import chalk from 'chalk'
7
+ import { yellow, dim, cyan } from '../lib/color.js'
7
8
  import ora from 'ora'
8
9
  import { execSync } from 'child_process'
9
10
 
@@ -19,7 +20,7 @@ export const listCommand = new Command('list')
19
20
  }
20
21
 
21
22
  if (!repo) {
22
- console.log(chalk.yellow('No active repo. Pick one:\n'))
23
+ console.log(yellow('No active repo. Pick one:\n'))
23
24
  repo = await pickRepo()
24
25
  console.log()
25
26
  }
@@ -29,12 +30,12 @@ export const listCommand = new Command('list')
29
30
  spinner.stop()
30
31
 
31
32
  if (issues.length === 0) {
32
- console.log(chalk.dim('No open issues.'))
33
+ console.log(dim('No open issues.'))
33
34
  return
34
35
  }
35
36
 
36
37
  const selected = await search({
37
- message: `Open issues in ${chalk.cyan(repo)}`,
38
+ message: `Open issues in ${cyan(repo)}`,
38
39
  source: (input) => {
39
40
  const term = (input || '').toLowerCase()
40
41
  return issues
@@ -48,12 +49,13 @@ export const listCommand = new Command('list')
48
49
  value: i.url,
49
50
  }))
50
51
  },
52
+ theme,
51
53
  })
52
54
 
53
55
  // Open in browser
54
56
  try {
55
57
  execSync(`open "${selected}"`)
56
58
  } catch {
57
- console.log(`\nOpen: ${chalk.cyan(selected)}`)
59
+ console.log(`\nOpen: ${cyan(selected)}`)
58
60
  }
59
61
  })