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,299 @@
1
+ /**
2
+ * Integration tests for flush.js.
3
+ *
4
+ * Tests the five critical flush paths:
5
+ * 1. Empty outbox → "nothing to flush" message, no exit
6
+ * 2. All items flush successfully → files deleted, summary printed
7
+ * 3. Rate limited mid-flush → partial flush, remaining files stay, exit 0
8
+ * 4. Duplicate found → file deleted, skipped in summary
9
+ * 5. createIssue() throws → file gets status=failed, continues to next
10
+ * 6. verifyIssue() returns false → file kept, warning printed
11
+ *
12
+ * Requires: --experimental-test-module-mocks (Node 22+)
13
+ *
14
+ * Run:
15
+ * node --experimental-test-module-mocks --test src/commands/flush.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
26
+ // ---------------------------------------------------------------------------
27
+
28
+ let mockSafetyAllowed = true
29
+ let mockSafetyCallCount = 0
30
+ let mockSafetyBlockAfter = Infinity // block after N successful calls
31
+ let mockCreateIssueImpl = () => ({ number: 42, url: 'https://github.com/test/repo/issues/42' })
32
+ let mockVerifyImpl = () => true
33
+ let mockDedupImpl = async () => ({ action: 'allow', results: [] })
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Module mocks — must be set up before importing module under test
37
+ // ---------------------------------------------------------------------------
38
+
39
+ mock.module('../lib/gh.js', {
40
+ namedExports: {
41
+ requireAuth: () => {},
42
+ createIssue: (...args) => mockCreateIssueImpl(...args),
43
+ verifyIssue: (...args) => mockVerifyImpl(...args),
44
+ listRepos: () => [],
45
+ listIssues: () => [],
46
+ listLabels: () => [],
47
+ },
48
+ })
49
+
50
+ mock.module('../lib/safety.js', {
51
+ namedExports: {
52
+ checkSafety: () => {
53
+ mockSafetyCallCount++
54
+ const allowed = mockSafetyCallCount <= mockSafetyBlockAfter
55
+ return {
56
+ allowed,
57
+ reason: allowed ? undefined : 'Rate limit reached',
58
+ circuitState: 'closed',
59
+ rateInfo: {},
60
+ }
61
+ },
62
+ recordSuccess: () => {},
63
+ recordFailure: () => {},
64
+ },
65
+ })
66
+
67
+ mock.module('../lib/dedup.js', {
68
+ namedExports: {
69
+ checkDuplicate: async (...args) => mockDedupImpl(...args),
70
+ recordCreation: async () => {},
71
+ computeFingerprint: (title, body) =>
72
+ Buffer.from(title + body).toString('hex').slice(0, 64),
73
+ },
74
+ })
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Import after mocks
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const { flushCommand } = await import('./flush.js')
81
+ const { writeToDrafts, readDrafts, markComplete } = await import('../lib/drafts.js')
82
+ const { store } = await import('../lib/config.js')
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Temp dir setup
86
+ // ---------------------------------------------------------------------------
87
+
88
+ let tmpDir
89
+ let originalCwd
90
+
91
+ before(() => {
92
+ originalCwd = process.cwd()
93
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-flush-test-'))
94
+ fs.mkdirSync(path.join(tmpDir, '.git'))
95
+ process.chdir(tmpDir)
96
+ store.set('activeRepo', 'test/repo')
97
+ })
98
+
99
+ after(() => {
100
+ try { process.chdir(originalCwd) } catch { /* ignore */ }
101
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
102
+ })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ class ExitError extends Error {
109
+ constructor(code) {
110
+ super(`process.exit(${code})`)
111
+ this.exitCode = code
112
+ }
113
+ }
114
+
115
+ function trapExit() {
116
+ return mock.method(process, 'exit', (code) => { throw new ExitError(code) })
117
+ }
118
+
119
+ function resetStubs() {
120
+ mockSafetyAllowed = true
121
+ mockSafetyCallCount = 0
122
+ mockSafetyBlockAfter = Infinity
123
+ mockCreateIssueImpl = (_repo, _opts) => ({ number: 42, url: 'https://github.com/test/repo/issues/42' })
124
+ mockVerifyImpl = () => true
125
+ mockDedupImpl = async () => ({ action: 'allow', results: [] })
126
+ }
127
+
128
+ async function runFlush(opts = {}) {
129
+ return flushCommand.parseAsync(['node', 'tissues', 'flush', ...Object.entries(opts).flatMap(([k, v]) => [`--${k}`, v])])
130
+ }
131
+
132
+ function clearOutbox() {
133
+ const items = readDrafts(tmpDir)
134
+ for (const item of items) markComplete(item.id, {}, tmpDir)
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Tests
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe('flush.js integration', () => {
142
+ before(() => { resetStubs(); clearOutbox() })
143
+
144
+ test('empty outbox — exits cleanly with no-op message', async () => {
145
+ clearOutbox()
146
+ // Should not throw, should not call createIssue
147
+ let createCalled = false
148
+ mockCreateIssueImpl = () => { createCalled = true; return { number: 1, url: '' } }
149
+
150
+ await runFlush()
151
+
152
+ assert.equal(createCalled, false, 'createIssue should not be called for empty outbox')
153
+ })
154
+
155
+ test('full success — all files deleted, summary shown', async () => {
156
+ resetStubs()
157
+ clearOutbox()
158
+
159
+ let issueNum = 100
160
+ mockCreateIssueImpl = (_repo, _opts) => ({
161
+ number: ++issueNum,
162
+ url: `https://github.com/test/repo/issues/${issueNum}`,
163
+ })
164
+
165
+ writeToDrafts({ repo: 'test/repo', title: 'Issue Alpha', body: 'body a' }, tmpDir)
166
+ writeToDrafts({ repo: 'test/repo', title: 'Issue Beta', body: 'body b' }, tmpDir)
167
+ writeToDrafts({ repo: 'test/repo', title: 'Issue Gamma', body: 'body c' }, tmpDir)
168
+
169
+ await runFlush()
170
+
171
+ const remaining = readDrafts(tmpDir).filter((i) => i.repo === 'test/repo')
172
+ assert.equal(remaining.length, 0, 'all outbox files should be deleted after full success')
173
+ })
174
+
175
+ test('rate limited mid-flush — partial flush, remaining files stay, exit 0', async () => {
176
+ resetStubs()
177
+ clearOutbox()
178
+
179
+ // Allow only the first createIssue call (safety blocks before 2nd)
180
+ mockSafetyBlockAfter = 1
181
+
182
+ writeToDrafts({ repo: 'test/repo', title: 'Partial One', body: '' }, tmpDir)
183
+ writeToDrafts({ repo: 'test/repo', title: 'Partial Two', body: '' }, tmpDir)
184
+ writeToDrafts({ repo: 'test/repo', title: 'Partial Three', body: '' }, tmpDir)
185
+
186
+ const exitTrap = trapExit()
187
+ let exitCode = null
188
+ try {
189
+ await runFlush()
190
+ } catch (err) {
191
+ if (err instanceof ExitError) exitCode = err.exitCode
192
+ else throw err
193
+ } finally {
194
+ exitTrap.mock.restore()
195
+ }
196
+
197
+ assert.equal(exitCode, 0, 'rate-limited flush should exit 0')
198
+
199
+ const remaining = readDrafts(tmpDir).filter(
200
+ (i) => i.repo === 'test/repo' && i.status !== 'failed',
201
+ )
202
+ assert.ok(remaining.length > 0, 'some files should remain after partial flush')
203
+ assert.ok(remaining.length < 3, 'at least one file should have been sent')
204
+
205
+ clearOutbox()
206
+ })
207
+
208
+ test('duplicate detected — file deleted, counted as skipped', async () => {
209
+ resetStubs()
210
+ clearOutbox()
211
+
212
+ mockDedupImpl = async () => ({
213
+ action: 'block',
214
+ results: [{ action: 'block', reason: 'Exact match', existingIssue: null }],
215
+ })
216
+
217
+ writeToDrafts({ repo: 'test/repo', title: 'Dupe Issue', body: 'same body' }, tmpDir)
218
+
219
+ let createCalled = false
220
+ mockCreateIssueImpl = () => { createCalled = true; return { number: 1, url: '' } }
221
+
222
+ await runFlush()
223
+
224
+ assert.equal(createCalled, false, 'createIssue should not be called for duplicates')
225
+
226
+ const remaining = readDrafts(tmpDir).filter((i) => i.title === 'Dupe Issue')
227
+ assert.equal(remaining.length, 0, 'duplicate outbox file should be deleted')
228
+
229
+ resetStubs()
230
+ })
231
+
232
+ test('createIssue throws — file gets status=failed, continues to next item', async () => {
233
+ resetStubs()
234
+ clearOutbox()
235
+
236
+ let callCount = 0
237
+ mockCreateIssueImpl = (_repo, opts) => {
238
+ callCount++
239
+ if (opts.title === 'Will Fail') throw new Error('API error')
240
+ return { number: 200 + callCount, url: `https://github.com/test/repo/issues/${200 + callCount}` }
241
+ }
242
+
243
+ writeToDrafts({ repo: 'test/repo', title: 'Will Fail', body: '' }, tmpDir)
244
+ writeToDrafts({ repo: 'test/repo', title: 'Will Succeed', body: '' }, tmpDir)
245
+
246
+ await runFlush()
247
+
248
+ const all = readDrafts(tmpDir)
249
+ const failed = all.filter((i) => i.title === 'Will Fail')
250
+ const succeeded = all.filter((i) => i.title === 'Will Succeed')
251
+
252
+ assert.equal(failed.length, 1, 'failed item should remain in outbox')
253
+ assert.equal(failed[0].status, 'failed', 'failed item should have status=failed')
254
+ assert.ok(failed[0].error, 'failed item should have error message')
255
+
256
+ assert.equal(succeeded.length, 0, 'successful item should be deleted from outbox')
257
+
258
+ clearOutbox()
259
+ })
260
+
261
+ test('verifyIssue returns false — file kept as pending', async () => {
262
+ resetStubs()
263
+ clearOutbox()
264
+
265
+ mockCreateIssueImpl = () => ({ number: 999, url: 'https://github.com/test/repo/issues/999' })
266
+ mockVerifyImpl = () => false
267
+
268
+ writeToDrafts({ repo: 'test/repo', title: 'Unverifiable', body: '' }, tmpDir)
269
+
270
+ await runFlush()
271
+
272
+ const remaining = readDrafts(tmpDir).filter((i) => i.title === 'Unverifiable')
273
+ assert.equal(remaining.length, 1, 'unverified file should remain in outbox')
274
+ assert.equal(remaining[0].status, 'pending', 'status should stay pending, not failed')
275
+
276
+ clearOutbox()
277
+ })
278
+
279
+ test('only flushes items for active repo — ignores other repos', async () => {
280
+ resetStubs()
281
+ clearOutbox()
282
+
283
+ let createCalled = 0
284
+ mockCreateIssueImpl = (_repo, _opts) => { createCalled++; return { number: createCalled, url: '' } }
285
+
286
+ writeToDrafts({ repo: 'test/repo', title: 'Mine', body: '' }, tmpDir)
287
+ writeToDrafts({ repo: 'other/repo', title: 'Not mine', body: '' }, tmpDir)
288
+
289
+ await runFlush()
290
+
291
+ assert.equal(createCalled, 1, 'should only create issues for active repo')
292
+
293
+ // Other repo item should still be there
294
+ const other = readDrafts(tmpDir).filter((i) => i.repo === 'other/repo')
295
+ assert.equal(other.length, 1, 'other-repo item should remain untouched')
296
+
297
+ clearOutbox()
298
+ })
299
+ })
@@ -3,7 +3,8 @@ import { search } from '@inquirer/prompts'
3
3
  import { store } from '../lib/config.js'
4
4
  import { theme } from '../lib/theme.js'
5
5
  import { pickRepo } from '../lib/repo-picker.js'
6
- import { requireAuth, listIssues } from '../lib/gh.js'
6
+ import { requireAuth } from '../lib/gh.js'
7
+ import { ensureFresh } from '../lib/cache.js'
7
8
  import { yellow, dim, cyan } from '../lib/color.js'
8
9
  import ora from 'ora'
9
10
  import { execSync } from 'child_process'
@@ -26,7 +27,7 @@ export const listCommand = new Command('list')
26
27
  }
