diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cf189e229..3685db957 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2608,6 +2608,36 @@ running rather than killed, and the next pull reconciles. An interactive path, a missing file or a failed spawn is a line in `~/.teamai/debug.log` (`postPull: launched / exited / timed out`), never a failed pull. +### Post-init script + +`postPull` runs headless, so it cannot ask the member anything. A team whose +MCP servers need a personal token (`${TAPD_ACCESS_TOKEN}` in `mcp.yaml`) +declares `scripts.postInit` instead: a Node entrypoint `teamai init` runs +once, with the terminal attached and no time limit, after the local config +and hooks are in place and before the closing pull, so what it collects +(say, through `teamai env set`) is delivered by that same pull: + +```yaml +scripts: + postInit: + path: scripts/setup-env.mjs +``` + +The script gets `TEAMAI_REPO` (the team repo clone it runs in) and +`TEAMAI_SCOPE` (`user` or `project`). The same path rules as `postPull` +apply: a path that resolves outside the team repo is rejected. A non-zero +exit does not fail init; it is a warning naming the command below. Without a +terminal (stdin is not a TTY, `CI` or `TEAMAI_NONINTERACTIVE` is set) init +skips the script and says how to run it later: + +```bash +teamai script run postInit # runs the script again, then syncs MCP servers +``` + +`script run` runs the script whether or not there is a terminal, and exits +with the script's code when it fails. An older CLI that does not know +`postInit` reads the same `teamai.yaml` and ignores it. + ### CI Integration `teamai ci extract-mr --output --dry-run` refuses before provider access or artifact creation. It prints `teamai ci extract-mr --output has no --dry-run preview, nothing was run` and exits 1. Omit `--output` to preview, or omit `--dry-run` to write artifacts. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b46d2aadf..60e65c4bf 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2412,6 +2412,32 @@ scripts: 或拉起失败只会是 `~/.teamai/debug.log` 里的一行(`postPull: launched / exited / timed out`),绝不会让 pull 失败。 +### Init 后脚本 + +`postPull` 在后台运行,无法向成员提问。MCP server 需要个人 token(`mcp.yaml` +里的 `${TAPD_ACCESS_TOKEN}`)的团队改为声明 `scripts.postInit`:一个 Node +入口,`teamai init` 在本地配置与 hooks 就位之后、收尾的 pull 之前运行它一次, +终端接入、不设时限,因此它收集到的内容(比如通过 `teamai env set`)会由同一次 +pull 交付: + +```yaml +scripts: + postInit: + path: scripts/setup-env.mjs +``` + +脚本可读到 `TEAMAI_REPO`(运行所在的团队仓克隆)和 `TEAMAI_SCOPE`(`user` 或 +`project`)。路径规则与 `postPull` 相同:解析到团队仓之外会被拒绝。非零退出不会 +让 init 失败,只会给出警告并提示下面的命令。没有终端时(stdin 不是 TTY、设置了 +`CI` 或 `TEAMAI_NONINTERACTIVE`)init 会跳过脚本并说明之后如何运行: + +```bash +teamai script run postInit # 再次运行该脚本,然后同步 MCP server +``` + +`script run` 不论有没有终端都会运行脚本,脚本失败时以脚本的退出码退出。不认识 +`postInit` 的旧版 CLI 读取同一份 `teamai.yaml` 时会忽略它。 + ### CI 集成 `teamai ci extract-mr --output --dry-run` 在访问 provider 或创建 artifacts 前拒绝执行,打印 `teamai ci extract-mr --output has no --dry-run preview, nothing was run` 并以退出码 1 结束。省略 `--output` 可执行预览;去掉 `--dry-run` 可写入 artifacts。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 1031dd830..d59448a50 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -211,6 +211,11 @@ Generated: do not edit by hand. Regenerate with - `--global` — Remove the value set for every team on this machine instead - `teamai env exec ` — Run a command with this directory's team env variables and secrets (put -- before the command) +## script + +- `teamai script` — Run a script the team declares in teamai.yaml + - `teamai script run ` — Run a team script again: postInit (the setup script `teamai init` runs with the terminal attached), then sync MCP servers + ## hooks - `teamai hooks` — Manage teamai hooks in AI tool settings diff --git a/src/__tests__/e2e/post-init-script-1012.test.ts b/src/__tests__/e2e/post-init-script-1012.test.ts new file mode 100644 index 000000000..1de9febf9 --- /dev/null +++ b/src/__tests__/e2e/post-init-script-1012.test.ts @@ -0,0 +1,228 @@ +/** + * E2E (#1012): `scripts.postInit` in teamai.yaml, the interactive team setup + * script `teamai init` runs with the terminal attached. + * + * The team declares a secret (`SETUP_TOKEN`) an MCP server needs and a setup + * script that asks for it and stores it with `teamai env set`. Driven through + * the real CLI binary against a bare `git` provider remote in a sandbox HOME: + * + * 1. `teamai init` without a terminal finishes, skips the script and names + * `teamai script run postInit`; + * 2. `teamai script run postInit` runs the script, which reads the member's + * answer, sees TEAMAI_REPO / TEAMAI_SCOPE, and stores the token; the MCP + * sync that follows writes the resolved value into the tool's config; + * 3. a script path that resolves outside the team repo is rejected; + * 4. a failing script makes `script run` exit with its code, with no MCP sync. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', + GIT_TERMINAL_PROMPT: '0', +}; + +const ANSWER = 'token-from-the-member-1012'; +/** `init` wants a real repo URL; git's `insteadOf` in the sandbox HOME rewrites it to the bare remote. */ +const FAKE_URL = (name: string): string => `https://git.example.com/team/${name}.git`; + +interface RunResult { + code: number | null; + output: string; +} + +let sandbox: string; + +function git(args: string[], dir: string): void { + execFileSync('git', args, { cwd: dir, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +function runCLI(args: string[], home: string, cwd: string, extraEnv: Record = {}, stdin?: string): Promise { + const env: Record = { + ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', GIT_CONFIG_NOSYSTEM: '1', ...extraEnv, + }; + delete env.CLAUDE_CONFIG_DIR; + delete env.CI; + delete env.TEAMAI_NONINTERACTIVE; + return new Promise((resolve) => { + // stdin is a pipe, never a terminal: init must not wait on anyone. + const child = spawn('node', [CLI, ...args], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (d: Buffer) => { output += d.toString(); }); + child.stderr.on('data', (d: Buffer) => { output += d.toString(); }); + if (stdin !== undefined) child.stdin.write(stdin); + child.stdin.end(); + child.on('close', (code) => resolve({ code, output })); + }); +} + +/** The setup script the team ships: asks for the token and stores it with `teamai env set`. */ +const SETUP_SCRIPT = ` +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import readline from 'node:readline'; + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +const token = await new Promise((resolve) => rl.question('SETUP_TOKEN: ', resolve)); +rl.close(); +fs.writeFileSync(process.env.SETUP_RECORD, JSON.stringify({ + token, repo: process.env.TEAMAI_REPO, scope: process.env.TEAMAI_SCOPE, cwd: process.cwd(), +})); +const set = spawnSync(process.execPath, [process.env.TEAMAI_CLI, 'env', 'set', 'SETUP_TOKEN', '--stdin'], { + input: token + '\\n', stdio: ['pipe', 'inherit', 'inherit'], +}); +process.exit(set.status ?? 1); +`; + +/** A team repo on a bare remote: teamai.yaml with `scripts.postInit`, a declared secret, an MCP server that needs it. */ +function makeTeam(name: string, script: { path: string; body?: string }): { remote: string } { + const seed = path.join(sandbox, `${name}-seed`); + const remote = path.join(sandbox, `${name}.git`); + // A retried test starts over. + fs.rmSync(seed, { recursive: true, force: true }); + fs.rmSync(remote, { recursive: true, force: true }); + fs.mkdirSync(path.join(seed, 'env'), { recursive: true }); + fs.mkdirSync(path.join(seed, 'mcp'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), [ + `team: ${name}`, + `repo: ${FAKE_URL(name)}`, + 'provider: git', + 'sharing:', + ' mcp:', + ' autoApply: true', + ' env:', + ' injectShellProfile: false', + 'scripts:', + ' postInit:', + ` path: ${script.path}`, + '', + ].join('\n')); + fs.writeFileSync(path.join(seed, 'env', 'secrets.yaml'), 'secrets:\n - key: SETUP_TOKEN\n description: Setup token fixture\n'); + fs.writeFileSync(path.join(seed, 'mcp', 'mcp.yaml'), [ + 'servers:', + ' - name: setup-api', + ' transport: http', + ' url: https://setup.example.com/mcp', + ' headers:', + ' X-Setup-Token: ${SETUP_TOKEN}', + '', + ].join('\n')); + if (script.body !== undefined) { + const file = path.join(seed, script.path); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, script.body); + } + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['init', '-q', '--bare', '-b', 'main', remote], sandbox); + git(['push', '-q', remote, 'main'], seed); + return { remote }; +} + +function makeHome(name: string, remote: string): string { + const home = path.join(sandbox, `home-${name}`); + fs.rmSync(home, { recursive: true, force: true }); + // Cursor is installed on this machine. + fs.mkdirSync(path.join(home, '.cursor'), { recursive: true }); + execFileSync('git', ['config', '--global', `url.${remote}.insteadOf`, FAKE_URL(name)], { + cwd: sandbox, stdio: 'pipe', env: { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home }, + }); + return home; +} + +function cursorHeader(home: string): string | undefined { + const file = path.join(home, '.cursor', 'mcp.json'); + if (!fs.existsSync(file)) return undefined; + const json = JSON.parse(fs.readFileSync(file, 'utf8')) as { mcpServers?: Record }> }; + return json.mcpServers?.['setup-api']?.headers?.['X-Setup-Token']; +} + +describe.skipIf(process.platform === 'win32')('scripts.postInit (#1012)', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-1012-e2e-'))); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('init without a terminal skips the script, names how to run it, and script run then collects the token', async () => { + const { remote } = makeTeam('setup', { path: 'scripts/setup-env.mjs', body: SETUP_SCRIPT }); + const home = makeHome('setup', remote); + const cwd = path.join(sandbox, 'setup-cwd'); + fs.mkdirSync(cwd, { recursive: true }); + const record = path.join(sandbox, 'setup-record.json'); + const env = { SETUP_RECORD: record, TEAMAI_CLI: CLI }; + + // 1. init: no terminal, so the script is skipped and init still finishes. + const init = await runCLI(['init', FAKE_URL('setup'), '--provider', 'git', '--scope', 'user', '--agent', 'cursor', '--force'], home, cwd, env); + expect(init.code, init.output).toBe(0); + expect(init.output).toMatch(/teamai initialized successfully/); + expect(init.output).toContain('Team setup script scripts/setup-env.mjs needs a terminal and was not run. Run it later with `teamai script run postInit`.'); + expect(fs.existsSync(record)).toBe(false); + // The server waits for its token. + expect(cursorHeader(home)).toBeUndefined(); + expect(init.output).toMatch(/setup-api: SETUP_TOKEN is not set\. Run `teamai env set SETUP_TOKEN`/); + + // 2. script run: the script reads the member's answer and stores the token; MCP is synced after it. + const run = await runCLI(['script', 'run', 'postInit'], home, cwd, env, `${ANSWER}\n`); + expect(run.code, run.output).toBe(0); + expect(run.output).toContain('Running the team setup script scripts/setup-env.mjs'); + expect(run.output).toContain('SETUP_TOKEN: '); + expect(run.output).toContain('Set SETUP_TOKEN for this team'); + expect(run.output).toContain('Team setup script scripts/setup-env.mjs finished.'); + expect(run.output).toContain('MCP inject:'); + const recorded = JSON.parse(fs.readFileSync(record, 'utf8')) as { token: string; repo: string; scope: string; cwd: string }; + const localPath = path.join(home, '.teamai', 'team-repo'); + expect(recorded).toEqual({ token: ANSWER, repo: localPath, scope: 'user', cwd: localPath }); + expect(cursorHeader(home), run.output).toBe(ANSWER); + // The token went to the member's store, not into the output. + expect(run.output).not.toContain(ANSWER); + }, 120_000); + + it('rejects a script path that resolves outside the team repo', async () => { + const { remote } = makeTeam('escape', { path: '../outside.mjs' }); + fs.writeFileSync(path.join(sandbox, 'outside.mjs'), 'process.exit(0);\n'); + const home = makeHome('escape', remote); + const cwd = path.join(sandbox, 'escape-cwd'); + fs.mkdirSync(cwd, { recursive: true }); + // The clone lands at /.teamai/team-repo, so ../outside.mjs is /.teamai/outside.mjs. + fs.mkdirSync(path.join(home, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(home, '.teamai', 'outside.mjs'), 'process.exit(0);\n'); + + const init = await runCLI(['init', FAKE_URL('escape'), '--provider', 'git', '--scope', 'user', '--agent', 'cursor', '--force'], home, cwd); + expect(init.code, init.output).toBe(0); + const run = await runCLI(['script', 'run', 'postInit'], home, cwd, {}, '\n'); + expect(run.code, run.output).toBe(0); + expect(run.output).toMatch(/Team setup script \.\.\/outside\.mjs was not run: .*outside/); + expect(run.output).not.toContain('Running the team setup script'); + }, 120_000); + + it('a failing script makes script run exit with its code and skips the MCP sync', async () => { + const { remote } = makeTeam('failing', { path: 'scripts/fail.mjs', body: 'console.log("setup failed on purpose");\nprocess.exit(3);\n' }); + const home = makeHome('failing', remote); + const cwd = path.join(sandbox, 'failing-cwd'); + fs.mkdirSync(cwd, { recursive: true }); + + const init = await runCLI(['init', FAKE_URL('failing'), '--provider', 'git', '--scope', 'user', '--agent', 'cursor', '--force'], home, cwd); + expect(init.code, init.output).toBe(0); + const run = await runCLI(['script', 'run', 'postInit'], home, cwd, {}, ''); + expect(run.code, run.output).toBe(3); + expect(run.output).toContain('setup failed on purpose'); + expect(run.output).toContain('Team setup script scripts/fail.mjs exited 3. Run it again with `teamai script run postInit`.'); + expect(run.output).not.toContain('MCP inject:'); + }, 120_000); +}); diff --git a/src/dry-run-guard.ts b/src/dry-run-guard.ts index 639369847..73b9b3666 100644 --- a/src/dry-run-guard.ts +++ b/src/dry-run-guard.ts @@ -64,6 +64,7 @@ export const DRY_RUN_PREVIEW: ReadonlySet = new Set([ 'env set', 'env unset', 'env exec', + 'script run', 'hooks list', 'hooks inject', 'mcp list', diff --git a/src/index.ts b/src/index.ts index a2bd68ed0..80fd97bd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -736,6 +736,19 @@ envCmd exitLike(await envExec(argv.slice(argv.indexOf('exec', argv.indexOf('env')) + 1), globalOpts)); }); +// ─── Team scripts ─────────────────────────────────────── + +program + .command('script') + .description('Run a script the team declares in teamai.yaml') + .command('run ') + .description('Run a team script again: postInit (the setup script `teamai init` runs with the terminal attached), then sync MCP servers') + .action(async (name: string) => { + const globalOpts = program.opts() as GlobalOptions; + const { scriptRun } = await import('./script-cmd.js'); + await scriptRun(name, globalOpts); + }); + // ─── Hooks commands ───────────────────────────────────── const hooksCmd = program diff --git a/src/init.ts b/src/init.ts index 57d25e4ed..6a8b0f81b 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1432,6 +1432,14 @@ export async function initSelfRepo(options: GlobalOptions & { // state may not exist yet } + // Step 6.6: the team's setup script (teamai.yaml `scripts.postInit`), then + // one MCP sync so what it collected reaches the tools (#1012). + const { runDeclaredPostInit } = await import('./post-init.js'); + if ((await runDeclaredPostInit(localConfig)).kind === 'ran') { + const { mcpInject } = await import('./mcp-cmd.js'); + await mcpInject({ verbose: options.verbose }); + } + log.success('teamai initialized (single-repo mode)!'); log.info('Next steps:'); log.info(' 1. Add team resources by dropping them into .teamai/ (or author them in your AI tool as usual):'); @@ -2154,6 +2162,12 @@ export async function init(options: GlobalOptions & { } } + // Step 7.6: the team's setup script (teamai.yaml `scripts.postInit`), with + // the terminal attached, so what it collects (`teamai env set`) is there for + // the pull below. Skipped without a terminal, with the command to run later. + const { runDeclaredPostInit } = await import('./post-init.js'); + await runDeclaredPostInit(localConfig); + // Step 8: deliver the team's resources now. Rules and MCP are read once at // session start, before the SessionStart hook syncs, so without this the // first session after init runs without them. Failures are reported by pull diff --git a/src/post-init.ts b/src/post-init.ts new file mode 100644 index 000000000..ba3491084 --- /dev/null +++ b/src/post-init.ts @@ -0,0 +1,95 @@ +/** + * Team-defined post-init script (`scripts.postInit` in teamai.yaml, #1012). + * + * `scripts.postPull` runs headless at the end of every pull, so it cannot ask + * the member anything. A team whose MCP servers need a personal token has no + * step a new member sees: `teamai init` ends and the token is still unset. + * `postInit` is that step: a Node entrypoint `teamai init` runs once, with the + * terminal attached, after the local config and hooks are in place and before + * the closing pull delivers MCP, so the values it collects (say, through + * `teamai env set`) land in that same pull. `teamai script run postInit` runs + * it again for a member who already initialized. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { loadTeamConfig } from './config.js'; +import { log } from './utils/logger.js'; +import { assertSafePath } from './utils/path-safety.js'; +import { isInteractive } from './utils/prompt.js'; +import type { LocalConfig } from './types.js'; + +/** The command that runs the script again. */ +export const RUN_POST_INIT = 'teamai script run postInit'; + +/** What `runDeclaredPostInit` did: the exit code when the script ran. */ +export type PostInitOutcome = + | { readonly kind: 'none' } + | { readonly kind: 'skipped' } + | { readonly kind: 'ran'; readonly code: number | null }; + +/** + * Run the team's post-init script for this scope, if it declares one. Never + * throws: a bad path, a missing file or a failed run is a warning with the + * command that runs it again, never a failed init. + * + * Without `explicit`, the script is skipped when nobody can answer it + * (`isInteractive`: no TTY, `CI`, `TEAMAI_NONINTERACTIVE`), with the command + * to run later. `teamai script run postInit` passes `explicit` and always runs. + */ +export async function runDeclaredPostInit( + localConfig: LocalConfig, + { explicit = false }: { explicit?: boolean } = {}, +): Promise { + const repoPath = localConfig.repo.localPath; + const teamConfig = await loadTeamConfig(repoPath); + const declared = teamConfig?.scripts?.postInit?.path; + if (!declared) return { kind: 'none' }; + const scriptPath = path.resolve(repoPath, declared); + if (!fs.existsSync(scriptPath)) { + log.warn(`Team setup script ${declared} is declared in teamai.yaml but not in the team repo; it was not run.`); + return { kind: 'none' }; + } + // This script runs on every member's machine: the repo must not be able to + // point it outside its own clone, symlinks included. + try { + assertSafePath(scriptPath, [repoPath]); + } catch (e) { + log.warn(`Team setup script ${declared} was not run: ${(e as Error).message}`); + return { kind: 'none' }; + } + if (!explicit && !isInteractive()) { + log.info(`Team setup script ${declared} needs a terminal and was not run. Run it later with \`${RUN_POST_INIT}\`.`); + return { kind: 'skipped' }; + } + + log.info(`Running the team setup script ${declared}...`); + const code = await runScript(scriptPath, repoPath, localConfig.scope); + if (code === 0) { + log.success(`Team setup script ${declared} finished.`); + } else { + log.warn(`Team setup script ${declared} exited ${code ?? 'without a code'}. Run it again with \`${RUN_POST_INIT}\`.`); + } + return { kind: 'ran', code }; +} + +/** + * Run the script under our node with the terminal attached and no time limit: + * it may prompt, and only the member can say when they are done. + */ +function runScript(scriptPath: string, repoPath: string, scope: LocalConfig['scope']): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath], { + cwd: repoPath, + stdio: 'inherit', + env: { ...process.env, TEAMAI_REPO: repoPath, TEAMAI_SCOPE: scope }, + }); + child.on('error', (e) => { + log.warn(`Team setup script could not be started: ${e.message}`); + resolve(null); + }); + child.on('close', (code) => resolve(code)); + }); +} diff --git a/src/script-cmd.ts b/src/script-cmd.ts new file mode 100644 index 000000000..ee495af33 --- /dev/null +++ b/src/script-cmd.ts @@ -0,0 +1,38 @@ +/** + * `teamai script run `: run a script the team declares in teamai.yaml + * for the scope that governs this directory. Only `postInit` for now (#1012): + * the setup script `teamai init` runs with the terminal attached, for a member + * who already initialized, followed by the MCP sync init would have done. + */ +import { autoDetectInit } from './config.js'; +import { log } from './utils/logger.js'; +import { runDeclaredPostInit } from './post-init.js'; +import type { GlobalOptions } from './types.js'; + +const SCRIPTS = ['postInit'] as const; + +export async function scriptRun(name: string, options: GlobalOptions): Promise { + if (!(SCRIPTS as readonly string[]).includes(name)) { + log.error(`Unknown team script "${name}". Scripts that can be run: ${SCRIPTS.join(', ')}.`); + process.exitCode = 1; + return; + } + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); + if (!teamConfig.scripts?.postInit) { + log.info('This team declares no postInit script (teamai.yaml `scripts.postInit`).'); + return; + } + if (options.dryRun) { + log.info(`[dry-run] Would run ${teamConfig.scripts.postInit.path} from ${localConfig.repo.localPath}, then sync MCP servers.`); + return; + } + const outcome = await runDeclaredPostInit(localConfig, { explicit: true }); + if (outcome.kind !== 'ran') return; + if (outcome.code !== 0) { + process.exitCode = outcome.code ?? 1; + return; + } + // The values the script collected reach the tools now, as after init. + const { mcpInject } = await import('./mcp-cmd.js'); + await mcpInject({ verbose: options.verbose }); +} diff --git a/src/types.ts b/src/types.ts index 7bbce11ea..6b8fa6c07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -330,6 +330,14 @@ export const TeamaiConfigSchema = z.object({ postPull: z.object({ path: z.string().min(1), }).optional(), + /** Run once by `teamai init`, with the terminal attached, after the local + * config and hooks are in place and before the closing pull, so it can + * ask the member for what the team's MCP servers need (`teamai env set`). + * Skipped without a terminal; `teamai script run postInit` runs it again. + * Same path rules as `postPull`. */ + postInit: z.object({ + path: z.string().min(1), + }).optional(), }).optional(), // MCP paths are only set for tools whose config location has been verified. // Tools left without `mcp` are skipped by MCP sync rather than guessed at, so a