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,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
|
+
})
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
+
})
|