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.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. 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
+ }