tissues 0.6.1 → 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 +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -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/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/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- 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 +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- 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.test.js +294 -0
- package/src/lib/gh.js +60 -0
- 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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command — manual one-shot sync + background daemon management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { fork } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { store } from '../lib/config.js'
|
|
12
|
+
import { requireAuth } from '../lib/gh.js'
|
|
13
|
+
import { syncIssues, syncLabels, syncRepos } from '../lib/cache.js'
|
|
14
|
+
import { pickRepo } from '../lib/repo-picker.js'
|
|
15
|
+
import { green, yellow, red, dim, cyan, bold } from '../lib/color.js'
|
|
16
|
+
import ora from 'ora'
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'tissues')
|
|
19
|
+
const PID_FILE = path.join(CONFIG_DIR, 'sync.pid')
|
|
20
|
+
const STATUS_FILE = path.join(CONFIG_DIR, 'sync-status.json')
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Daemon helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function readPid() {
|
|
27
|
+
try {
|
|
28
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10)
|
|
29
|
+
// Check if process is alive
|
|
30
|
+
process.kill(pid, 0)
|
|
31
|
+
return pid
|
|
32
|
+
} catch {
|
|
33
|
+
// Clean up stale PID file
|
|
34
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readStatus() {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordActivity() {
|
|
48
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
49
|
+
const status = readStatus() || {}
|
|
50
|
+
status.lastActivity = new Date().toISOString()
|
|
51
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Main sync command (one-shot)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export const syncCommand = new Command('sync')
|
|
59
|
+
.description('Sync issues, labels, and repos to local cache')
|
|
60
|
+
.option('--repo <repo>', 'Repository to sync (default: active repo)')
|
|
61
|
+
.option('--force', 'Force full sync, ignore freshness')
|
|
62
|
+
.option('--quiet', 'Suppress output')
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
requireAuth()
|
|
65
|
+
|
|
66
|
+
let repo = opts.repo || store.get('activeRepo')
|
|
67
|
+
if (!repo) {
|
|
68
|
+
console.log(yellow('No active repo. Pick one:\n'))
|
|
69
|
+
repo = await pickRepo()
|
|
70
|
+
console.log()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const log = opts.quiet ? () => {} : console.log
|
|
74
|
+
|
|
75
|
+
// Sync repos
|
|
76
|
+
const repoSpinner = opts.quiet ? null : ora('Syncing repos...').start()
|
|
77
|
+
try {
|
|
78
|
+
const result = syncRepos({ force: opts.force })
|
|
79
|
+
if (repoSpinner) repoSpinner.succeed(`Repos: ${result.synced} cached`)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (repoSpinner) repoSpinner.warn(`Repos sync failed: ${err.message}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sync labels
|
|
85
|
+
const labelSpinner = opts.quiet ? null : ora('Syncing labels...').start()
|
|
86
|
+
try {
|
|
87
|
+
const result = syncLabels(repo, { force: opts.force })
|
|
88
|
+
if (labelSpinner) labelSpinner.succeed(`Labels: ${result.synced} cached`)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (labelSpinner) labelSpinner.warn(`Labels sync failed: ${err.message}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sync issues (incremental)
|
|
94
|
+
const issueSpinner = opts.quiet ? null : ora('Syncing issues...').start()
|
|
95
|
+
try {
|
|
96
|
+
const result = syncIssues(repo, { force: opts.force })
|
|
97
|
+
if (issueSpinner) issueSpinner.succeed(`Issues: ${result.synced} synced (${result.total} total)`)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (issueSpinner) issueSpinner.warn(`Issues sync failed: ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log()
|
|
103
|
+
log(green('Sync complete.'))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Daemon subcommands
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const startCommand = new Command('start')
|
|
111
|
+
.description('Start the background sync daemon')
|
|
112
|
+
.action(() => {
|
|
113
|
+
const existing = readPid()
|
|
114
|
+
if (existing) {
|
|
115
|
+
console.log(yellow(`Daemon already running (PID ${existing}).`))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
120
|
+
const daemonScript = path.join(__dirname, '..', 'daemon', 'sync.js')
|
|
121
|
+
|
|
122
|
+
const child = fork(daemonScript, [], {
|
|
123
|
+
detached: true,
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
})
|
|
126
|
+
child.unref()
|
|
127
|
+
|
|
128
|
+
console.log(green(`Sync daemon started (PID ${child.pid}).`))
|
|
129
|
+
console.log(dim('Run `tissues sync status` to check on it.'))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const stopCommand = new Command('stop')
|
|
133
|
+
.description('Stop the background sync daemon')
|
|
134
|
+
.action(() => {
|
|
135
|
+
const pid = readPid()
|
|
136
|
+
if (!pid) {
|
|
137
|
+
console.log(dim('No daemon running.'))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
process.kill(pid, 'SIGTERM')
|
|
143
|
+
console.log(green(`Daemon stopped (PID ${pid}).`))
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(yellow(`Could not stop daemon: ${err.message}`))
|
|
146
|
+
// Clean up stale PID file
|
|
147
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const restartCommand = new Command('restart')
|
|
152
|
+
.description('Restart the background sync daemon')
|
|
153
|
+
.action(async () => {
|
|
154
|
+
// Stop
|
|
155
|
+
const pid = readPid()
|
|
156
|
+
if (pid) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 'SIGTERM')
|
|
159
|
+
console.log(dim(`Stopped daemon (PID ${pid}).`))
|
|
160
|
+
// Brief wait for cleanup
|
|
161
|
+
await new Promise(r => setTimeout(r, 500))
|
|
162
|
+
} catch { /* ok */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Start
|
|
166
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
167
|
+
const daemonScript = path.join(__dirname, '..', 'daemon', 'sync.js')
|
|
168
|
+
|
|
169
|
+
const child = fork(daemonScript, [], {
|
|
170
|
+
detached: true,
|
|
171
|
+
stdio: 'ignore',
|
|
172
|
+
})
|
|
173
|
+
child.unref()
|
|
174
|
+
|
|
175
|
+
console.log(green(`Sync daemon restarted (PID ${child.pid}).`))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const statusCommand = new Command('status')
|
|
179
|
+
.description('Show background sync daemon status')
|
|
180
|
+
.action(() => {
|
|
181
|
+
const pid = readPid()
|
|
182
|
+
const status = readStatus()
|
|
183
|
+
|
|
184
|
+
if (!pid && !status) {
|
|
185
|
+
console.log(dim('No daemon has been started.'))
|
|
186
|
+
console.log(dim('Run `tissues sync start` to begin background syncing.'))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(bold('\nSync Daemon'))
|
|
191
|
+
if (pid) {
|
|
192
|
+
console.log(` Status: ${green('running')} (PID ${pid})`)
|
|
193
|
+
} else {
|
|
194
|
+
console.log(` Status: ${dim(status?.status || 'stopped')}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (status) {
|
|
198
|
+
if (status.startedAt) console.log(` Started: ${dim(status.startedAt)}`)
|
|
199
|
+
if (status.lastSync) console.log(` Last sync: ${dim(status.lastSync)}`)
|
|
200
|
+
if (status.nextSync) console.log(` Next sync: ${dim(status.nextSync)}`)
|
|
201
|
+
if (status.lastError) console.log(` Last error: ${red(status.lastError)}`)
|
|
202
|
+
|
|
203
|
+
if (status.perRepoStats) {
|
|
204
|
+
console.log(bold('\n Per-repo stats:'))
|
|
205
|
+
for (const [repo, stats] of Object.entries(status.perRepoStats)) {
|
|
206
|
+
if (stats.status === 'ok') {
|
|
207
|
+
console.log(` ${cyan(repo)}: ${stats.issues} issues, ${stats.labels} labels`)
|
|
208
|
+
} else if (stats.status === 'error') {
|
|
209
|
+
console.log(` ${cyan(repo)}: ${red(stats.error)}`)
|
|
210
|
+
} else if (stats.status === 'skipped') {
|
|
211
|
+
console.log(` ${cyan(repo)}: ${yellow(stats.reason)}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
console.log()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
syncCommand.addCommand(startCommand)
|
|
220
|
+
syncCommand.addCommand(stopCommand)
|
|
221
|
+
syncCommand.addCommand(restartCommand)
|
|
222
|
+
syncCommand.addCommand(statusCommand)
|
|
223
|
+
|
|
224
|
+
// Export recordActivity for use in preAction hook
|
|
225
|
+
export { recordActivity }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Background sync daemon for tissues.
|
|
5
|
+
*
|
|
6
|
+
* Keeps the local cache warm by periodically syncing issues, labels,
|
|
7
|
+
* and repos in the background. Runs as a detached child process.
|
|
8
|
+
*
|
|
9
|
+
* PID file: ~/.config/tissues/sync.pid
|
|
10
|
+
* Status file: ~/.config/tissues/sync-status.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import { syncIssues, syncLabels, syncRepos } from '../lib/cache.js'
|
|
17
|
+
import { getToken } from '../lib/gh.js'
|
|
18
|
+
import { loadConfig } from '../lib/defaults.js'
|
|
19
|
+
import { store } from '../lib/config.js'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Paths
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'tissues')
|
|
26
|
+
const PID_FILE = path.join(CONFIG_DIR, 'sync.pid')
|
|
27
|
+
const STATUS_FILE = path.join(CONFIG_DIR, 'sync-status.json')
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Status file operations
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function readStatus() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeStatus(updates) {
|
|
42
|
+
const current = readStatus()
|
|
43
|
+
const merged = { ...current, ...updates }
|
|
44
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
45
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(merged, null, 2), 'utf8')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Adaptive interval
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function getInterval(config) {
|
|
53
|
+
const syncConfig = config.sync || {}
|
|
54
|
+
const baseInterval = (syncConfig.interval || 300) * 1000
|
|
55
|
+
|
|
56
|
+
if (!syncConfig.adaptiveInterval) return baseInterval
|
|
57
|
+
|
|
58
|
+
const status = readStatus()
|
|
59
|
+
const lastActivity = status.lastActivity ? new Date(status.lastActivity).getTime() : 0
|
|
60
|
+
const idle = Date.now() - lastActivity
|
|
61
|
+
|
|
62
|
+
const THIRTY_MIN = 30 * 60 * 1000
|
|
63
|
+
const TWO_HOURS = 2 * 60 * 60 * 1000
|
|
64
|
+
const EIGHT_HOURS = 8 * 60 * 60 * 1000
|
|
65
|
+
|
|
66
|
+
if (idle < THIRTY_MIN) return 2 * 60 * 1000 // Active: every 2 min
|
|
67
|
+
if (idle < TWO_HOURS) return 5 * 60 * 1000 // Normal: every 5 min
|
|
68
|
+
if (idle < EIGHT_HOURS) return 15 * 60 * 1000 // Idle: every 15 min
|
|
69
|
+
return 60 * 60 * 1000 // Sleep: every 60 min
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Sync cycle
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const failureCounts = new Map() // repo -> consecutive failure count
|
|
77
|
+
const SKIP_THRESHOLD = 5
|
|
78
|
+
const SKIP_DURATION = 60 * 60 * 1000 // 1 hour
|
|
79
|
+
const skipUntil = new Map()
|
|
80
|
+
|
|
81
|
+
async function syncCycle(config) {
|
|
82
|
+
// Auth check
|
|
83
|
+
const token = getToken()
|
|
84
|
+
if (!token) {
|
|
85
|
+
writeStatus({ lastError: 'Not authenticated', lastErrorAt: new Date().toISOString() })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const syncConfig = config.sync || {}
|
|
90
|
+
const repos = syncConfig.repos?.length
|
|
91
|
+
? syncConfig.repos
|
|
92
|
+
: [store.get('activeRepo')].filter(Boolean)
|
|
93
|
+
|
|
94
|
+
if (repos.length === 0) {
|
|
95
|
+
writeStatus({ lastError: 'No repos configured', lastErrorAt: new Date().toISOString() })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const perRepoStats = {}
|
|
100
|
+
|
|
101
|
+
for (const repo of repos) {
|
|
102
|
+
// Check skip
|
|
103
|
+
const skipTime = skipUntil.get(repo)
|
|
104
|
+
if (skipTime && Date.now() < skipTime) {
|
|
105
|
+
perRepoStats[repo] = { status: 'skipped', reason: 'too many failures' }
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const issueResult = syncIssues(repo)
|
|
111
|
+
const labelResult = syncLabels(repo)
|
|
112
|
+
perRepoStats[repo] = {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
issues: issueResult.synced,
|
|
115
|
+
labels: labelResult.synced,
|
|
116
|
+
}
|
|
117
|
+
failureCounts.set(repo, 0)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const count = (failureCounts.get(repo) || 0) + 1
|
|
120
|
+
failureCounts.set(repo, count)
|
|
121
|
+
perRepoStats[repo] = { status: 'error', error: err.message }
|
|
122
|
+
|
|
123
|
+
if (count >= SKIP_THRESHOLD) {
|
|
124
|
+
skipUntil.set(repo, Date.now() + SKIP_DURATION)
|
|
125
|
+
perRepoStats[repo].skippedUntil = new Date(Date.now() + SKIP_DURATION).toISOString()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sync repos list (global, not per-repo)
|
|
131
|
+
try {
|
|
132
|
+
syncRepos()
|
|
133
|
+
} catch {
|
|
134
|
+
// Non-fatal
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
writeStatus({
|
|
138
|
+
lastSync: new Date().toISOString(),
|
|
139
|
+
perRepoStats,
|
|
140
|
+
lastError: null,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Main loop
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async function main() {
|
|
149
|
+
// Write PID
|
|
150
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
151
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8')
|
|
152
|
+
|
|
153
|
+
writeStatus({
|
|
154
|
+
pid: process.pid,
|
|
155
|
+
startedAt: new Date().toISOString(),
|
|
156
|
+
status: 'running',
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Graceful shutdown
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
writeStatus({ status: 'stopped', stoppedAt: new Date().toISOString() })
|
|
162
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
163
|
+
process.exit(0)
|
|
164
|
+
}
|
|
165
|
+
process.on('SIGTERM', cleanup)
|
|
166
|
+
process.on('SIGINT', cleanup)
|
|
167
|
+
|
|
168
|
+
// Run first cycle immediately
|
|
169
|
+
const config = loadConfig()
|
|
170
|
+
await syncCycle(config)
|
|
171
|
+
|
|
172
|
+
// Schedule recurring
|
|
173
|
+
const schedule = () => {
|
|
174
|
+
const freshConfig = loadConfig()
|
|
175
|
+
const interval = getInterval(freshConfig)
|
|
176
|
+
writeStatus({ nextSync: new Date(Date.now() + interval).toISOString() })
|
|
177
|
+
setTimeout(async () => {
|
|
178
|
+
await syncCycle(freshConfig)
|
|
179
|
+
schedule()
|
|
180
|
+
}, interval)
|
|
181
|
+
}
|
|
182
|
+
schedule()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch(err => {
|
|
186
|
+
writeStatus({ status: 'crashed', error: err.message, crashedAt: new Date().toISOString() })
|
|
187
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ok */ }
|
|
188
|
+
process.exit(1)
|
|
189
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { BaseAdapter } from './base.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Adapter for the Claude Code CLI (anthropic claude-code).
|
|
8
|
+
* Uses: claude --print 'prompt' --model MODEL --dangerously-skip-permissions
|
|
9
|
+
*/
|
|
10
|
+
export class ClaudeCliAdapter extends BaseAdapter {
|
|
11
|
+
get name() {
|
|
12
|
+
return 'claude-cli'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isConfigured() {
|
|
16
|
+
return !!this.config.binary
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async complete(messages, opts = {}) {
|
|
20
|
+
const binary = this.config.binary || 'claude'
|
|
21
|
+
const model = opts.model || this.config.model || null
|
|
22
|
+
const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
|
|
23
|
+
|
|
24
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
25
|
+
const promptText = lastUserMsg?.content || ''
|
|
26
|
+
|
|
27
|
+
// Build system context from system messages
|
|
28
|
+
const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
|
|
29
|
+
const fullPrompt = systemParts.length
|
|
30
|
+
? `${systemParts.join('\n\n')}\n\n${promptText}`
|
|
31
|
+
: promptText
|
|
32
|
+
|
|
33
|
+
const args = ['--print', fullPrompt, '--dangerously-skip-permissions']
|
|
34
|
+
if (model) args.push('--model', model)
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
execFile(binary, args, {
|
|
38
|
+
timeout: timeoutMs,
|
|
39
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
40
|
+
}, (err, stdout, stderr) => {
|
|
41
|
+
if (err) {
|
|
42
|
+
const msg = stderr?.trim() || err.message
|
|
43
|
+
reject(new Error(`claude-cli error: ${this.sanitizeErrorBody(msg)}`))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const text = stdout.trim()
|
|
47
|
+
if (!text) {
|
|
48
|
+
reject(new Error('claude-cli returned no output'))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
resolve(text)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { GeminiCliAdapter } from './gemini-cli.js'
|
|
4
|
+
import { ClaudeCliAdapter } from './claude-cli.js'
|
|
5
|
+
import { CodexCliAdapter } from './codex-cli.js'
|
|
6
|
+
import { OpenClawAdapter } from './openclaw.js'
|
|
7
|
+
|
|
8
|
+
describe('GeminiCliAdapter', () => {
|
|
9
|
+
it('has correct name', () => {
|
|
10
|
+
const adapter = new GeminiCliAdapter({ binary: 'gemini' })
|
|
11
|
+
assert.equal(adapter.name, 'gemini-cli')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('isConfigured returns true when binary is set', () => {
|
|
15
|
+
const adapter = new GeminiCliAdapter({ binary: 'gemini' })
|
|
16
|
+
assert.equal(adapter.isConfigured(), true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
20
|
+
const adapter = new GeminiCliAdapter({})
|
|
21
|
+
assert.equal(adapter.isConfigured(), false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('complete rejects when binary does not exist', async () => {
|
|
25
|
+
const adapter = new GeminiCliAdapter({ binary: 'nonexistent-gemini-binary-xyz' })
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
28
|
+
/gemini-cli error/,
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('ClaudeCliAdapter', () => {
|
|
34
|
+
it('has correct name', () => {
|
|
35
|
+
const adapter = new ClaudeCliAdapter({ binary: 'claude' })
|
|
36
|
+
assert.equal(adapter.name, 'claude-cli')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('isConfigured returns true when binary is set', () => {
|
|
40
|
+
const adapter = new ClaudeCliAdapter({ binary: 'claude' })
|
|
41
|
+
assert.equal(adapter.isConfigured(), true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
45
|
+
const adapter = new ClaudeCliAdapter({})
|
|
46
|
+
assert.equal(adapter.isConfigured(), false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('complete rejects when binary does not exist', async () => {
|
|
50
|
+
const adapter = new ClaudeCliAdapter({ binary: 'nonexistent-claude-binary-xyz' })
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
53
|
+
/claude-cli error/,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('CodexCliAdapter', () => {
|
|
59
|
+
it('has correct name', () => {
|
|
60
|
+
const adapter = new CodexCliAdapter({ binary: 'codex' })
|
|
61
|
+
assert.equal(adapter.name, 'codex-cli')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('isConfigured returns true when binary is set', () => {
|
|
65
|
+
const adapter = new CodexCliAdapter({ binary: 'codex' })
|
|
66
|
+
assert.equal(adapter.isConfigured(), true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('isConfigured returns false when binary is not set', () => {
|
|
70
|
+
const adapter = new CodexCliAdapter({})
|
|
71
|
+
assert.equal(adapter.isConfigured(), false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('complete rejects when binary does not exist', async () => {
|
|
75
|
+
const adapter = new CodexCliAdapter({ binary: 'nonexistent-codex-binary-xyz' })
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
78
|
+
/codex-cli error|git/,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('OpenClawAdapter', () => {
|
|
84
|
+
it('has correct name', () => {
|
|
85
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
|
|
86
|
+
assert.equal(adapter.name, 'openclaw')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('isConfigured returns true with url and token', () => {
|
|
90
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790', token: 'test' })
|
|
91
|
+
assert.equal(adapter.isConfigured(), true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('isConfigured returns false without token', () => {
|
|
95
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
|
|
96
|
+
assert.equal(adapter.isConfigured(), false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('isConfigured returns false without url', () => {
|
|
100
|
+
const adapter = new OpenClawAdapter({ token: 'test' })
|
|
101
|
+
assert.equal(adapter.isConfigured(), false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('healthCheck returns false for unreachable gateway', async () => {
|
|
105
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
|
|
106
|
+
const healthy = await adapter.healthCheck()
|
|
107
|
+
assert.equal(healthy, false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('complete rejects without gateway url', async () => {
|
|
111
|
+
const adapter = new OpenClawAdapter({ token: 'test' })
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
114
|
+
/gateway URL not configured/,
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('complete rejects without token', async () => {
|
|
119
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:18790' })
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
122
|
+
/token not configured/,
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('complete rejects when gateway is unreachable', async () => {
|
|
127
|
+
const adapter = new OpenClawAdapter({ gatewayUrl: 'http://localhost:1', token: 'test' })
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
() => adapter.complete([{ role: 'user', content: 'test' }]),
|
|
130
|
+
/not reachable/,
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { BaseAdapter } from './base.js'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 120_000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapter for the OpenAI Codex CLI.
|
|
11
|
+
* Codex requires a git repo, so we create a temp dir with git init.
|
|
12
|
+
* Uses: codex exec 'prompt'
|
|
13
|
+
*/
|
|
14
|
+
export class CodexCliAdapter extends BaseAdapter {
|
|
15
|
+
get name() {
|
|
16
|
+
return 'codex-cli'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
isConfigured() {
|
|
20
|
+
return !!this.config.binary
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async complete(messages, opts = {}) {
|
|
24
|
+
const binary = this.config.binary || 'codex'
|
|
25
|
+
const timeoutMs = this.config.timeout || DEFAULT_TIMEOUT_MS
|
|
26
|
+
|
|
27
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
|
|
28
|
+
const promptText = lastUserMsg?.content || ''
|
|
29
|
+
|
|
30
|
+
const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content)
|
|
31
|
+
const fullPrompt = systemParts.length
|
|
32
|
+
? `${systemParts.join('\n\n')}\n\n${promptText}`
|
|
33
|
+
: promptText
|
|
34
|
+
|
|
35
|
+
// Codex requires a git repository — create a temp one
|
|
36
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'tissues-codex-'))
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Initialize git repo in temp dir
|
|
40
|
+
await this._exec('git', ['init', '--quiet'], { cwd: tempDir, timeout: 10_000 })
|
|
41
|
+
|
|
42
|
+
const args = ['exec', fullPrompt]
|
|
43
|
+
|
|
44
|
+
return await new Promise((resolve, reject) => {
|
|
45
|
+
execFile(binary, args, {
|
|
46
|
+
cwd: tempDir,
|
|
47
|
+
timeout: timeoutMs,
|
|
48
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
49
|
+
}, (err, stdout, stderr) => {
|
|
50
|
+
if (err) {
|
|
51
|
+
const msg = stderr?.trim() || err.message
|
|
52
|
+
reject(new Error(`codex-cli error: ${this.sanitizeErrorBody(msg)}`))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const text = stdout.trim()
|
|
56
|
+
if (!text) {
|
|
57
|
+
reject(new Error('codex-cli returned no output'))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
resolve(text)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
} finally {
|
|
64
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* cleanup best-effort */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @private */
|
|
69
|
+
_exec(cmd, args, opts) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
execFile(cmd, args, opts, (err, stdout) => {
|
|
72
|
+
if (err) reject(err)
|
|
73
|
+
else resolve(stdout)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|