tissues 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ )
@@ -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
  })
@@ -4,7 +4,9 @@ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
4
4
  import { getSafetyStatus, forceReset } from '../lib/safety.js'
5
5
  import { getDb } from '../lib/db.js'
6
6
  import { getCircuitState, countRecentEvents, probeCircuit } from '../lib/db.js'
7
- import chalk from 'chalk'
7
+ import { countPending } from '../lib/drafts.js'
8
+ import { getAuthStatus } from '../lib/gh.js'
9
+ import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
8
10
 
9
11
  /**
10
12
  * Query fingerprint count and last creation time for a repo from the DB.
@@ -66,13 +68,24 @@ function timeAgo(isoString) {
66
68
  * @returns {string}
67
69
  */
68
70
  function colorCircuitState(state) {
69
- if (state === 'closed') return chalk.green('closed') + ' ' + chalk.green('✓')
70
- if (state === 'open') return chalk.red('open') + ' ' + chalk.red('✗')
71
- return chalk.yellow('half-open') + ' ' + chalk.yellow('~')
71
+ if (state === 'closed') return green('closed') + ' ' + green('✓')
72
+ if (state === 'open') return red('open') + ' ' + red('✗')
73
+ return yellow('half-open') + ' ' + yellow('~')
74
+ }
75
+
76
+ function maskKey(value) {
77
+ if (!value || typeof value !== 'string') return null
78
+ if (value.length <= 4) return '****'
79
+ return '****' + value.slice(-4)
80
+ }
81
+
82
+ function formatBudget(val) {
83
+ if (val === 0 || !val) return 'unlimited'
84
+ return val.toLocaleString()
72
85
  }
73
86
 
74
87
  export const statusCommand = new Command('status')
75
- .description('Show safety status (circuit breaker + rate limits) for the active repo')
88
+ .description('Show auth, safety, and config status')
76
89
  .option('--repo <repo>', 'Repository override (owner/name)')
77
90
  .option('--agent <name>', "Check a specific agent's status (default: 'human')", 'human')
78
91
  .option('--reset', 'Force-reset the circuit breaker to closed')
@@ -83,19 +96,59 @@ export const statusCommand = new Command('status')
83
96
  }
84
97
 
85
98
  if (!repo) {
86
- console.error(chalk.red('No active repo. Set one with: tissues use <owner/repo>'))
99
+ console.error(red('No active repo. Set one with: tissues config'))
87
100
  process.exit(1)
88
101
  }
89
102
 
103
+ const repoRoot = findRepoRoot()
104
+ const config = loadConfig(repoRoot)
90
105
  const agent = opts.agent
91
106
 
107
+ // --- Auth ---
108
+ console.log(bold('Auth'))
109
+ try {
110
+ const status = getAuthStatus()
111
+ const active = status.accounts?.find((a) => a.active)
112
+ if (active) {
113
+ console.log(` GitHub: ${green('✔')} ${active.login}`)
114
+ } else {
115
+ console.log(` GitHub: ${red('✗')} not logged in`)
116
+ }
117
+ } catch {
118
+ console.log(` GitHub: ${red('✗')} gh CLI not available`)
119
+ }
120
+ console.log(` Active repo: ${cyan(repo)}`)
121
+
122
+ // --- AI config ---
123
+ console.log()
124
+ console.log(bold('AI'))
125
+ const aiEnabled = config.ai?.enabled !== false
126
+ console.log(` Enabled: ${aiEnabled ? green('yes') : dim('no')}`)
127
+ if (aiEnabled) {
128
+ console.log(` Provider: ${config.ai?.provider || dim('not set')}`)
129
+ const providers = ['anthropic', 'openai', 'gemini']
130
+ const keys = providers.filter((p) => config.ai?.keys?.[p]).map((p) => `${p} (${maskKey(config.ai.keys[p])})`)
131
+ console.log(` Keys: ${keys.length > 0 ? keys.join(', ') : dim('none')}`)
132
+ const budgets = config.ai?.budgets || {}
133
+ console.log(` Budgets: ${formatBudget(budgets.maxTokensPerRequest)}/req, ${formatBudget(budgets.maxTokensPerHour)}/hr, ${formatBudget(budgets.maxTokensPerDay)}/day`)
134
+ const routes = config.ai?.routes || []
135
+ console.log(` Routes: ${routes.length > 0 ? `${routes.length} rule${routes.length === 1 ? '' : 's'}` : dim('none')}`)
136
+ }
137
+
138
+ // --- Template ---
139
+ console.log()
140
+ console.log(bold('Templates'))
141
+ console.log(` Default: ${config.templates?.default || 'default'}`)
142
+
92
143
  // Handle --reset before displaying status
