dispatch
uses node:path
import { buildsDir, runOnBuild } from "#codebase/tcmts/cli/builds"; import { Codebase, sessionDirFor } from "#codebase/tcmts/cli/codebase"; import { routeMcp, routes, routeTcm } from "#codebase/tcmts/cli/commands"; import { helpText, readsBranches } from "#codebase/tcmts/cli/help"; import { checkInto, execute, followMirror, importProject, Printed, unknownCommand, watch, } from "#codebase/tcmts/cli/main"; import { endSession, isSessionBranch, liveMcpServers, registerMcpServer, serveMcp, sessionClient, startSession, superviseMcp, sweepSessions, } from "#codebase/tcmts/cli/mcp"; import { inPendingMerge } from "#codebase/tcmts/cli/merge"; import { handlers } from "#codebase/tcmts/cli/run"; import type { Started } from "#codebase/tcmts/cli/run"; import { showInstallProgress, UsageError } from "#codebase/tcmts/cli/ui"; import { vitestChild } from "#codebase/tcmts/cli/vitest"; import { TS_VERSION } from "#codebase/tcmts/core/program"; import { cleanMessage } from "#codebase/tcmts/store"; import { parse } from "@bomb.sh/router"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path_1 from "node:path"; export async function dispatch(argv: string[]): Promise<number> { // -C <dir>: as if tcm were started there, as git's -C: the codebase there, not one TCM_CODEBASE or a session's // TCM_BRANCH names (the caller's): to run a tcm (a session's own, say) on a codebase made to try something in. // A tcm of its own, started there: this one read TCM_CODEBASE as it started if (argv[0] === "-C") { const dir = argv[1] && path_1.resolve(argv[1]); if (!dir || !fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UsageError( `-C <dir>: ${argv[1] ? `${argv[1]} isn't a directory` : "give the directory to run in"}`, ); const { TCM_CODEBASE: _, TCM_BRANCH: __, ...env } = process.env; const r = spawnSync( process.execPath, [...process.execArgv, process.argv[1]!, ...argv.slice(2)], { cwd: dir, env, stdio: "inherit" }, ); return r.status ?? 1; } // the builds kept (tcm's repository's dist/), for what this tcm starts too: a `tcm run` of the codebase's own tcm const kept = buildsDir(); if (kept) process.env.TCM_BUILDS = kept; // --build <build>: the command run by another tcm, a kept build (`tcm builds`) or a tcm.js if (argv[0] === "--build" || argv[0]?.startsWith("--build=")) { const spec = argv[0] === "--build" ? argv[1] : argv[0].slice("--build=".length); if (!spec) throw new UsageError( "--build <build>: known-good, previous, an id `tcm builds` lists, or a tcm.js", ); return runOnBuild(spec, argv.slice(argv[0] === "--build" ? 2 : 1)); } // a hash as tcm shows it (`#abc`) with another `#` before it (`##abc`) is that hash (not after `--`: a program's) const literals = argv.indexOf("--"); argv = argv.map((a, i) => (literals < 0 || i < literals) && /^##+[a-z2-7]+$/.test(a) ? a.replace(/^#+/, "#") : a, ); const cwd = process.cwd(); // a session's scratch files are its own, in its directory (`tcm mcp --branch`, TCM_BRANCH): a file named by // itself is there, and one with a path is where the path says // a file given is where the caller says; the default one is the codebase's (or its session's), wherever tcm runs const scratch = (f?: string, fallback = "scratch.ts") => { if (process.env.TCM_BRANCH && !/[\\/]/.test(f ?? "")) return path_1.resolve( sessionDirFor(Codebase.findRoot(cwd), process.env.TCM_BRANCH), f ?? fallback, ); if (f !== undefined) return path_1.resolve(cwd, f); try { return path_1.join(Codebase.findRoot(cwd), fallback); } catch { return path_1.resolve(cwd, fallback); } }; // the test runner's child process (see vitest.ts), not a command anyone types if (argv[0] === "internal-vitest") return (await vitestChild(argv[1]!), 0); const word = argv[0] && !argv[0].startsWith("-") ? argv[0] : undefined; if (word === "help") { const path = argv.slice(1).filter((a) => !a.startsWith("-")); if (path[0] && !Object.hasOwn(routes(), path[0])) throw new UsageError(unknownCommand(path[0])); throw new Printed(helpText(path)); } if (word && !Object.hasOwn(routes(), word)) throw new UsageError(unknownCommand(word)); const cmd = word as keyof ReturnType<typeof routes> | undefined; const rest = argv.slice(cmd ? 1 : 0); showInstallProgress(); if (!cmd) { const { model } = execute(cmd, parse(routeTcm(), { argv: rest })); watch(Codebase.find(cwd), checkInto(model.into), model.fresh); return -1; // keep running } // the editor says where the workspace is (initialize), so the codebase is found then // agents connect before there may be a codebase; each tool runs tcm itself, so this stays out of the way if (cmd === "mcp") { const { model } = execute(cmd, parse(routeMcp(), { argv: rest })); // a session: every command it runs works on its own branch, made from the codebase's own if it's new (with // --session, a new one for each server: every agent sharing one .mcp.json gets its own) const root = Codebase.findRoot(cwd); // (its tools run tcm in the codebase's directory: paths they're given are the codebase's) if (process.env.TCM_MCP_SERVER) { await serveMcp( root, [process.execPath, ...process.execArgv, process.argv[1]!], `TypeScript ${TS_VERSION}`, process.env.TCM_BRANCH || undefined, // (which tcm, to say when a call has an option it doesn't: this code's own hash, as `tcm status` says) (dispatch as unknown as Record<symbol, string | undefined>)[Symbol.for("tcm.hash")], ); return 0; } // an agent works on a branch of its own, as work on a git branch is, until the user lands it: the one --branch // names, or the one its last session (from the same directory) left holding work, or a new one; --direct, on the // codebase's own. Claimed, and said in its record, in one write transaction: no server starting meanwhile takes it const client = sessionClient(cwd); let resumed = false; if (model.direct) registerMcpServer(root, process.env.TCM_BRANCH || undefined, client); else { const claimed = await startSession(root, client, model.branch); resumed = claimed.resumed; registerMcpServer(root, claimed.branch, client); process.env.TCM_BRANCH = claimed.branch; // what sessions whose server was killed left (it's tidying: a server starts all the same) try { sweepSessions(root); } catch {} } // the session's branch as its record says now (the server can move onto another: tcm_cli ["switch", <b>]) const branchNow = () => model.direct ? undefined : (liveMcpServers(root).find((s) => s.pid === process.pid)?.branch ?? process.env.TCM_BRANCH); // the server proper, under a supervisor that starts it again (on the same branch) when this tcm is replaced or it // crashes const env = () => { const branch = branchNow(); return { ...process.env, ...(branch ? { TCM_BRANCH: branch } : {}), TCM_MCP_SERVER: "1", TCM_MCP_PID: String(process.pid), ...(resumed ? { TCM_MCP_RESUMED: "1" } : {}), ...(model.direct ? { TCM_MCP_DIRECT: "1" } : {}), }; }; const code = await superviseMcp( [process.execPath, ...process.execArgv, process.argv[1]!, "mcp"], env, root, process.argv[1]!, ); // a branch a server made for itself that holds nothing main doesn't goes with it const last = branchNow(); if (last && isSessionBranch(last)) endSession(root, last, process.pid); return code; } // what every command is given; those that don't open the codebase (`tcm init`, `tcm migrate`, …) run now, finding it // themselves if they need one const started: Started = { cmd, rest, cwd, scratch, kept, // (which tcm: this code's own hash, which covers every command) tcmHash: (dispatch as unknown as Record<symbol, string | undefined>)[Symbol.for("tcm.hash")], }; const handler = handlers()[cmd]; if ("alone" in handler) return handler.alone(started); // help and usage errors don't need a codebase: check the line before looking for one (parsed again, typed, // by its handler) const line = execute( cmd, (parse as (definition: unknown, input: { argv: string[] }) => { ok: boolean })( routes()[cmd](), { argv: rest, }, ), ) as { model?: { branch?: string; message?: string } }; const cb = Codebase.find(cwd); // --branch, of a command that only reads: that branch, read as it is, and nothing changed (this process's own, a // session's, is untouched, its `#codebase` files too) const reading = readsBranches([cmd]) ? line.model?.branch : undefined; if (reading) cb.store.readOnly(reading); cb.adoptMirror = (m) => importProject(cb, m.dir, "update", m.into, false, m.exclude, m.tests, false); // a mirror's files changed outside tcm come in first (importing them is the command's job, and checking them // against the codebase `mirror --check`'s) if (cmd !== "import" && cmd !== "mirror" && !reading) followMirror(cb); // -m: what the entry the command makes is for (each command says it afresh: a worker runs many on one store) cb.store.message = cleanMessage(line.model?.message); const run = () => handler.run({ ...started, cb }); // a merge into this branch not finished: what reads or changes code works on it as merged, settling it return !reading && [ "add", "update", "edit", "patch", "resolve", "rm", "mv", "alias", "todo", "status", "view", "ls", "find", "search", "check", "diff", "deps", "dependents", "docs", "at", "test", "history", "lint", "fmt", "run", ].includes(cmd) ? inPendingMerge(cb, run) : run(); }
uses
- tcmts.cli.builds.buildsDir
- tcmts.cli.builds.runOnBuild
- tcmts.cli.codebase.Codebase
- tcmts.cli.codebase.sessionDirFor
- tcmts.cli.commands.routeMcp
- tcmts.cli.commands.routeTcm
- tcmts.cli.commands.routes
- tcmts.cli.help.helpText
- tcmts.cli.help.readsBranches
- tcmts.cli.main.Printed
- tcmts.cli.main.checkInto
- tcmts.cli.main.execute
- tcmts.cli.main.followMirror
- tcmts.cli.main.importProject
- tcmts.cli.main.unknownCommand
- tcmts.cli.main.watch
- tcmts.cli.mcp.endSession
- tcmts.cli.mcp.isSessionBranch
- tcmts.cli.mcp.liveMcpServers
- tcmts.cli.mcp.registerMcpServer
- tcmts.cli.mcp.serveMcp
- tcmts.cli.mcp.sessionClient
- tcmts.cli.mcp.startSession
- tcmts.cli.mcp.superviseMcp
- tcmts.cli.mcp.sweepSessions
- tcmts.cli.merge.inPendingMerge
- tcmts.cli.run.Started
- tcmts.cli.run.handlers
- tcmts.cli.ui.UsageError
- tcmts.cli.ui.showInstallProgress
- tcmts.cli.vitest.vitestChild
- tcmts.core.program.TS_VERSION
- tcmts.store.cleanMessage