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