93
144
  if (opts.reset) {
94
145
  forceReset(repo, agent)
95
- console.log(chalk.green(`Circuit breaker reset to closed for ${repo} / ${agent}`))
146
+ console.log(green(`\nCircuit breaker reset to closed for ${repo} / ${agent}`))
96
147
  }
97
148
 
98
- const config = loadConfig(findRepoRoot())
149
+ // --- Safety ---
150
+ console.log()
151
+ console.log(bold('Safety'))
99
152
  const safetyCfg = config.safety ?? {}
100
153
 
101
154
  const cfg = {
@@ -130,7 +183,7 @@ export const statusCommand = new Command('status')
130
183
  const agentHourRemaining = Math.max(0, cfg.maxPerHour - agentHourCount)
131
184
  const globalHourRemaining = Math.max(0, cfg.globalMaxPerHour - globalHourCount)
132
185
 
133
- console.log(`Circuit Breaker: ${colorCircuitState(circuitState)}`)
186
+ console.log(` Circuit: ${colorCircuitState(circuitState)}`)
134
187
  if (circuitState === 'open' && circuit.cooldownUntil) {
135
188
  const until = new Date(
136
189
  circuit.cooldownUntil.endsWith('Z') ? circuit.cooldownUntil : circuit.cooldownUntil + 'Z'
@@ -138,29 +191,38 @@ export const statusCommand = new Command('status')
138
191
  const diffMs = until - Date.now()
139
192
  if (diffMs > 0) {
140
193
  const mins = Math.ceil(diffMs / 60_000)
141
- console.log(chalk.dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
194
+ console.log(dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
142
195
  }
143
196
  }
144
197
 
145
198
  console.log(
146
- `Rate Limit: ${agentHourCount}/${cfg.maxPerHour} per hour` +
147
- chalk.dim(` (${agentHourRemaining} remaining)`)
199
+ ` Rate: ${agentHourCount}/${cfg.maxPerHour} per hour` +
200
+ dim(` (${agentHourRemaining} remaining)`)
148
201
  )
149
202
  console.log(
150
- `Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
203
+ ` Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
151
204
  )
152
205
  console.log(
153
- `Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
154
- chalk.dim(` (${globalHourRemaining} remaining)`)
206
+ ` Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
207
+ dim(` (${globalHourRemaining} remaining)`)
155
208
  )
156
209
 
157
210
  console.log()
158
-
159
211
  if (lastCreatedAt) {
160
- console.log(`Last issue created: ${timeAgo(lastCreatedAt)}`)
212
+ console.log(`Last issue: ${timeAgo(lastCreatedAt)}`)
161
213
  } else {
162
- console.log(chalk.dim('Last issue created: never'))
214
+ console.log(dim('Last issue: never'))
163
215
  }
164
216
 
165
- console.log(`Fingerprints stored: ${fingerprintCount}`)
217
+ console.log(`Fingerprints: ${fingerprintCount}`)
218
+
219
+ const pendingCount = countPending(repoRoot)
220
+ if (pendingCount > 0) {
221
+ console.log(
222
+ cyan(`Drafts: ${pendingCount} pending`) +
223
+ dim(' (run ') + cyan('tissues drafts') + dim(' to manage)'),
224
+ )
225
+ } else {
226
+ console.log(dim('Drafts: empty'))
227
+ }
166
228
  })
@@ -0,0 +1,157 @@
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 { listTemplates, loadTemplate, builtInTemplateKeys } from '../lib/templates.js'
8
+ import { findRepoRoot, loadConfig } from '../lib/defaults.js'
9
+ import { theme } from '../lib/theme.js'
10
+
11
+ function isCancelled(err) {
12
+ return err?.name === 'ExitPromptError' || err?.message?.includes('User force closed')
13
+ }
14
+
15
+ async function promptOrBack(fn) {
16
+ try { return await fn() } catch (err) { if (isCancelled(err)) return Symbol.for('back'); throw err }
17
+ }
18
+
19
+ function userTemplateDir() {
20
+ return path.join(os.homedir(), '.config', 'tissues', 'templates')
21
+ }
22
+
23
+ export const templatesCommand = new Command('templates')
24
+ .description('Manage issue templates (view, edit, create)')
25
+ .action(async () => {
26
+ while (true) {
27
+ const repoRoot = findRepoRoot()
28
+ const templates = listTemplates(repoRoot)
29
+ const builtInKeys = builtInTemplateKeys()
30
+
31
+ // Deduplicate by key
32
+ const seen = new Set()
33
+ const choices = []
34
+ for (const tpl of templates) {
35
+ if (!seen.has(tpl.key)) {
36
+ seen.add(tpl.key)
37
+ choices.push({
38
+ name: `${tpl.key.padEnd(14)} ${dim(tpl.name)} ${dim(`(${tpl.source})`)}`,
39
+ value: tpl.key,
40
+ })
41
+ }
42
+ }
43
+ choices.push({ name: green('Create New Template'), value: '_create' })
44
+ choices.push({ name: dim('Done'), value: 'done' })
45
+
46
+ const chosen = await promptOrBack(() => select({ message: 'Templates', choices, theme }))
47
+ if (chosen === Symbol.for('back') || chosen === 'done') break
48
+
49
+ if (chosen === '_create') {
50
+ await createNewTemplate(builtInKeys, seen)
51
+ continue
52
+ }
53
+
54
+ // View / edit existing template
55
+ await editTemplate(chosen, repoRoot, builtInKeys)
56
+ console.log()
57
+ }
58
+ })
59
+
60
+ async function createNewTemplate(builtInKeys, existingKeys) {
61
+ const name = await promptOrBack(() => input({ message: 'Template name (lowercase, no spaces)', theme }))
62
+ if (name === Symbol.for('back') || !name) return
63
+
64
+ const key = name.trim().toLowerCase().replace(/\s+/g, '-')
65
+ if (!key) return
66
+
67
+ // Block reusing exact built-in names
68
+ if (builtInKeys.includes(key)) {
69
+ console.log(red(` "${key}" is a built-in template. Use edit to customize it instead.`))
70
+ return
71
+ }
72
+
73
+ // Block duplicates
74
+ if (existingKeys.has(key)) {
75
+ console.log(red(` Template "${key}" already exists.`))
76
+ return
77
+ }
78
+
79
+ const body = await promptOrBack(() =>
80
+ editor({
81
+ message: 'Template body (opens editor)',
82
+ default: `## ${key.charAt(0).toUpperCase() + key.slice(1)}\n\n{{description}}\n\n## Details\n\n`,
83
+ theme,
84
+ }),
85
+ )
86
+ if (body === Symbol.for('back') || !body) return
87
+
88
+ const dir = userTemplateDir()
89
+ fs.mkdirSync(dir, { recursive: true })
90
+ const filePath = path.join(dir, `${key}.md`)
91
+ fs.writeFileSync(filePath, body, 'utf8')
92
+ console.log(green(` ✔ Template "${key}" created: ${filePath}`))
93
+ }
94
+
95
+ async function editTemplate(key, repoRoot, builtInKeys) {
96
+ let tpl
97
+ try {
98
+ tpl = loadTemplate(key, repoRoot)
99
+ } catch (err) {
100
+ console.log(red(` ${err.message}`))
101
+ return
102
+ }
103
+
104
+ console.log(`\n ${tpl.name} ${dim(`(${tpl.source})`)}`)
105
+ console.log(dim(' ─'.repeat(20)))
106
+ // Show preview (truncated)
107
+ const preview = tpl.body.split('\n').slice(0, 8).join('\n')
108
+ console.log(dim(preview))
109
+ if (tpl.body.split('\n').length > 8) console.log(dim(' ...'))
110
+ console.log()
111
+
112
+ const actionChoices = [
113
+ { name: 'Edit', value: 'edit' },
114
+ ]
115
+ if (builtInKeys.includes(key) && tpl.source === 'built-in') {
116
+ actionChoices[0] = { name: 'Customize (creates user copy)', value: 'edit' }
117
+ }
118
+ // Only allow deleting user templates
119
+ const userFile = path.join(userTemplateDir(), `${key}.md`)
120
+ if (fs.existsSync(userFile)) {
121
+ actionChoices.push({ name: red('Delete user copy'), value: 'delete' })
122
+ }
123
+ actionChoices.push({ name: dim('Back'), value: 'back' })
124
+
125
+ const action = await promptOrBack(() => select({ message: tpl.name, choices: actionChoices, theme }))
126
+ if (action === Symbol.for('back') || action === 'back') return
127
+
128
+ if (action === 'delete') {
129
+ fs.unlinkSync(userFile)
130
+ console.log(green(` ✔ User copy of "${key}" deleted`))
131
+ if (builtInKeys.includes(key)) {
132
+ console.log(dim(` Built-in "${key}" will be used again.`))
133
+ }
134
+ return
135
+ }
136
+
137
+ // Edit — open in editor with current body
138
+ const newBody = await promptOrBack(() =>
139
+ editor({
140
+ message: `Edit ${key}`,
141
+ default: tpl.body,
142
+ theme,
143
+ }),
144
+ )
145
+ if (newBody === Symbol.for('back') || !newBody) return
146
+
147
+ // Save as user template (even if editing a built-in — creates a user override)
148
+ const dir = userTemplateDir()
149
+ fs.mkdirSync(dir, { recursive: true })
150
+ fs.writeFileSync(path.join(dir, `${key}.md`), newBody, 'utf8')
151
+
152
+ if (tpl.source === 'built-in') {
153
+ console.log(green(` ✔ User copy of "${key}" created (overrides built-in)`))
154
+ } else {
155
+ console.log(green(` ✔ Template "${key}" updated`))
156
+ }
157
+ }