27
28
 
28
29
  const spinner = ora('Fetching issues...').start()
29
- const issues = listIssues(repo)
30
+ const issues = ensureFresh(repo, 'issues', { state: 'open' })
30
31
  spinner.stop()
31
32
 
32
33
  if (issues.length === 0) {
@@ -0,0 +1,347 @@
1
+ import { Command } from 'commander'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { bold, dim, green, yellow, red, cyan } from '../lib/color.js'
5
+ import { select, input } from '@inquirer/prompts'
6
+ import { loadConfig, userConfigPath } from '../lib/defaults.js'
7
+ import { theme } from '../lib/theme.js'
8
+ import { listProviders, listAllProviders, resolveProviderAdapter, migrateProviderConfig } from '../lib/ai/router.js'
9
+ import { discoverProviders } from '../lib/ai/discovery.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function readUserConfig() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
18
+ } catch {
19
+ return {}
20
+ }
21
+ }
22
+
23
+ function writeUserConfig(obj) {
24
+ const filePath = userConfigPath()
25
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
26
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
27
+ }
28
+
29
+ const PROVIDER_TYPES = {
30
+ anthropic: 'api',
31
+ openai: 'api',
32
+ gemini: 'api',
33
+ ollama: 'api',
34
+ 'openai-compat': 'api',
35
+ command: 'cli',
36
+ 'gemini-cli': 'cli',
37
+ 'claude-cli': 'cli',
38
+ 'codex-cli': 'cli',
39
+ openclaw: 'openclaw',
40
+ }
41
+
42
+ function providerType(name) {
43
+ return PROVIDER_TYPES[name] || 'custom'
44
+ }
45
+
46
+ function statusIcon(configured) {
47
+ return configured ? green('●') : dim('○')
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Dashboard
52
+ // ---------------------------------------------------------------------------
53
+
54
+ async function showDashboard() {
55
+ const config = loadConfig()
56
+ const ai = config.ai || {}
57
+ const activeProvider = ai.provider || 'anthropic'
58
+
59
+ // Discover available providers on this system
60
+ const discovered = await discoverProviders()
61
+ const discoveredNames = new Set(discovered.map((d) => d.name))
62
+
63
+ // All known providers (built-in + custom)
64
+ const allProviders = listAllProviders(config)
65
+
66
+ console.log(bold('\n AI Providers\n'))
67
+
68
+ // Active provider
69
+ console.log(` ${green('●')} ${bold(activeProvider)} ${dim('(active)')}\n`)
70
+
71
+ // Built-in providers
72
+ console.log(dim(' Built-in:'))
73
+ const builtIn = listProviders()
74
+ for (const name of builtIn) {
75
+ if (name === 'command') continue // skip generic command adapter
76
+ try {
77
+ const { adapter } = resolveProviderAdapter(config, name)
78
+ const configured = adapter.isConfigured()
79
+ const type = providerType(name)
80
+ const active = name === activeProvider ? ` ${green('← active')}` : ''
81
+ const disc = discoveredNames.has(name) ? ` ${cyan('discovered')}` : ''
82
+ console.log(` ${statusIcon(configured)} ${name} ${dim(`[${type}]`)}${active}${disc}`)
83
+ } catch {
84
+ console.log(` ${dim('○')} ${name} ${dim('[unavailable]')}`)
85
+ }
86
+ }
87
+
88
+ // Custom providers
89
+ const customNames = allProviders.filter((n) => !builtIn.includes(n))
90
+ if (customNames.length > 0) {
91
+ console.log(dim('\n Custom:'))
92
+ for (const name of customNames) {
93
+ try {
94
+ const { adapter } = resolveProviderAdapter(config, name)
95
+ const configured = adapter.isConfigured()
96
+ const active = name === activeProvider ? ` ${green('← active')}` : ''
97
+ console.log(` ${statusIcon(configured)} ${name} ${dim('[custom]')}${active}`)
98
+ } catch {
99
+ console.log(` ${dim('○')} ${name} ${dim('[error]')}`)
100
+ }
101
+ }
102
+ }
103
+
104
+ // Discovered but not yet configured
105
+ const unconfigured = discovered.filter((d) => !allProviders.includes(d.name))
106
+ if (unconfigured.length > 0) {
107
+ console.log(dim('\n Discovered (not configured):'))
108
+ for (const d of unconfigured) {
109
+ console.log(` ${yellow('◌')} ${d.name} ${dim(`[${d.type}]`)} — ${dim(`run: tissues providers add ${d.name}`)}`)
110
+ }
111
+ }
112
+
113
+ // Routes
114
+ if (ai.routes?.length) {
115
+ console.log(dim('\n Routes:'))
116
+ for (const rule of ai.routes) {
117
+ const match = rule.match?.template
118
+ ? `template:${rule.match.template}`
119
+ : rule.match?.labels
120
+ ? `labels:${rule.match.labels.join(',')}`
121
+ : '?'
122
+ console.log(` ${dim('→')} ${match} ${dim('→')} ${rule.provider}${rule.model ? ` (${rule.model})` : ''}`)
123
+ }
124
+ }
125
+
126
+ console.log()
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Add provider
131
+ // ---------------------------------------------------------------------------
132
+
133
+ async function addProvider(name) {
134
+ const config = loadConfig()
135
+ const ai = config.ai || {}
136
+ const builtIn = listProviders()
137
+ const activeProvider = ai.provider || 'anthropic'
138
+
139
+ if (!name) {
140
+ // Interactive: discover + let user pick (exclude already-active provider)
141
+ const discovered = await discoverProviders()
142
+ const choices = discovered
143
+ .filter((d) => d.name !== activeProvider) // Don't show the already-active provider
144
+ .map((d) => ({
145
+ name: `${d.name} [${d.type}]${d.status === 'configured' ? dim(' (configured)') : ''}`,
146
+ value: d.name,
147
+ }))
148
+ if (choices.length === 0) {
149
+ console.log(yellow('No new providers discovered. Install a CLI (gemini, claude, codex) or set API keys.'))
150
+ return
151
+ }
152
+ name = await select({
153
+ message: 'Select a provider to configure:',
154
+ choices,
155
+ theme,
156
+ })
157
+ }
158
+
159
+ if (builtIn.includes(name)) {
160
+ // Built-in provider — just set as active
161
+ const current = readUserConfig()
162
+ if (!current.ai) current.ai = {}
163
+ current.ai.provider = name
164
+ writeUserConfig(current)
165
+ console.log(green(`Set ${bold(name)} as active provider.`))
166
+ return
167
+ }
168
+
169
+ // CLI or OpenClaw provider that was discovered
170
+ const discovered = await discoverProviders()
171
+ const match = discovered.find((d) => d.name === name)
172
+
173
+ if (match?.type === 'openclaw') {
174
+ const current = readUserConfig()
175
+ if (!current.ai) current.ai = {}
176
+ current.ai.openclaw = {
177
+ gatewayUrl: match.config.gatewayUrl,
178
+ token: match.config.token,
179
+ }
180
+ current.ai.provider = 'openclaw'
181
+ writeUserConfig(current)
182
+ console.log(green(`Configured OpenClaw gateway and set as active provider.`))
183
+ return
184
+ }
185
+
186
+ if (match?.type === 'cli') {
187
+ const current = readUserConfig()
188
+ if (!current.ai) current.ai = {}
189
+ current.ai.provider = name
190
+ writeUserConfig(current)
191
+ console.log(green(`Set ${bold(name)} as active provider.`))
192
+ return
193
+ }
194
+
195
+ // Custom provider — prompt for command
196
+ const command = await input({
197
+ message: `Shell command for ${name}:`,
198
+ theme,
199
+ })
200
+ if (!command) return
201
+
202
+ const current = readUserConfig()
203
+ if (!current.ai) current.ai = {}
204
+ if (!current.ai.providers) current.ai.providers = {}
205
+ current.ai.providers[name] = { command }
206
+ writeUserConfig(current)
207
+ console.log(green(`Added custom provider ${bold(name)}.`))
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Test provider
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async function testProvider(name) {
215
+ const config = loadConfig()
216
+
217
+ if (!name) {
218
+ // Interactive: let user pick from configured providers
219
+ const allProviders = listAllProviders(config)
220
+ const discovered = await discoverProviders()
221
+ const configured = discovered.filter((d) => d.status === 'configured' || allProviders.includes(d.name))
222
+
223
+ if (configured.length === 0) {
224
+ console.log(yellow('No configured providers to test. Run `tissues providers add` first.'))
225
+ return
226
+ }
227
+
228
+ const choices = configured.map((d) => ({
229
+ name: `${d.name} [${d.type}]`,
230
+ value: d.name,
231
+ }))
232
+
233
+ name = await select({
234
+ message: 'Select a provider to test:',
235
+ choices,
236
+ theme,
237
+ })
238
+ }
239
+
240
+ console.log(dim(`Testing ${name}...`))
241
+
242
+ try {
243
+ const { adapter, model } = resolveProviderAdapter(config, name)
244
+ if (!adapter.isConfigured()) {
245
+ console.error(red(`Provider ${name} is not configured.`))
246
+ process.exitCode = 1
247
+ return
248
+ }
249
+
250
+ const messages = [
251
+ { role: 'system', content: 'You are a helpful assistant. Reply in one short sentence.' },
252
+ { role: 'user', content: 'Say "hello from tissues" and nothing else.' },
253
+ ]
254
+ const response = await adapter.complete(messages, { model, maxTokens: 100 })
255
+ console.log(green('✓ Response:'), response)
256
+ } catch (err) {
257
+ console.error(red(`✗ ${err.message}`))
258
+ process.exitCode = 1
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Remove provider
264
+ // ---------------------------------------------------------------------------
265
+
266
+ async function removeProvider(name) {
267
+ const config = loadConfig()
268
+ const builtIn = listProviders()
269
+
270
+ if (!name) {
271
+ // Interactive: let user pick from custom providers only
272
+ const customProviders = migrateProviderConfig(config.ai || {})
273
+ const customNames = Object.keys(customProviders)
274
+
275
+ if (customNames.length === 0) {
276
+ console.log(yellow('No custom providers to remove.'))
277
+ return
278
+ }
279
+
280
+ const choices = customNames.map((n) => ({
281
+ name: n,
282
+ value: n,
283
+ }))
284
+
285
+ name = await select({
286
+ message: 'Select a provider to remove:',
287
+ choices,
288
+ theme,
289
+ })
290
+ }
291
+
292
+ if (builtIn.includes(name)) {
293
+ console.error(red(`Cannot remove built-in provider ${name}. Use 'tissues config set ai.provider <other>' to switch.`))
294
+ process.exitCode = 1
295
+ return
296
+ }
297
+
298
+ const current = readUserConfig()
299
+ if (current.ai?.providers?.[name]) {
300
+ delete current.ai.providers[name]
301
+ if (current.ai.provider === name) {
302
+ current.ai.provider = 'anthropic'
303
+ }
304
+ writeUserConfig(current)
305
+ console.log(green(`Removed provider ${bold(name)}.`))
306
+ } else if (current.ai?.commands?.[name]) {
307
+ delete current.ai.commands[name]
308
+ if (current.ai.provider === name) {
309
+ current.ai.provider = 'anthropic'
310
+ }
311
+ writeUserConfig(current)
312
+ console.log(green(`Removed legacy provider ${bold(name)}.`))
313
+ } else {
314
+ console.error(yellow(`Provider ${name} not found in user config.`))
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Command
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export const providersCommand = new Command('providers')
323
+ .description('Manage AI providers — discover, configure, and test')
324
+ .action(async () => {
325
+ await showDashboard()
326
+ })
327
+
328
+ providersCommand
329
+ .command('add [name]')
330
+ .description('Add or auto-configure a provider')
331
+ .action(async (name) => {
332
+ await addProvider(name)
333
+ })
334
+
335
+ providersCommand
336
+ .command('test [name]')
337
+ .description('Send a test prompt to verify a provider works')
338
+ .action(async (name) => {
339
+ await testProvider(name)
340
+ })
341
+
342
+ providersCommand
343
+ .command('remove [name]')
344
+ .description('Remove a custom provider from config')
345
+ .action(async (name) => {
346
+ await removeProvider(name)
347
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { providersCommand } from './providers.js'
4
+
5
+ describe('providersCommand', () => {
6
+ it('is a Commander command', () => {
7
+ assert.equal(providersCommand.name(), 'providers')
8
+ })
9
+
10
+ it('has description', () => {
11
+ assert.ok(providersCommand.description().length > 0)
12
+ })
13
+
14
+ it('has add subcommand', () => {
15
+ const sub = providersCommand.commands.find((c) => c.name() === 'add')
16
+ assert.ok(sub, 'add subcommand exists')
17
+ })
18
+
19
+ it('has test subcommand', () => {
20
+ const sub = providersCommand.commands.find((c) => c.name() === 'test')
21
+ assert.ok(sub, 'test subcommand exists')
22
+ })
23
+
24
+ it('has remove subcommand', () => {
25
+ const sub = providersCommand.commands.find((c) => c.name() === 'remove')
26
+ assert.ok(sub, 'remove subcommand exists')
27
+ })
28
+ })