tspl-cli 0.0.0-stage → 0.1.1
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 +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Os = require('os')
|
|
5
|
+
const Path = require('path')
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = 'http://localhost:1500'
|
|
8
|
+
|
|
9
|
+
const homeDir = () => process.env.TSPL_HOME || Path.join(Os.homedir(), '.tspl')
|
|
10
|
+
|
|
11
|
+
const readJson = file => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err.code === 'ENOENT') {
|
|
16
|
+
return {}
|
|
17
|
+
}
|
|
18
|
+
throw err
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const writeJson = (file, data, mode) => {
|
|
23
|
+
Fs.mkdirSync(homeDir(), { recursive: true, mode: 0o700 })
|
|
24
|
+
Fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode })
|
|
25
|
+
Fs.chmodSync(file, mode)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const configFile = () => Path.join(homeDir(), 'config.json')
|
|
29
|
+
|
|
30
|
+
const credentialsFile = () => Path.join(homeDir(), 'credentials.json')
|
|
31
|
+
|
|
32
|
+
const readConfig = () => ({ apiUrl: DEFAULT_API_URL, ...readJson(configFile()) })
|
|
33
|
+
|
|
34
|
+
const writeConfig = config => writeJson(configFile(), config, 0o600)
|
|
35
|
+
|
|
36
|
+
const accountsFile = () => Path.join(homeDir(), 'accounts.json')
|
|
37
|
+
|
|
38
|
+
/** Saved logins, one per person and organization, so you can switch account or organization without a password. */
|
|
39
|
+
const readAccounts = () => readJson(accountsFile())
|
|
40
|
+
|
|
41
|
+
const accountKey = account => `${account.organization}|${String(account.email || '').toLowerCase()}`
|
|
42
|
+
|
|
43
|
+
const listAccounts = () => Object.values(readAccounts()).filter(account => account && account.organization)
|
|
44
|
+
|
|
45
|
+
const saveAccount = credentials => {
|
|
46
|
+
if (!credentials.organization) return
|
|
47
|
+
const accounts = readAccounts()
|
|
48
|
+
if (accounts[credentials.organization] && accounts[credentials.organization].email === credentials.email) delete accounts[credentials.organization]
|
|
49
|
+
accounts[accountKey(credentials)] = { ...credentials, lastUsedAt: new Date().toISOString() }
|
|
50
|
+
writeJson(accountsFile(), accounts, 0o600)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const forgetAccount = account => {
|
|
54
|
+
if (!account || !account.organization) return
|
|
55
|
+
const accounts = readAccounts()
|
|
56
|
+
delete accounts[accountKey(account)]
|
|
57
|
+
if (accounts[account.organization] && accounts[account.organization].email === account.email) delete accounts[account.organization]
|
|
58
|
+
writeJson(accountsFile(), accounts, 0o600)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The login to use for an organization: the default login's person if they belong to it, else the latest one used. */
|
|
62
|
+
const accountFor = organization => {
|
|
63
|
+
const candidates = listAccounts().filter(account => account.organization === organization)
|
|
64
|
+
const current = readJson(credentialsFile())
|
|
65
|
+
return (
|
|
66
|
+
candidates.find(account => account.email === current.email) ||
|
|
67
|
+
candidates.sort((a, b) => String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || '')))[0] ||
|
|
68
|
+
null
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let activeOrganization = null
|
|
73
|
+
|
|
74
|
+
/** Which organization this process works in. Set once per command from the project folder (or `tspl init --org`). */
|
|
75
|
+
const setActiveOrganization = organization => {
|
|
76
|
+
activeOrganization = organization || null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getActiveOrganization = () => activeOrganization
|
|
80
|
+
|
|
81
|
+
const readCredentials = () => {
|
|
82
|
+
if (activeOrganization) {
|
|
83
|
+
const saved = accountFor(activeOrganization)
|
|
84
|
+
if (saved) return saved
|
|
85
|
+
}
|
|
86
|
+
return readJson(credentialsFile())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const writeCredentials = credentials => {
|
|
90
|
+
saveAccount(credentials)
|
|
91
|
+
const current = readJson(credentialsFile())
|
|
92
|
+
if (!activeOrganization || !current.organization || current.organization === credentials.organization) {
|
|
93
|
+
writeJson(credentialsFile(), credentials, 0o600)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const clearCredentials = () => {
|
|
98
|
+
const account = readCredentials()
|
|
99
|
+
forgetAccount(account)
|
|
100
|
+
const current = readJson(credentialsFile())
|
|
101
|
+
if (!activeOrganization || (current.organization === account.organization && current.email === account.email)) Fs.rmSync(credentialsFile(), { force: true })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const enginesFile = () => Path.join(homeDir(), 'engines.json')
|
|
105
|
+
|
|
106
|
+
const readEngines = () => readJson(enginesFile())
|
|
107
|
+
|
|
108
|
+
const writeEngines = engines => writeJson(enginesFile(), engines, 0o600)
|
|
109
|
+
|
|
110
|
+
const indexFile = projectId => Path.join(homeDir(), 'index', `${projectId}.json`)
|
|
111
|
+
|
|
112
|
+
const readIndex = projectId => readJson(indexFile(projectId))
|
|
113
|
+
|
|
114
|
+
const writeIndex = (projectId, index) => {
|
|
115
|
+
Fs.mkdirSync(Path.join(homeDir(), 'index'), { recursive: true, mode: 0o700 })
|
|
116
|
+
const file = indexFile(projectId)
|
|
117
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
118
|
+
Fs.writeFileSync(temp, JSON.stringify(index), { mode: 0o600 })
|
|
119
|
+
Fs.renameSync(temp, file)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const taskFile = taskId => Path.join(homeDir(), 'tasks', `${taskId}.json`)
|
|
123
|
+
|
|
124
|
+
const currentOrg = () => readCredentials().organization || null
|
|
125
|
+
|
|
126
|
+
/** A task written under one login is not shown under another organization's login. Older files without an owner stay visible. */
|
|
127
|
+
const visible = task => !task.owner || !currentOrg() || task.owner === currentOrg()
|
|
128
|
+
|
|
129
|
+
const readTask = taskId => {
|
|
130
|
+
const task = readJson(taskFile(taskId))
|
|
131
|
+
return visible(task) ? task : {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const writeTask = task => {
|
|
135
|
+
Fs.mkdirSync(Path.join(homeDir(), 'tasks'), { recursive: true, mode: 0o700 })
|
|
136
|
+
Fs.writeFileSync(taskFile(task.id), `${JSON.stringify({ ...task, owner: task.owner || currentOrg(), updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const removeTask = taskId => Fs.rmSync(taskFile(taskId), { force: true })
|
|
140
|
+
|
|
141
|
+
const listTasks = () => {
|
|
142
|
+
const dir = Path.join(homeDir(), 'tasks')
|
|
143
|
+
try {
|
|
144
|
+
return Fs.readdirSync(dir)
|
|
145
|
+
.filter(name => name.endsWith('.json'))
|
|
146
|
+
.map(name => readJson(Path.join(dir, name)))
|
|
147
|
+
.filter(task => task.id && visible(task))
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err.code === 'ENOENT') return []
|
|
150
|
+
throw err
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const runFile = taskId => Path.join(homeDir(), 'runs', `${taskId}.json`)
|
|
155
|
+
|
|
156
|
+
const readRun = taskId => readJson(runFile(taskId))
|
|
157
|
+
|
|
158
|
+
const writeRun = (taskId, run) => {
|
|
159
|
+
Fs.mkdirSync(Path.join(homeDir(), 'runs'), { recursive: true, mode: 0o700 })
|
|
160
|
+
Fs.writeFileSync(runFile(taskId), `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const activeFile = () => Path.join(homeDir(), 'active.json')
|
|
164
|
+
|
|
165
|
+
const readActive = () => readJson(activeFile())
|
|
166
|
+
|
|
167
|
+
const writeActive = active => writeJson(activeFile(), active, 0o600)
|
|
168
|
+
|
|
169
|
+
const feedbackFile = projectId => Path.join(homeDir(), 'feedback', `${projectId}.json`)
|
|
170
|
+
|
|
171
|
+
const readFeedback = projectId => readJson(feedbackFile(projectId))
|
|
172
|
+
|
|
173
|
+
const writeFeedback = (projectId, feedback) => {
|
|
174
|
+
Fs.mkdirSync(Path.join(homeDir(), 'feedback'), { recursive: true, mode: 0o700 })
|
|
175
|
+
Fs.writeFileSync(feedbackFile(projectId), JSON.stringify(feedback), { mode: 0o600 })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const writePlanPage = (taskKey, html) => {
|
|
179
|
+
const dir = Path.join(homeDir(), 'plans')
|
|
180
|
+
Fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
181
|
+
const file = Path.join(dir, `${taskKey}.html`)
|
|
182
|
+
Fs.writeFileSync(file, html, { mode: 0o600 })
|
|
183
|
+
return file
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const SYNC_LOCK_MINUTES = 15
|
|
187
|
+
|
|
188
|
+
const syncLockFile = () => Path.join(homeDir(), 'sync.lock')
|
|
189
|
+
|
|
190
|
+
const lockSync = () => {
|
|
191
|
+
const file = syncLockFile()
|
|
192
|
+
try {
|
|
193
|
+
if (Date.now() - Fs.statSync(file).mtimeMs > SYNC_LOCK_MINUTES * 60 * 1000) Fs.rmSync(file, { force: true })
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err.code !== 'ENOENT') throw err
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
Fs.mkdirSync(homeDir(), { recursive: true, mode: 0o700 })
|
|
199
|
+
Fs.writeFileSync(file, String(process.pid), { flag: 'wx', mode: 0o600 })
|
|
200
|
+
return true
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code === 'EEXIST') return false
|
|
203
|
+
throw err
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const unlockSync = () => Fs.rmSync(syncLockFile(), { force: true })
|
|
208
|
+
|
|
209
|
+
const syncStateFile = () => Path.join(homeDir(), 'sync.json')
|
|
210
|
+
|
|
211
|
+
const readSyncState = () => readJson(syncStateFile())
|
|
212
|
+
|
|
213
|
+
const writeSyncState = state => writeJson(syncStateFile(), state, 0o600)
|
|
214
|
+
|
|
215
|
+
const linksFile = () => Path.join(homeDir(), 'links.json')
|
|
216
|
+
|
|
217
|
+
const readLinks = () => readJson(linksFile())
|
|
218
|
+
|
|
219
|
+
const writeLinks = links => writeJson(linksFile(), links, 0o600)
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
readConfig,
|
|
223
|
+
writeConfig,
|
|
224
|
+
readCredentials,
|
|
225
|
+
writeCredentials,
|
|
226
|
+
clearCredentials,
|
|
227
|
+
setActiveOrganization,
|
|
228
|
+
getActiveOrganization,
|
|
229
|
+
readAccounts,
|
|
230
|
+
listAccounts,
|
|
231
|
+
accountFor,
|
|
232
|
+
saveAccount,
|
|
233
|
+
forgetAccount,
|
|
234
|
+
readLinks,
|
|
235
|
+
writeLinks,
|
|
236
|
+
readEngines,
|
|
237
|
+
writeEngines,
|
|
238
|
+
readIndex,
|
|
239
|
+
writeIndex,
|
|
240
|
+
readRun,
|
|
241
|
+
writeRun,
|
|
242
|
+
readTask,
|
|
243
|
+
writeTask,
|
|
244
|
+
removeTask,
|
|
245
|
+
listTasks,
|
|
246
|
+
readActive,
|
|
247
|
+
writeActive,
|
|
248
|
+
readFeedback,
|
|
249
|
+
writeFeedback,
|
|
250
|
+
lockSync,
|
|
251
|
+
unlockSync,
|
|
252
|
+
readSyncState,
|
|
253
|
+
writeSyncState,
|
|
254
|
+
writePlanPage,
|
|
255
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tspl
|
|
3
|
+
description: Work on a tspl task: read its brief, plan, get approval, build and report. Tasks are prepared with "tspl task new" in a terminal (no AI tokens) and have an id like t-abc123-9f2e.
|
|
4
|
+
argument-hint: <task id> or <what should change>
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Task from the user: $ARGUMENTS
|
|
9
|
+
|
|
10
|
+
Keep messages short and plain.
|
|
11
|
+
|
|
12
|
+
## A. The argument is a tspl task id (starts with "t-")
|
|
13
|
+
|
|
14
|
+
1. Call `get_brief` with it. The brief has the task, the "done when" checks, whether the PM confirmed the scope, and the files tspl found (no AI). Read those files at the "look at" places. If confidence is LOW, do one short, targeted search for the names tspl lists and the task's words. Do not read whole repos. If the brief says the PM sent the scope back, tell the user and stop.
|
|
15
|
+
2. Compare two or three meaningful implementation approaches from the code. If the brief has unanswered saved options, show those instead of generating new ones. Call `propose_approaches` with unique option IDs, titles, summaries, benefits, drawbacks, affected files, exactly one `recommendedId`, the recommendation reason, and `expectedVersion` (0 initially). Show the options through your question UI or a numbered list, marking one **Recommended**. Wait for the user's explicit answer; never automatically choose or treat silence as consent. Record the answer with `select_approach` using its version and option ID. If the brief already has a valid selection, use it without asking again. This choice does not approve the plan.
|
|
16
|
+
3. Call `submit_plan` with `approach: {version, optionId}` matching the saved choice, and: files to edit, create and remove (repo/path and what changes), screen changes, and tests (with the "done when" numbers each proves and the command that runs each test from its repo folder). tspl returns a checklist by repo; fix the checks that are real problems.
|
|
17
|
+
4. Show the whole checklist. A person approves it, never this chat: the user runs `tspl plan approve <task id>` in their own terminal, or decides in the tspl console; then the PM does the same if the project needs it. When the user says they decided, call `plan_status`. For changes, update the same task and submit the next version. Build nothing until `plan_status` says approved.
|
|
18
|
+
5. When approved: either call `start_build` here (show the branch and where each repo starts, ask the user, then call it again with `confirm: true`), or suggest `tspl build start <task id> --open` in a terminal, which opens a fresh AI session with only the checklist. Only checklist files and test files can change: git refuses commits and pushes with other files. Build the checklist the way the surrounding code is written, run the tests, check every "done when" item.
|
|
19
|
+
6. Do not commit or push. When the checklist is built, call `finish_build` with each "done when" result, each test result and a two-sentence summary; report failures honestly. tspl runs the plan's test commands itself on the files as they are and the server decides whether the task is done. If it PASSED, show the report: the task is done. Committing, pushing (`tspl build push <task id>`) and the pull request are the user's own choice afterwards. If it lists problems, fix them and call `finish_build` again. If tspl cannot reach its server, say so and stop; do not commit instead.
|
|
20
|
+
In a project with a `.tspl/` folder (set up with `tspl init`) `finish_build` ignores what you report as proof: tspl commits nothing for you, runs the approved checks on your committed branch itself, has a separate AI session review it, and then waits for the person to confirm the push (`tspl task run <task id>` in their terminal opens the draft pull requests and watches CI). Never tell the user the task is done: say what stage tspl reports and the next step it prints. Done-when items no executed check covers are checked by the person with `tspl task verify <task id> --check <n>`, never by you.
|
|
21
|
+
|
|
22
|
+
## B. The argument is free text, a ticket id, or empty
|
|
23
|
+
|
|
24
|
+
The cheapest way is outside this chat. Tell the user to run, in a terminal in this project folder:
|
|
25
|
+
|
|
26
|
+
tspl task new "<what should change>"
|
|
27
|
+
|
|
28
|
+
It asks only what is missing (options come from the code), suggests "done when" checks, finds the files, and saves the task so the PM can confirm the scope; it uses no AI tokens. Then they come back with `/tspl <task id>`.
|
|
29
|
+
|
|
30
|
+
If they prefer to continue here: fill a task card with only what the user said or showed (ticket, type, what, who, where, how, steps, now, expected, doneWhen, details) and call `check_task`. "needs answers": ask all listed questions in one go with your question tool, put the answers in the named fields and call `check_task` again with the returned card ("skip" from the user: set `skip: true`). "ready": show the summary, ask the user to confirm, then continue at A1 with the task id it returned.
|
package/src/engines.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawn, execFileSync } = require('child_process')
|
|
4
|
+
|
|
5
|
+
const LIMIT_PATTERN = /usage limit|rate limit|limit reached|limit exceeded|quota|too many requests|\b429\b/i
|
|
6
|
+
|
|
7
|
+
const ENGINES = {
|
|
8
|
+
claude: {
|
|
9
|
+
label: 'Claude Code',
|
|
10
|
+
command: 'claude',
|
|
11
|
+
uses: 'your Claude Pro/Max subscription',
|
|
12
|
+
install: 'npm install -g @anthropic-ai/claude-code',
|
|
13
|
+
login: 'run "claude" once and sign in',
|
|
14
|
+
},
|
|
15
|
+
codex: {
|
|
16
|
+
label: 'Codex CLI',
|
|
17
|
+
command: 'codex',
|
|
18
|
+
uses: 'your ChatGPT Plus/Pro subscription',
|
|
19
|
+
install: 'npm install -g @openai/codex',
|
|
20
|
+
login: 'run "codex" once and choose Sign in with ChatGPT',
|
|
21
|
+
},
|
|
22
|
+
gemini: {
|
|
23
|
+
label: 'Gemini CLI',
|
|
24
|
+
command: 'gemini',
|
|
25
|
+
uses: 'your Google account',
|
|
26
|
+
install: 'npm install -g @google/gemini-cli@latest',
|
|
27
|
+
login: 'run "gemini" once and sign in with Google',
|
|
28
|
+
},
|
|
29
|
+
ollama: {
|
|
30
|
+
label: 'Ollama',
|
|
31
|
+
command: 'ollama',
|
|
32
|
+
uses: 'free local models on this machine',
|
|
33
|
+
install: 'brew install ollama',
|
|
34
|
+
login: 'download a model, e.g. "ollama pull qwen2.5-coder"',
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PREFERENCE = ['claude', 'codex', 'gemini', 'ollama']
|
|
39
|
+
|
|
40
|
+
const detect = name => {
|
|
41
|
+
try {
|
|
42
|
+
const out = execFileSync(ENGINES[name].command, ['--version'], {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
45
|
+
timeout: 10000,
|
|
46
|
+
})
|
|
47
|
+
return { installed: true, version: out.trim().split('\n')[0] }
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { installed: false, version: null }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const detectAll = () =>
|
|
54
|
+
Object.fromEntries(Object.keys(ENGINES).map(name => [name, detect(name)]))
|
|
55
|
+
|
|
56
|
+
const defaultAssignments = installed => {
|
|
57
|
+
const available = PREFERENCE.filter(name => installed[name] && installed[name].installed)
|
|
58
|
+
if (!available.length) {
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
61
|
+
const main = available[0]
|
|
62
|
+
return { plan: main, build: main, verify: main, bulk: main }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const commandFor = (name, prompt, model, { noTools = false } = {}) => {
|
|
66
|
+
switch (name) {
|
|
67
|
+
case 'claude':
|
|
68
|
+
return ['claude', ['-p', prompt, '--output-format', 'json', ...(model ? ['--model', model] : []), ...(noTools ? ['--tools', ''] : [])]]
|
|
69
|
+
case 'codex':
|
|
70
|
+
return ['codex', ['exec', ...(model ? ['-m', model] : []), ...(noTools ? ['--sandbox', 'read-only'] : []), prompt]]
|
|
71
|
+
case 'gemini':
|
|
72
|
+
return ['gemini', [...(model ? ['-m', model] : []), '-p', prompt]]
|
|
73
|
+
case 'ollama':
|
|
74
|
+
return ['ollama', ['run', model || 'qwen2.5-coder', prompt]]
|
|
75
|
+
default:
|
|
76
|
+
throw new Error(`Unknown engine "${name}"`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NOISE = /DeprecationWarning|--trace-deprecation|^\(node:\d+\)|ExperimentalWarning|^\s*$/
|
|
81
|
+
|
|
82
|
+
/** The first line that is an actual error, skipping Node warnings the provider CLIs print on stderr. */
|
|
83
|
+
const firstError = text => String(text || '').split('\n').find(line => !NOISE.test(line)) || ''
|
|
84
|
+
|
|
85
|
+
const READ_TOOLS = ['Read', 'Grep', 'Glob']
|
|
86
|
+
const EDIT_TOOLS = ['Read', 'Edit', 'Write', 'Glob', 'Grep', 'Bash']
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What each engine can do headless. enforcedReadOnly means the provider itself refuses edits for plan/review runs
|
|
90
|
+
* (checked in the provider's own help); where it is false tspl still detects and rejects changes afterwards.
|
|
91
|
+
*/
|
|
92
|
+
const CAPABILITIES = {
|
|
93
|
+
claude: { plan: true, edit: true, review: true, enforcedReadOnly: true },
|
|
94
|
+
codex: { plan: true, edit: true, review: true, enforcedReadOnly: true },
|
|
95
|
+
gemini: { plan: true, edit: true, review: true, enforcedReadOnly: false },
|
|
96
|
+
ollama: { plan: true, edit: false, review: true, enforcedReadOnly: true },
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const agentCapabilities = name => {
|
|
100
|
+
if (!CAPABILITIES[name]) throw new Error(`Unknown engine "${name}"`)
|
|
101
|
+
return { ...CAPABILITIES[name] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The command for a structured agent run. role is plan, edit or review; plan and review runs get read-only tools where the
|
|
106
|
+
* provider can enforce that. sessionId is generated by the caller so a review can be proven to be a different session.
|
|
107
|
+
*/
|
|
108
|
+
const agentCommand = (name, role, { prompt, model, dirs = [], sessionId, schema }) => {
|
|
109
|
+
const capability = agentCapabilities(name)
|
|
110
|
+
if (!capability[role]) throw new Error(`${ENGINES[name].label} cannot do the "${role}" step on its own`)
|
|
111
|
+
const readOnly = role !== 'edit'
|
|
112
|
+
switch (name) {
|
|
113
|
+
case 'claude':
|
|
114
|
+
return ['claude', [
|
|
115
|
+
'-p', prompt,
|
|
116
|
+
'--output-format', 'json',
|
|
117
|
+
...(model ? ['--model', model] : []),
|
|
118
|
+
...(sessionId ? ['--session-id', sessionId] : []),
|
|
119
|
+
...(role === 'review' ? ['--no-session-persistence'] : []),
|
|
120
|
+
...(schema ? ['--json-schema', JSON.stringify(schema)] : []),
|
|
121
|
+
...(readOnly ? ['--tools', READ_TOOLS.join(',')] : ['--permission-mode', 'acceptEdits', '--allowedTools', ...EDIT_TOOLS]),
|
|
122
|
+
...dirs.slice(1).flatMap(dir => ['--add-dir', dir]),
|
|
123
|
+
]]
|
|
124
|
+
case 'codex':
|
|
125
|
+
return ['codex', [
|
|
126
|
+
'exec',
|
|
127
|
+
'--sandbox', readOnly ? 'read-only' : 'workspace-write',
|
|
128
|
+
...(model ? ['-m', model] : []),
|
|
129
|
+
...dirs.slice(1).flatMap(dir => ['--add-dir', dir]),
|
|
130
|
+
prompt,
|
|
131
|
+
]]
|
|
132
|
+
case 'gemini':
|
|
133
|
+
return ['gemini', [
|
|
134
|
+
...(model ? ['-m', model] : []),
|
|
135
|
+
'--approval-mode', readOnly ? 'default' : 'auto_edit',
|
|
136
|
+
...(dirs.length > 1 ? ['--include-directories', dirs.slice(1).join(',')] : []),
|
|
137
|
+
'-p', prompt,
|
|
138
|
+
]]
|
|
139
|
+
case 'ollama':
|
|
140
|
+
return ['ollama', ['run', model || 'qwen2.5-coder', prompt]]
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`Unknown engine "${name}"`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const AUTH_PATTERN = /not logged in|please (log ?in|sign in|authenticate)|unauthori[sz]ed|authentication (failed|required)|invalid api key|\b401\b|run .{0,20}login/i
|
|
147
|
+
|
|
148
|
+
const parseOutput = (name, stdout) => {
|
|
149
|
+
if (name === 'claude') {
|
|
150
|
+
try {
|
|
151
|
+
const data = JSON.parse(stdout)
|
|
152
|
+
return {
|
|
153
|
+
text: data.structured_output ? JSON.stringify(data.structured_output) : data.result || '',
|
|
154
|
+
failed: Boolean(data.is_error),
|
|
155
|
+
usage: data.usage
|
|
156
|
+
? {
|
|
157
|
+
inputTokens: data.usage.input_tokens || 0,
|
|
158
|
+
outputTokens: data.usage.output_tokens || 0,
|
|
159
|
+
cacheReadTokens: data.usage.cache_read_input_tokens || 0,
|
|
160
|
+
}
|
|
161
|
+
: null,
|
|
162
|
+
sessionId: data.session_id || null,
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { text: stdout.trim(), failed: false, usage: null, sessionId: null }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { text: stdout.trim(), failed: false, usage: null, sessionId: null }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const run = (name, prompt, { model, cwd, timeoutMs = 120000, noTools = false } = {}) =>
|
|
172
|
+
new Promise(resolve => {
|
|
173
|
+
const [command, args] = commandFor(name, prompt, model, { noTools })
|
|
174
|
+
const started = Date.now()
|
|
175
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
176
|
+
let stdout = ''
|
|
177
|
+
let stderr = ''
|
|
178
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs)
|
|
179
|
+
|
|
180
|
+
child.stdout.on('data', chunk => {
|
|
181
|
+
stdout += chunk
|
|
182
|
+
})
|
|
183
|
+
child.stderr.on('data', chunk => {
|
|
184
|
+
stderr += chunk
|
|
185
|
+
})
|
|
186
|
+
child.on('error', err => {
|
|
187
|
+
clearTimeout(timer)
|
|
188
|
+
resolve({ ok: false, error: err.message, limitHit: false, ms: Date.now() - started })
|
|
189
|
+
})
|
|
190
|
+
child.on('close', code => {
|
|
191
|
+
clearTimeout(timer)
|
|
192
|
+
const parsed = parseOutput(name, stdout)
|
|
193
|
+
const problem = code !== 0 || parsed.failed
|
|
194
|
+
const message = `${parsed.text}\n${stderr}`
|
|
195
|
+
resolve({
|
|
196
|
+
ok: !problem,
|
|
197
|
+
text: parsed.text,
|
|
198
|
+
usage: parsed.usage,
|
|
199
|
+
sessionId: parsed.sessionId,
|
|
200
|
+
limitHit: problem && LIMIT_PATTERN.test(message),
|
|
201
|
+
error: problem ? firstError(stderr) || firstError(parsed.text) || `exited with code ${code}` : null,
|
|
202
|
+
ms: Date.now() - started,
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
firstError,
|
|
209
|
+
AUTH_PATTERN,
|
|
210
|
+
agentCapabilities,
|
|
211
|
+
agentCommand,
|
|
212
|
+
ENGINES,
|
|
213
|
+
detectAll,
|
|
214
|
+
defaultAssignments,
|
|
215
|
+
commandFor,
|
|
216
|
+
parseOutput,
|
|
217
|
+
run,
|
|
218
|
+
}
|