tspl-cli 0.0.0-stage → 0.1.0
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 +38 -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,267 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { execFileSync, spawn } = require('child_process')
|
|
6
|
+
const { confirm, input, search } = require('@inquirer/prompts')
|
|
7
|
+
|
|
8
|
+
const { request } = require('../api-client')
|
|
9
|
+
const { ENGINES } = require('../engines')
|
|
10
|
+
const { findProject } = require('../workspace')
|
|
11
|
+
const { readLinks, readActive, listTasks } = require('../config-store')
|
|
12
|
+
const { projectInstructionsText } = require('../project-config')
|
|
13
|
+
const { prepareBuild, describeProposal, blockers, runBuild, stopBuild, checkBuild, renderBuildBrief, planRepos } = require('../task/build')
|
|
14
|
+
const { askBranchChoice } = require('../task/branch-choice')
|
|
15
|
+
const { conventionsFor } = require('../task/conventions')
|
|
16
|
+
const { createContext } = require('../mcp/server')
|
|
17
|
+
const { checkEdit, checkFiles } = require('../task/gate')
|
|
18
|
+
const { engineFor } = require('../task/refine')
|
|
19
|
+
|
|
20
|
+
const taskUrl = (project, taskKey) => `/api/v1/projects/${project._id}/tasks/${encodeURIComponent(taskKey)}`
|
|
21
|
+
|
|
22
|
+
const openSession = (brief, cwd) => {
|
|
23
|
+
const { name, model } = engineFor('build')
|
|
24
|
+
const args = {
|
|
25
|
+
claude: ['claude', [brief, ...(model ? ['--model', model] : [])]],
|
|
26
|
+
codex: ['codex', [...(model ? ['-m', model] : []), brief]],
|
|
27
|
+
gemini: ['gemini', [...(model ? ['-m', model] : []), '-i', brief]],
|
|
28
|
+
}[name]
|
|
29
|
+
if (!args) throw new Error(`${ENGINES[name].label} cannot open an interactive session; paste the brief above into your AI tool`)
|
|
30
|
+
const env = { ...process.env }
|
|
31
|
+
delete env.CLAUDECODE
|
|
32
|
+
console.log(`\nOpening ${ENGINES[name].label} with the build brief…\n`)
|
|
33
|
+
const child = spawn(args[0], args[1], { cwd, stdio: 'inherit', env })
|
|
34
|
+
return new Promise(resolve => child.on('close', resolve).on('error', err => {
|
|
35
|
+
console.log(`Could not start ${args[0]}: ${err.message}`)
|
|
36
|
+
resolve(1)
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const start = async (taskKey, options) => {
|
|
41
|
+
const { project, repos } = await findProject(process.cwd())
|
|
42
|
+
const task = await request('GET', `/api/v1/projects/${project._id}/tasks/${encodeURIComponent(taskKey)}`)
|
|
43
|
+
if (!['approved', 'building'].includes(task.status)) {
|
|
44
|
+
throw new Error(`The plan is ${task.status}; building starts after approval (tspl plan show ${taskKey})`)
|
|
45
|
+
}
|
|
46
|
+
const prepare = choice =>
|
|
47
|
+
prepareBuild({
|
|
48
|
+
taskKey,
|
|
49
|
+
task,
|
|
50
|
+
project,
|
|
51
|
+
paths: new Map(repos.map(repo => [repo.name, repo.path])),
|
|
52
|
+
branch: options.branch,
|
|
53
|
+
base: options.base,
|
|
54
|
+
fromCurrent: options.fromCurrent,
|
|
55
|
+
...choice,
|
|
56
|
+
})
|
|
57
|
+
let proposal = prepare()
|
|
58
|
+
const ask = !options.yes && !options.branch && !options.base && !options.fromCurrent && process.stdin.isTTY
|
|
59
|
+
if (ask) {
|
|
60
|
+
const choice = await askBranchChoice(proposal, { input, confirm, search })
|
|
61
|
+
if (!choice) return
|
|
62
|
+
proposal = prepare(choice)
|
|
63
|
+
}
|
|
64
|
+
console.log(describeProposal(proposal))
|
|
65
|
+
const problems = blockers(proposal)
|
|
66
|
+
if (problems.length) {
|
|
67
|
+
problems.forEach(problem => console.log(`✗ ${problem}`))
|
|
68
|
+
process.exitCode = 1
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (!ask && !options.yes && !(await confirm({ message: 'Create this branch in these repos and start building?', default: true }))) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const result = await runBuild({ projectId: project._id, taskKey, task, proposal })
|
|
75
|
+
result.repos.forEach(repo => console.log(`✓ ${repo.name}: on ${proposal.branch} from ${repo.base}@${repo.baseSha.slice(0, 7)}${repo.notes.length ? ` (${repo.notes.join('; ')})` : ''}`))
|
|
76
|
+
console.log('\nCommits and pushes in these repos are limited to the files on the approved checklist.\n')
|
|
77
|
+
const loaded = await createContext(process.cwd()).load().catch(() => null)
|
|
78
|
+
const conventions = loaded ? conventionsFor(loaded.graph, loaded.index, { focus: planRepos(task.plan) }) : []
|
|
79
|
+
const brief = renderBuildBrief({ ...task, build: { branch: proposal.branch, repos: result.repos } }, result.repos, conventions, projectInstructionsText(process.cwd()))
|
|
80
|
+
console.log(brief)
|
|
81
|
+
if (options.open) {
|
|
82
|
+
process.exitCode = await openSession(brief, process.cwd())
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
console.log(`\nPaste the brief above into a fresh AI session, or run: tspl build start ${taskKey} --open`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const check = async taskKey => {
|
|
89
|
+
const { project, repos } = await findProject(process.cwd())
|
|
90
|
+
const task = await request('GET', taskUrl(project, taskKey))
|
|
91
|
+
if (!task.build) throw new Error(`No build has started for ${taskKey}`)
|
|
92
|
+
const paths = new Map(repos.map(repo => [repo.name, repo.path]))
|
|
93
|
+
const results = checkBuild({ taskKey, build: { ...task.build, ticket: task.ticket, files: [...['edit', 'create', 'remove'].flatMap(kind => (task.plan[kind] || []).map(item => item.file)), ...(task.plan.tests || []).map(test => test.file).filter(Boolean)] }, paths })
|
|
94
|
+
results.forEach(repo => {
|
|
95
|
+
console.log(`${repo.allow ? '✓' : '✗'} ${repo.name}: ${repo.files.length} file(s) changed since the base`)
|
|
96
|
+
repo.reasons.forEach(reason => console.log(` ${reason}`))
|
|
97
|
+
})
|
|
98
|
+
if (results.some(repo => !repo.allow)) process.exitCode = 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const push = async taskKey => {
|
|
102
|
+
const { project, repos } = await findProject(process.cwd())
|
|
103
|
+
const task = await request('GET', taskUrl(project, taskKey))
|
|
104
|
+
if (!task.build) throw new Error(`No build has started for ${taskKey}`)
|
|
105
|
+
const paths = new Map(repos.map(repo => [repo.name, repo.path]))
|
|
106
|
+
for (const entry of task.build.repos || []) {
|
|
107
|
+
const dir = paths.get(entry.name)
|
|
108
|
+
if (!dir) {
|
|
109
|
+
console.log(`✗ ${entry.name}: not linked on this computer`)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const result = execFileSync('git', ['-C', dir, 'push', '-u', 'origin', task.build.branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
113
|
+
console.log(`✓ ${entry.name}: pushed ${task.build.branch}${result.trim() ? `\n${result.trim()}` : ''}`)
|
|
114
|
+
const link = (task.report && (task.report.repos || []).find(repo => repo.name === entry.name) || {}).pullRequest
|
|
115
|
+
if (link) console.log(` pull request: ${link}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const guard = async hook => {
|
|
120
|
+
const top = real(tryGit(process.cwd(), ['rev-parse', '--show-toplevel']) || process.cwd())
|
|
121
|
+
const found = Object.entries(readActive()).find(([, build]) => build.repos.some(repo => real(repo.path) === top))
|
|
122
|
+
if (!found) return
|
|
123
|
+
const [taskKey, build] = found
|
|
124
|
+
const repo = build.repos.find(entry => real(entry.path) === top)
|
|
125
|
+
const branch = tryGit(top, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
126
|
+
let files
|
|
127
|
+
if (hook === 'pre-commit') {
|
|
128
|
+
files = (tryGit(top, ['-c', 'core.quotePath=false', 'diff', '--cached', '--name-only', '--diff-filter=ACDMRT']) || '').split('\n').filter(Boolean)
|
|
129
|
+
} else {
|
|
130
|
+
const zero = /^0+$/
|
|
131
|
+
const ranges = (await readStdin())
|
|
132
|
+
.split('\n')
|
|
133
|
+
.map(line => line.trim().split(/\s+/))
|
|
134
|
+
.filter(parts => parts.length === 4 && !zero.test(parts[1]))
|
|
135
|
+
.map(([, localSha, , remoteSha]) => `${zero.test(remoteSha) ? repo.baseSha || repo.base : remoteSha}..${localSha}`)
|
|
136
|
+
files = [...new Set((ranges.length ? ranges : [`${repo.baseSha || repo.base}..HEAD`]).flatMap(range =>
|
|
137
|
+
(tryGit(top, ['-c', 'core.quotePath=false', 'diff', '--name-only', range]) || '').split('\n').filter(Boolean)))]
|
|
138
|
+
}
|
|
139
|
+
const decision = checkFiles({ taskKey, build, repoName: repo.name, branch, files })
|
|
140
|
+
if (!decision.allow) {
|
|
141
|
+
process.stderr.write(`${decision.reasons.join('\n')}\n`)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tryGit = (dir, args) => {
|
|
147
|
+
try {
|
|
148
|
+
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const status = () => {
|
|
155
|
+
const active = Object.entries(readActive())
|
|
156
|
+
if (!active.length) {
|
|
157
|
+
console.log('No build in progress.')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
active.forEach(([taskKey, build]) => {
|
|
161
|
+
console.log(`${taskKey}${build.ticket ? ` · ${build.ticket}` : ''} · branch ${build.branch} · ${build.repos.map(repo => repo.name).join(', ')} · ${build.files.length} files on the checklist`)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const stop = async taskKey => {
|
|
166
|
+
const local = stopBuild(taskKey)
|
|
167
|
+
let server = ''
|
|
168
|
+
try {
|
|
169
|
+
const { project } = await findProject(process.cwd())
|
|
170
|
+
const url = `${taskUrl(project, taskKey)}/workflow`
|
|
171
|
+
const current = await request('GET', url)
|
|
172
|
+
if (current.workflow && !['ready'].includes(current.workflow.stage)) {
|
|
173
|
+
await request('POST', `${url}/stop`)
|
|
174
|
+
server = ` The task is stopped on the server too (resume with: tspl task run ${taskKey}; change the plan with: tspl plan amend ${taskKey}).`
|
|
175
|
+
} else if (!current.workflow && current.task && current.task.status === 'building') {
|
|
176
|
+
await request('POST', `${url}/amend`, { note: 'build stopped on a developer computer' })
|
|
177
|
+
server = ' The task is open for a new plan on the server too.'
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
server = ` Could not tell the server (${err.message}); run: tspl plan amend ${taskKey} when you can.`
|
|
181
|
+
}
|
|
182
|
+
console.log((local
|
|
183
|
+
? `Stopped ${taskKey}: its edit lock is lifted on this computer. The branches stay as they are.`
|
|
184
|
+
: `No task ${taskKey} on this computer.`) + server)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const readStdin = () =>
|
|
188
|
+
new Promise(resolve => {
|
|
189
|
+
let data = ''
|
|
190
|
+
process.stdin.setEncoding('utf8')
|
|
191
|
+
process.stdin.on('data', chunk => { data += chunk })
|
|
192
|
+
process.stdin.on('end', () => resolve(data))
|
|
193
|
+
process.stdin.on('error', () => resolve(data))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const real = file => {
|
|
197
|
+
try {
|
|
198
|
+
return Fs.realpathSync(file)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
try {
|
|
201
|
+
return Path.join(Fs.realpathSync(Path.dirname(file)), Path.basename(file))
|
|
202
|
+
} catch (inner) {
|
|
203
|
+
return file
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const gate = async () => {
|
|
209
|
+
let input = {}
|
|
210
|
+
try {
|
|
211
|
+
input = JSON.parse((await readStdin()) || '{}')
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
const toolInput = input.tool_input || {}
|
|
216
|
+
const target = toolInput.file_path || toolInput.notebook_path
|
|
217
|
+
if (!target) return
|
|
218
|
+
const repos = Object.values(readLinks()).flatMap(link =>
|
|
219
|
+
Object.values(link.repos || {}).map(repo => ({ projectId: link.projectId, name: repo.name, path: real(repo.path) })))
|
|
220
|
+
const decision = checkEdit({
|
|
221
|
+
file: real(Path.resolve(input.cwd || process.cwd(), target)),
|
|
222
|
+
repos,
|
|
223
|
+
active: readActive(),
|
|
224
|
+
tasks: listTasks(),
|
|
225
|
+
currentBranch: dir => {
|
|
226
|
+
try {
|
|
227
|
+
return execFileSync('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
if (!decision.allow) {
|
|
234
|
+
process.stderr.write(`${decision.reason}\n`)
|
|
235
|
+
process.exit(2)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
register(program) {
|
|
241
|
+
const build = program.command('build').description('Start building an approved plan on a new branch, see or stop builds')
|
|
242
|
+
build
|
|
243
|
+
.command('start <task>')
|
|
244
|
+
.description('Create the branch in every repo on the approved plan (checks for uncommitted work first)')
|
|
245
|
+
.option('--branch <name>', 'name of the new branch (otherwise you are asked; default: from the ticket and title)')
|
|
246
|
+
.option('--base <branch>', 'branch every repo starts from (otherwise you are asked; default: the project base branch)')
|
|
247
|
+
.option('--from-current', 'start from the branch each repo has checked out, not the project base branch')
|
|
248
|
+
.option('-y, --yes', 'do not ask to confirm')
|
|
249
|
+
.option('--open', 'open a fresh AI session with only the build brief')
|
|
250
|
+
.action(start)
|
|
251
|
+
build.command('status').description('Builds in progress on this computer').action(status)
|
|
252
|
+
build
|
|
253
|
+
.command('check <task>')
|
|
254
|
+
.description('Compare the commits on the build branch with the approved checklist (for CI too)')
|
|
255
|
+
.action(check)
|
|
256
|
+
build
|
|
257
|
+
.command('push <task>')
|
|
258
|
+
.description('Push the build branch of every repo on the plan')
|
|
259
|
+
.action(push)
|
|
260
|
+
program.command('guard <hook>', { hidden: true }).description('Git hook: refuses commits and pushes outside the approved checklist').action(guard)
|
|
261
|
+
build
|
|
262
|
+
.command('stop <task>')
|
|
263
|
+
.description('Stop a task on this computer: lifts its edit lock (branches are kept)')
|
|
264
|
+
.action(stop)
|
|
265
|
+
program.command('gate', { hidden: true }).description('Edit lock used by the AI tool before each file edit').action(gate)
|
|
266
|
+
},
|
|
267
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Os = require('os')
|
|
5
|
+
const Path = require('path')
|
|
6
|
+
const { spawnSync } = require('child_process')
|
|
7
|
+
|
|
8
|
+
const BIN = Path.join(__dirname, '..', '..', 'bin', 'tspl.js')
|
|
9
|
+
const CLAUDE_SKILL = Path.join(__dirname, '..', 'connect', 'claude-skill.md')
|
|
10
|
+
const MARK_START = '<!-- tspl:start -->'
|
|
11
|
+
const MARK_END = '<!-- tspl:end -->'
|
|
12
|
+
const TOOLS = {
|
|
13
|
+
claude: 'Claude Code',
|
|
14
|
+
codex: 'Codex CLI',
|
|
15
|
+
antigravity: 'Antigravity CLI',
|
|
16
|
+
agy: 'Antigravity CLI',
|
|
17
|
+
gemini: 'Gemini CLI',
|
|
18
|
+
cursor: 'Cursor',
|
|
19
|
+
vscode: 'VS Code (Copilot)',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const claudeDir = () => process.env.CLAUDE_CONFIG_DIR || Path.join(Os.homedir(), '.claude')
|
|
23
|
+
|
|
24
|
+
const runClaude = args => {
|
|
25
|
+
const env = { ...process.env }
|
|
26
|
+
delete env.CLAUDECODE
|
|
27
|
+
return spawnSync('claude', args, { encoding: 'utf8', env })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const quote = value => (/\s/.test(value) ? `"${value}"` : value)
|
|
31
|
+
|
|
32
|
+
const GATE_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit'
|
|
33
|
+
const isGate = entry => (entry.hooks || []).some(hook => /tspl(\.js)?"?\s+gate\b/.test(hook.command || ''))
|
|
34
|
+
|
|
35
|
+
const updateSettings = change => {
|
|
36
|
+
const file = Path.join(claudeDir(), 'settings.json')
|
|
37
|
+
let settings = {}
|
|
38
|
+
if (Fs.existsSync(file)) {
|
|
39
|
+
settings = JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
40
|
+
}
|
|
41
|
+
change(settings)
|
|
42
|
+
Fs.mkdirSync(claudeDir(), { recursive: true })
|
|
43
|
+
Fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`)
|
|
44
|
+
return file
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isUsage = entry => (entry.hooks || []).some(hook => /tspl(\.js)?"?\s+usage\b/.test(hook.command || ''))
|
|
48
|
+
|
|
49
|
+
const setGate = on =>
|
|
50
|
+
updateSettings(settings => {
|
|
51
|
+
const hooks = settings.hooks || {}
|
|
52
|
+
const others = (hooks.PreToolUse || []).filter(entry => !isGate(entry))
|
|
53
|
+
const gate = { matcher: GATE_MATCHER, hooks: [{ type: 'command', command: `"${process.execPath}" "${BIN}" gate` }] }
|
|
54
|
+
hooks.PreToolUse = on ? [...others, gate] : others
|
|
55
|
+
if (!hooks.PreToolUse.length) delete hooks.PreToolUse
|
|
56
|
+
const stops = (hooks.Stop || []).filter(entry => !isUsage(entry))
|
|
57
|
+
const usage = { hooks: [{ type: 'command', command: `"${process.execPath}" "${BIN}" usage` }] }
|
|
58
|
+
hooks.Stop = on ? [...stops, usage] : stops
|
|
59
|
+
if (!hooks.Stop.length) delete hooks.Stop
|
|
60
|
+
if (Object.keys(hooks).length) settings.hooks = hooks
|
|
61
|
+
else delete settings.hooks
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const connectClaude = options => {
|
|
65
|
+
const skillDir = Path.join(claudeDir(), 'skills', 'tspl')
|
|
66
|
+
|
|
67
|
+
if (options.remove) {
|
|
68
|
+
Fs.rmSync(skillDir, { recursive: true, force: true })
|
|
69
|
+
setGate(false)
|
|
70
|
+
runClaude(['mcp', 'remove', '--scope', 'user', 'tspl'])
|
|
71
|
+
console.log('Removed tspl from Claude Code. Restart Claude Code to finish.')
|
|
72
|
+
return { configured: true, manualSteps: ['Restart Claude Code to finish removing tspl.'] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Fs.mkdirSync(skillDir, { recursive: true })
|
|
76
|
+
Fs.copyFileSync(CLAUDE_SKILL, Path.join(skillDir, 'SKILL.md'))
|
|
77
|
+
console.log(`✓ /tspl command added (${Path.join(skillDir, 'SKILL.md')})`)
|
|
78
|
+
console.log(`✓ edit lock and usage record added (${setGate(true)})`)
|
|
79
|
+
|
|
80
|
+
runClaude(['mcp', 'remove', '--scope', 'user', 'tspl'])
|
|
81
|
+
const added = runClaude(['mcp', 'add', '--scope', 'user', 'tspl', '--', process.execPath, BIN, 'mcp'])
|
|
82
|
+
if (added.error || added.status !== 0) {
|
|
83
|
+
console.log('✗ Could not add the tspl plug-in automatically. Run this once:')
|
|
84
|
+
const command = `claude mcp add --scope user tspl -- ${quote(process.execPath)} ${quote(BIN)} mcp`
|
|
85
|
+
console.log(` ${command}`)
|
|
86
|
+
return { configured: false, manualSteps: [`Run once: ${command}`] }
|
|
87
|
+
}
|
|
88
|
+
console.log('✓ tspl plug-in added for all your projects')
|
|
89
|
+
console.log('\nRestart Claude Code, open it in a project folder, then type: /tspl <what should change>')
|
|
90
|
+
return { configured: true, manualSteps: ['Restart Claude Code, then type /tspl <what should change> in a project folder.'] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const serverArgs = () => [process.execPath, BIN, 'mcp']
|
|
94
|
+
|
|
95
|
+
const runTool = (command, args) => {
|
|
96
|
+
const env = { ...process.env }
|
|
97
|
+
delete env.CLAUDECODE
|
|
98
|
+
return spawnSync(command, args, { encoding: 'utf8', env })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const skillBody = () => Fs.readFileSync(CLAUDE_SKILL, 'utf8').replace(/^---[\s\S]*?---\n/, '')
|
|
102
|
+
|
|
103
|
+
const instructionsBlock = () =>
|
|
104
|
+
[
|
|
105
|
+
MARK_START,
|
|
106
|
+
'## tspl: planned, approved AI changes',
|
|
107
|
+
'When the user types "/tspl <something>" or asks to work on a tspl task, follow these steps with the tspl MCP tools (check_task, get_brief, find_code, propose_approaches, select_approach, submit_plan, plan_status, start_build, finish_build).',
|
|
108
|
+
'',
|
|
109
|
+
skillBody().replace('Task from the user: $ARGUMENTS', 'Task from the user: the text after /tspl').trim(),
|
|
110
|
+
MARK_END,
|
|
111
|
+
'',
|
|
112
|
+
].join('\n')
|
|
113
|
+
|
|
114
|
+
const writeBlock = (file, on) => {
|
|
115
|
+
const current = Fs.existsSync(file) ? Fs.readFileSync(file, 'utf8') : ''
|
|
116
|
+
const pattern = new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}\\n?`, 'g')
|
|
117
|
+
const stripped = current.replace(pattern, '')
|
|
118
|
+
const next = on ? `${stripped.trimEnd()}${stripped.trim() ? '\n\n' : ''}${instructionsBlock()}` : stripped.trim() ? `${stripped.trimEnd()}\n` : ''
|
|
119
|
+
Fs.mkdirSync(Path.dirname(file), { recursive: true })
|
|
120
|
+
Fs.writeFileSync(file, next)
|
|
121
|
+
return file
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const writeServers = (file, key, on, entry) => {
|
|
125
|
+
let data = {}
|
|
126
|
+
if (Fs.existsSync(file)) {
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(Fs.readFileSync(file, 'utf8')) || {}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new Error(`${file} is not valid JSON; fix it first`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const servers = { ...(data[key] || {}) }
|
|
134
|
+
if (on) servers.tspl = entry
|
|
135
|
+
else delete servers.tspl
|
|
136
|
+
data[key] = servers
|
|
137
|
+
Fs.mkdirSync(Path.dirname(file), { recursive: true })
|
|
138
|
+
Fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`)
|
|
139
|
+
return file
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const enforcementNote = label => {
|
|
143
|
+
console.log(`✓ tspl plug-in added to ${label}`)
|
|
144
|
+
console.log(' Approved files are enforced by git hooks during a build (same for every tool); plan approval happens in a terminal or the console.')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const connectCodex = options => {
|
|
148
|
+
const agents = Path.join(Os.homedir(), '.codex', 'AGENTS.md')
|
|
149
|
+
runTool('codex', ['mcp', 'remove', 'tspl'])
|
|
150
|
+
if (options.remove) {
|
|
151
|
+
writeBlock(agents, false)
|
|
152
|
+
console.log('Removed tspl from Codex CLI.')
|
|
153
|
+
return { configured: true, manualSteps: [] }
|
|
154
|
+
}
|
|
155
|
+
const added = runTool('codex', ['mcp', 'add', 'tspl', '--', ...serverArgs()])
|
|
156
|
+
if (added.error || added.status !== 0) {
|
|
157
|
+
throw new Error(`Could not add the tspl plug-in to Codex: ${(added.stderr || added.error && added.error.message || '').trim()}. Run once: codex mcp add tspl -- ${serverArgs().map(quote).join(' ')}`)
|
|
158
|
+
}
|
|
159
|
+
enforcementNote('Codex CLI')
|
|
160
|
+
console.log(`✓ /tspl instructions added to ${writeBlock(agents, true)}`)
|
|
161
|
+
console.log('\nStart codex in a project folder and type: /tspl <task id>')
|
|
162
|
+
return { configured: true, manualSteps: ['Start codex in a project folder and type /tspl <task id>.'] }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const connectAntigravity = options => {
|
|
166
|
+
const skillDir = Path.join(Os.homedir(), '.gemini', 'config', 'skills', 'tspl')
|
|
167
|
+
if (options.remove) {
|
|
168
|
+
runTool('agy', ['mcp', 'remove', 'tspl'])
|
|
169
|
+
Fs.rmSync(skillDir, { recursive: true, force: true })
|
|
170
|
+
console.log('Removed tspl from the Antigravity CLI.')
|
|
171
|
+
return { configured: true, manualSteps: [] }
|
|
172
|
+
}
|
|
173
|
+
const added = runTool('agy', ['mcp', 'add', 'tspl', ...serverArgs()])
|
|
174
|
+
if (added.error || added.status !== 0) {
|
|
175
|
+
throw new Error(`Could not add the tspl plug-in to Antigravity: ${(added.stderr || added.error && added.error.message || '').trim()}. Run once: agy mcp add tspl ${serverArgs().map(quote).join(' ')}`)
|
|
176
|
+
}
|
|
177
|
+
enforcementNote('the Antigravity CLI')
|
|
178
|
+
Fs.mkdirSync(skillDir, { recursive: true })
|
|
179
|
+
Fs.copyFileSync(CLAUDE_SKILL, Path.join(skillDir, 'SKILL.md'))
|
|
180
|
+
console.log(`✓ /tspl skill added (${Path.join(skillDir, 'SKILL.md')})`)
|
|
181
|
+
console.log('\nStart agy in a project folder and type: /tspl <task id>')
|
|
182
|
+
return { configured: true, manualSteps: ['Start agy in a project folder and type /tspl <task id>.'] }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const connectGemini = options => {
|
|
186
|
+
const settings = Path.join(Os.homedir(), '.gemini', 'settings.json')
|
|
187
|
+
const memory = Path.join(Os.homedir(), '.gemini', 'GEMINI.md')
|
|
188
|
+
writeServers(settings, 'mcpServers', !options.remove, { command: process.execPath, args: [BIN, 'mcp'] })
|
|
189
|
+
writeBlock(memory, !options.remove)
|
|
190
|
+
if (options.remove) {
|
|
191
|
+
console.log('Removed tspl from the Gemini CLI.')
|
|
192
|
+
return { configured: true, manualSteps: [] }
|
|
193
|
+
}
|
|
194
|
+
enforcementNote('the Gemini CLI')
|
|
195
|
+
console.log(`✓ /tspl instructions added to ${memory}`)
|
|
196
|
+
console.log('\nStart gemini in a project folder and type: /tspl <task id>')
|
|
197
|
+
return { configured: true, manualSteps: ['Start gemini in a project folder and type /tspl <task id>.'] }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const connectCursor = options => {
|
|
201
|
+
const file = Path.join(Os.homedir(), '.cursor', 'mcp.json')
|
|
202
|
+
const rule = Path.join(Os.homedir(), '.tspl', 'tspl-instructions.md')
|
|
203
|
+
writeServers(file, 'mcpServers', !options.remove, { command: process.execPath, args: [BIN, 'mcp'] })
|
|
204
|
+
if (options.remove) {
|
|
205
|
+
console.log('Removed tspl from Cursor.')
|
|
206
|
+
return { configured: true, manualSteps: [] }
|
|
207
|
+
}
|
|
208
|
+
Fs.mkdirSync(Path.dirname(rule), { recursive: true })
|
|
209
|
+
Fs.writeFileSync(rule, instructionsBlock())
|
|
210
|
+
enforcementNote('Cursor')
|
|
211
|
+
console.log(`✓ MCP server written to ${file}`)
|
|
212
|
+
console.log(` Cursor has no user-wide skill file: paste ${rule} into Cursor Settings → Rules → User Rules once.`)
|
|
213
|
+
return { configured: true, manualSteps: [`Paste ${rule} into Cursor Settings → Rules → User Rules once.`, 'Restart Cursor.'] }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const vscodeUserDir = () => {
|
|
217
|
+
if (process.platform === 'darwin') return Path.join(Os.homedir(), 'Library', 'Application Support', 'Code', 'User')
|
|
218
|
+
if (process.platform === 'win32') return Path.join(process.env.APPDATA || Path.join(Os.homedir(), 'AppData', 'Roaming'), 'Code', 'User')
|
|
219
|
+
return Path.join(Os.homedir(), '.config', 'Code', 'User')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const connectVscode = options => {
|
|
223
|
+
const file = Path.join(vscodeUserDir(), 'mcp.json')
|
|
224
|
+
const rule = Path.join(Os.homedir(), '.tspl', 'tspl-instructions.md')
|
|
225
|
+
writeServers(file, 'servers', !options.remove, { type: 'stdio', command: process.execPath, args: [BIN, 'mcp'] })
|
|
226
|
+
if (options.remove) {
|
|
227
|
+
console.log('Removed tspl from VS Code.')
|
|
228
|
+
return { configured: true, manualSteps: [] }
|
|
229
|
+
}
|
|
230
|
+
Fs.mkdirSync(Path.dirname(rule), { recursive: true })
|
|
231
|
+
Fs.writeFileSync(rule, instructionsBlock())
|
|
232
|
+
enforcementNote('VS Code (Copilot agent mode)')
|
|
233
|
+
console.log(`✓ MCP server written to ${file}`)
|
|
234
|
+
console.log(` Add ${rule} as a Copilot instructions file (Settings → "chat.instructionsFilesLocations") once. The Claude Code extension needs only: tspl connect claude.`)
|
|
235
|
+
return { configured: true, manualSteps: [`Add ${rule} as a Copilot instructions file (chat.instructionsFilesLocations) once.`, 'Restart VS Code.'] }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const CONNECTORS = { claude: connectClaude, codex: connectCodex, antigravity: connectAntigravity, agy: connectAntigravity, gemini: connectGemini, cursor: connectCursor, vscode: connectVscode }
|
|
239
|
+
|
|
240
|
+
const connect = (tool, options = {}) => {
|
|
241
|
+
const name = String(tool).toLowerCase()
|
|
242
|
+
const run = CONNECTORS[name]
|
|
243
|
+
if (!run) throw new Error(`Unknown tool "${tool}". Tools: ${Object.keys(TOOLS).join(', ')}`)
|
|
244
|
+
const result = run(options) || {}
|
|
245
|
+
return { configured: result.configured !== false, manualSteps: result.manualSteps || [], error: null }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const connectTool = (tool, options = {}) => {
|
|
249
|
+
try {
|
|
250
|
+
return connect(tool, options)
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { configured: false, manualSteps: [], error: err.message }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
TOOLS,
|
|
258
|
+
connectTool,
|
|
259
|
+
writeBlock,
|
|
260
|
+
writeServers,
|
|
261
|
+
register(program) {
|
|
262
|
+
program
|
|
263
|
+
.command('connect <tool>')
|
|
264
|
+
.description('Add tspl to your AI tool so /tspl works there (claude, codex, antigravity, gemini, cursor, vscode)')
|
|
265
|
+
.option('--remove', 'take tspl out of that tool again')
|
|
266
|
+
.action((tool, options) => {
|
|
267
|
+
connect(tool, options)
|
|
268
|
+
})
|
|
269
|
+
},
|
|
270
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { ENGINE_STEPS } = require('../shared')
|
|
4
|
+
|
|
5
|
+
const { readEngines, writeEngines } = require('../config-store')
|
|
6
|
+
const { ENGINES, detectAll, defaultAssignments, run } = require('../engines')
|
|
7
|
+
|
|
8
|
+
const currentSetup = installed => {
|
|
9
|
+
const saved = readEngines()
|
|
10
|
+
return {
|
|
11
|
+
assignments: saved.assignments || defaultAssignments(installed),
|
|
12
|
+
models: saved.models || {},
|
|
13
|
+
saved: Boolean(saved.assignments),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const describe = (setup, step) => {
|
|
18
|
+
const engine = setup.assignments[step]
|
|
19
|
+
const model = engine && setup.models[step]
|
|
20
|
+
return engine ? `${engine}${model ? ` (${model})` : ''}` : '(none)'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const list = () => {
|
|
24
|
+
const installed = detectAll()
|
|
25
|
+
const setup = currentSetup(installed)
|
|
26
|
+
|
|
27
|
+
console.log('AI engines on this machine')
|
|
28
|
+
Object.entries(ENGINES).forEach(([name, engine]) => {
|
|
29
|
+
const found = installed[name]
|
|
30
|
+
const status = found.installed
|
|
31
|
+
? `installed ${found.version ? `(${found.version})` : ''}`.padEnd(34) + `uses ${engine.uses}`
|
|
32
|
+
: `not installed`.padEnd(34) + `install: ${engine.install}, then ${engine.login}`
|
|
33
|
+
console.log(` ${name.padEnd(8)}${status}`)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
console.log(`\nWhich engine does each step${setup.saved ? '' : ' (suggested; nothing saved yet)'}`)
|
|
37
|
+
Object.entries(ENGINE_STEPS).forEach(([step, purpose]) => {
|
|
38
|
+
console.log(` ${step.padEnd(8)}${describe(setup, step).padEnd(22)}${purpose}`)
|
|
39
|
+
})
|
|
40
|
+
if (setup.assignments.build && setup.assignments.build === setup.assignments.verify) {
|
|
41
|
+
console.log('\n Note: build and verify use the same engine. Install a second engine so the work is checked by a different AI.')
|
|
42
|
+
}
|
|
43
|
+
if (!Object.keys(setup.assignments).length) {
|
|
44
|
+
console.log('\n No AI engine is installed yet. Install one of the engines above first.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('\nChange a step: tspl engines set <step> <engine> [--model <name>]')
|
|
48
|
+
console.log('Check they work: tspl engines test (sends one tiny prompt to each engine you use)')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const set = (step, engine, options) => {
|
|
52
|
+
if (!ENGINE_STEPS[step]) {
|
|
53
|
+
throw new Error(`Unknown step "${step}". Steps: ${Object.keys(ENGINE_STEPS).join(', ')}`)
|
|
54
|
+
}
|
|
55
|
+
if (!ENGINES[engine]) {
|
|
56
|
+
throw new Error(`Unknown engine "${engine}". Engines: ${Object.keys(ENGINES).join(', ')}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const installed = detectAll()
|
|
60
|
+
if (!installed[engine].installed) {
|
|
61
|
+
throw new Error(`${ENGINES[engine].label} is not installed. Install: ${ENGINES[engine].install}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const setup = currentSetup(installed)
|
|
65
|
+
const assignments = { ...setup.assignments, [step]: engine }
|
|
66
|
+
const models = { ...setup.models }
|
|
67
|
+
if (options.model) {
|
|
68
|
+
models[step] = options.model
|
|
69
|
+
} else {
|
|
70
|
+
delete models[step]
|
|
71
|
+
}
|
|
72
|
+
writeEngines({ assignments, models })
|
|
73
|
+
|
|
74
|
+
console.log(`${step} now uses ${engine}${options.model ? ` (${options.model})` : ''}.`)
|
|
75
|
+
if (assignments.build && assignments.build === assignments.verify) {
|
|
76
|
+
console.log('Note: build and verify now use the same engine.')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const test = async engineArg => {
|
|
81
|
+
const installed = detectAll()
|
|
82
|
+
const setup = currentSetup(installed)
|
|
83
|
+
const targets = engineArg
|
|
84
|
+
? [[engineArg, undefined]]
|
|
85
|
+
: [...new Map(Object.keys(ENGINE_STEPS)
|
|
86
|
+
.filter(step => setup.assignments[step])
|
|
87
|
+
.map(step => [setup.assignments[step], setup.models[step]])).entries()]
|
|
88
|
+
|
|
89
|
+
if (!targets.length) {
|
|
90
|
+
console.log('No engines to test. Install one first; see: tspl engines')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [name, model] of targets) {
|
|
95
|
+
if (!ENGINES[name]) {
|
|
96
|
+
console.log(` ✗ ${name}: unknown engine`)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (!installed[name].installed) {
|
|
100
|
+
console.log(` ✗ ${name}: not installed (${ENGINES[name].install})`)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(` … ${name}${model ? ` (${model})` : ''}: sending a tiny test prompt`)
|
|
104
|
+
const result = await run(name, 'Reply with just the word OK.', { model, timeoutMs: 90000 })
|
|
105
|
+
process.stdout.write('\r')
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
const tokens = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tokens` : ''
|
|
108
|
+
console.log(` ✓ ${name}${model ? ` (${model})` : ''}: answered "${result.text.slice(0, 20)}" in ${(result.ms / 1000).toFixed(1)}s${tokens}`)
|
|
109
|
+
} else if (result.limitHit) {
|
|
110
|
+
console.log(` ✗ ${name}: your subscription's usage limit is reached. Tasks using it will pause and resume later.`)
|
|
111
|
+
} else {
|
|
112
|
+
console.log(` ✗ ${name}: ${result.error}. Is it logged in? ${ENGINES[name].login}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
currentSetup,
|
|
119
|
+
register(program) {
|
|
120
|
+
const engines = program
|
|
121
|
+
.command('engines')
|
|
122
|
+
.description('Choose which AI engine (your own subscription) does each step')
|
|
123
|
+
.action(list)
|
|
124
|
+
|
|
125
|
+
engines
|
|
126
|
+
.command('set <step> <engine>')
|
|
127
|
+
.description(`Use an engine for a step (${Object.keys(ENGINE_STEPS).join(', ')})`)
|
|
128
|
+
.option('--model <name>', 'a specific model of that engine, e.g. sonnet or opus for claude')
|
|
129
|
+
.action(set)
|
|
130
|
+
|
|
131
|
+
engines
|
|
132
|
+
.command('test [engine]')
|
|
133
|
+
.description('Send one tiny prompt to each engine you use, to check it is logged in')
|
|
134
|
+
.action(test)
|
|
135
|
+
},
|
|
136
|
+
}
|