Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> --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.
Expand Down
26 changes: 26 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> --dry-run` 在访问 provider 或创建 artifacts 前拒绝执行,打印 `teamai ci extract-mr --output has no --dry-run preview, nothing was run` 并以退出码 1 结束。省略 `--output` 可执行预览;去掉 `--dry-run` 可写入 artifacts。
Expand Down
5 changes: 5 additions & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command...>` — 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 <name>` — 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
Expand Down
228 changes: 228 additions & 0 deletions src/__tests__/e2e/post-init-script-1012.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}, stdin?: string): Promise<RunResult> {
const env: Record<string, string | undefined> = {
...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<string, { headers?: Record<string, string> }> };
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 <home>/.teamai/team-repo, so ../outside.mjs is <home>/.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);
});
1 change: 1 addition & 0 deletions src/dry-run-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const DRY_RUN_PREVIEW: ReadonlySet<string> = new Set([
'env set',
'env unset',
'env exec',
'script run',
'hooks list',
'hooks inject',
'mcp list',
Expand Down
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>')
.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
Expand Down
14 changes: 14 additions & 0 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):');
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading