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.
- package/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- 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
|
+
}
|