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,167 @@
1
+ /**
2
+ * Storage manager command — inspect and manage all cached/stored data.
3
+ */
4
+
5
+ import { Command } from 'commander'
6
+ import { confirm } from '@inquirer/prompts'
7
+ import { store } from '../lib/config.js'
8
+ import { theme } from '../lib/theme.js'
9
+ import {
10
+ getStorageOverview,
11
+ getIssueBreakdown,
12
+ clearCachedData,
13
+ exportStorageJson,
14
+ formatBytes,
15
+ timeAgo,
16
+ } from '../lib/storage.js'
17
+ import { bold, dim, cyan, green, yellow, red } from '../lib/color.js'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Display helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function padRight(str, len) {
24
+ return (str + ' '.repeat(len)).slice(0, len)
25
+ }
26
+
27
+ function printOverview(overview) {
28
+ const { database, files, totalDiskUsage } = overview
29
+
30
+ console.log(bold('\nDatabase'))
31
+ console.log(` Path: ${dim(database.path)}`)
32
+ console.log(` Size: ${formatBytes(database.size)}`)
33
+
34
+ const tableOrder = [
35
+ 'fingerprints', 'idempotency_keys',
36
+ 'cached_issues', 'cached_labels', 'cached_repos',
37
+ 'circuit_breaker', 'rate_events', 'sync_meta',
38
+ ]
39
+
40
+ for (const name of tableOrder) {
41
+ const info = database.tables[name]
42
+ if (!info) continue
43
+ const rowStr = padRight(`${info.rows} rows`, 14)
44
+ const syncStr = info.lastSync ? `last: ${timeAgo(info.lastSync)}` : ''
45
+ console.log(` ${padRight(name, 18)} ${rowStr} ${dim(syncStr)}`)
46
+ }
47
+
48
+ console.log(bold('\nFiles'))
49
+ if (files.drafts.count > 0) {
50
+ console.log(` Drafts: ${files.drafts.count} file${files.drafts.count === 1 ? '' : 's'} ${formatBytes(files.drafts.totalSize)}`)
51
+ }
52
+ if (files.images.count > 0) {
53
+ console.log(` Images: ${files.images.count} file${files.images.count === 1 ? '' : 's'} ${formatBytes(files.images.totalSize)}`)
54
+ }
55
+ if (files.templates.count > 0) {
56
+ console.log(` Templates: ${files.templates.count} file${files.templates.count === 1 ? '' : 's'}`)
57
+ }
58
+ if (files.enhancements.count > 0) {
59
+ console.log(` Enhancements: ${files.enhancements.count} file${files.enhancements.count === 1 ? '' : 's'}`)
60
+ }
61
+ if (files.drafts.count + files.images.count + files.templates.count + files.enhancements.count === 0) {
62
+ console.log(dim(' (none)'))
63
+ }
64
+
65
+ console.log(`\n ${bold('Total disk usage:')} ${formatBytes(totalDiskUsage)}`)
66
+ console.log()
67
+ }
68
+
69
+ function printIssueBreakdown(breakdown) {
70
+ if (breakdown.length === 0) {
71
+ console.log(dim('\n No cached issues.\n'))
72
+ return
73
+ }
74
+
75
+ console.log(bold('\nCached Issues'))
76
+ for (const entry of breakdown) {
77
+ const total = entry.open + entry.closed
78
+ const sync = entry.lastSync ? timeAgo(entry.lastSync) : 'never'
79
+ console.log(` ${cyan(entry.repo)}`)
80
+ console.log(` ${entry.open} open, ${entry.closed} closed (${total} total) last sync: ${dim(sync)}`)
81
+ }
82
+ console.log()
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Command
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export const storageCommand = new Command('storage')
90
+ .description('Inspect and manage cached data')
91
+ .argument('[category]', 'Drill-down category (issues, labels, fingerprints)')
92
+ .option('--repo <repo>', 'Scope to a specific repo')
93
+ .option('--json', 'Output as JSON')
94
+ .action(async (category, opts) => {
95
+ const repo = opts.repo || store.get('activeRepo') || undefined
96
+
97
+ if (opts.json) {
98
+ const data = exportStorageJson({ repo })
99
+ console.log(JSON.stringify(data, null, 2))
100
+ return
101
+ }
102
+
103
+ if (category === 'issues') {
104
+ const breakdown = getIssueBreakdown()
105
+ printIssueBreakdown(breakdown)
106
+ return
107
+ }
108
+
109
+ const overview = getStorageOverview({ repo })
110
+ printOverview(overview)
111
+ })
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Subcommands
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const clearCommand = new Command('clear')
118
+ .description('Clear cached data')
119
+ .argument('<target>', 'What to clear (issues, labels, repos, fingerprints, all)')
120
+ .option('--repo <repo>', 'Scope to a specific repo')
121
+ .option('--include-safety', 'Also reset circuit breaker and rate events')
122
+ .option('--force', 'Skip confirmation')
123
+ .action(async (target, opts) => {
124
+ const validTargets = ['issues', 'labels', 'repos', 'fingerprints', 'all']
125
+ if (!validTargets.includes(target)) {
126
+ console.error(red(`Invalid target: ${target}`))
127
+ console.error(dim(`Valid targets: ${validTargets.join(', ')}`))
128
+ process.exit(1)
129
+ }
130
+
131
+ const repo = opts.repo || undefined
132
+ const scope = repo ? ` for ${cyan(repo)}` : ''
133
+
134
+ if (!opts.force) {
135
+ const msg = target === 'all'
136
+ ? `Clear ALL cached data${scope}?${opts.includeSafety ? ' (including safety data)' : ''}`
137
+ : `Clear cached ${target}${scope}?`
138
+ const ok = await confirm({ message: msg, default: false, theme })
139
+ if (!ok) {
140
+ console.log(dim('Aborted.'))
141
+ return
142
+ }
143
+ }
144
+
145
+ const { cleared } = clearCachedData(target, {
146
+ repo,
147
+ includeSafety: opts.includeSafety || false,
148
+ })
149
+
150
+ if (cleared.length > 0) {
151
+ console.log(green('Cleared: ') + cleared.join(', '))
152
+ } else {
153
+ console.log(dim('Nothing to clear.'))
154
+ }
155
+ })
156
+
157
+ const exportCommand = new Command('export')
158
+ .description('Export storage metadata as JSON')
159
+ .option('--repo <repo>', 'Scope to a specific repo')
160
+ .action((opts) => {
161
+ const repo = opts.repo || store.get('activeRepo') || undefined
162
+ const data = exportStorageJson({ repo })
163
+ console.log(JSON.stringify(data, null, 2))
164
+ })
165
+
166
+ storageCommand.addCommand(clearCommand)
167
+ storageCommand.addCommand(exportCommand)
@@ -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
+ }