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.
Files changed (75) hide show
  1. package/README.md +127 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. 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
+ }