merge: ticket 08 (#879)

This commit is contained in:
Saul Moro
2026-09-28 15:55:07 +02:00
9 changed files with 565 additions and 17 deletions
+26 -6
View File
@@ -6,7 +6,7 @@ Proposal: [#875](https://github.com/Tencent/teamai-cli/issues/875). Plan: [#879]
A team declares which secrets its members need, in the team repo, with no value. Each member supplies the value on their own machine. No secret value is written to the team repo.
This document grows with the implementation and describes only what the current version does. Today that is declaring secrets, a member's value for each team or for every team on the machine, `${VAR}` in MCP servers, keeping an MCP entry when a pull can't find a declared secret, and telling the member what to run for it. `env exec` comes later.
This document grows with the implementation and describes only what the current version does. Today that is declaring secrets, a member's value for each team or for every team on the machine, `${VAR}` in MCP servers, keeping an MCP entry when a pull can't find a declared secret, telling the member what to run for it, and running a CLI with the team's env and secrets through `teamai env exec`.
## Declaring secrets
@@ -23,7 +23,7 @@ secrets:
- `key` is required and must be a shell variable name (letters, digits and underscores, not starting with a digit).
- An entry with any other key, `value:` included, is not declared, and `pull` and `teamai doctor` name the file, the secret and the key. A value does not belong in this file.
- A file that does not parse, that has no top-level `secrets:` key, or that defines a key twice is never read as "no secrets": the secrets are not resolved this run, and `env.sh`, the env backup and the MCP servers keep what they had, as for an `env.yaml` that cannot be used. `pull` warns, `env list` exits non-zero, and `teamai doctor` fails the `Team secrets can be resolved` check, each naming the file and the fix.
- A file that does not parse, that has no top-level `secrets:` key, or that defines a key twice is never read as "no secrets": the secrets are not resolved this run, and `env.sh`, the env backup and the MCP servers keep what they had, as for an `env.yaml` that cannot be used; `env exec` applies no secrets. `pull` warns, `env list` exits non-zero, and `teamai doctor` fails the `Team secrets can be resolved` check, each naming the file and the fix.
- An empty file or `secrets: []` declares none.
It is a separate file so a member on an older CLI, which reads only `env.yaml`, ignores it, and an older `teamai env add` or `env remove`, which rewrite `env.yaml`, cannot drop it.
@@ -102,13 +102,13 @@ teamai env unset GITHUB_TOKEN [--global]
## Resolution
`${VAR}` in `mcp/mcp.yaml` resolves a declared secret in this order:
`${VAR}` in `mcp/mcp.yaml` and [`env exec`](#running-a-cli-with-env-exec) resolve a declared secret in this order:
```text
the member's value for this team teamai env set KEY [--from-env VAR]
> the member's value for the machine teamai env set KEY --global
> the member's own environment not a value a teamai env.sh exported
> missing the server is skipped
> missing the server is skipped; env exec runs the command without it
```
A team value wins over the environment because it is an explicit choice for that team: otherwise a personal `GITHUB_TOKEN` exported in `.zshrc` would override the token a member set for their work team. A machine value suits a token the member uses with every team; a team that needs another account sets its own value, which wins. Variables that are not declared as secrets resolve as before.
@@ -119,7 +119,7 @@ A team value wins over the environment because it is an explicit choice for that
**Not bound to a host.** A secret reaches whatever server `mcp.yaml` names, as `${VAR}` always has. Unlike model profile keys, it is not tied to a gateway, so whoever can change `mcp.yaml` or add a namespace decides where members' tokens go. Whoever can push to the team repo already ships hooks that run on every member's machine.
**Still reachable.** The resolved value is written in plaintext to each tool's MCP config, as before (new files are created `0600`).
**Still reachable.** The resolved value is written in plaintext to each tool's MCP config, as before (new files are created `0600`). A command run under `env exec` gets it in its environment, and so does every process it starts: an agent that runs `teamai env exec -- env` can read it. The agent skills forbid that, but nothing enforces it. This keeps secrets out of git, not away from the member's machine or the agent running on it.
## A missing secret keeps the MCP entry
@@ -136,7 +136,7 @@ While the declarations fail, `pull` and `teamai mcp inject` change no MCP server
## A missing secret tells the member what to run
An interactive `pull`, `teamai mcp list`, `teamai env list` and `teamai doctor` print one line for each declared secret with no value: the MCP servers that use it, if any, the command that sets it, and the declared `url`.
An interactive `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and `teamai env exec` (on stderr) print one line for each declared secret with no value: the MCP servers that use it, if any, the command that sets it, and the declared `url`.
```text
github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (https://github.com/settings/tokens).
@@ -150,6 +150,26 @@ GITLAB_TOKEN is not set. Run `teamai env set GITLAB_TOKEN`.
- A secret stored with `--from-env` whose variable is unset reads as missing too.
- When the declarations or the member's value file can't be read, no line is printed: the command reports that failure instead.
## Running a CLI with `env exec`
A CLI such as `gh`, `glab` or one the company ships reads its token from its environment. `teamai env exec` runs it with this directory's team env:
```text
teamai env exec -- gh pr create
teamai env exec -- glab mr list GITLAB_HOST from env.yaml and GITLAB_TOKEN from the member, for this directory's team
```
- **Scope.** The directory's scope: the project teamai is set up for there, found through git, so every worktree of a project resolves to that project, else the user scope.
- **Environment.** The command inherits teamai's environment, overlaid with the scope's `env.yaml` variables (a scope variable wins over an inherited one), then with its secrets in the [resolution order](#resolution). A key declared as a secret that has no value for this scope is removed from the command's environment, so the command never sees a value `teamai env list` doesn't show for this scope: another team's export, or the member's own export when this team's value names another variable with `--from-env`.
- **Missing secret.** The [line](#a-missing-secret-tells-the-member-what-to-run) goes to stderr, and the command runs anyway: `gh` and `glab` can still use their own login.
- **Failures.** When the declarations fail, the variables are applied and no secret is; when `env.yaml` fails, the secrets are applied and no variable is; when the value file can't be read, every declared key is removed. Each says so on stderr. A project config that exists but can't be read is named on stderr, and the command runs with the inherited environment: it is not taken for "no scope", nor for the user scope.
- **No scope.** With no project or user config, the command runs with the inherited environment and a notice on stderr. Machine values are not applied there, since no team declares which keys the command needs. An HTTP team repo delivers no env here either.
- **Output.** Everything teamai prints goes to stderr, so the command's stdout can be piped. The exit code is the command's; a command ended by a signal ends teamai with the same signal, and a signal teamai receives is passed on. A command that can't be started exits 127.
- **Nothing written.** No value is written to disk or to `debug.log`. Finding the scope does what every command that finds one does: it may adopt a project partition, save the user scope's role migration, or set up a freshly cloned single-repo project; none of these writes a value.
- **Inherited as is, with three exceptions.** Without a terminal (every agent), teamai sets `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never` where they are unset, so a git child never waits for a credential prompt. The command inherits them.
- **Not for agents.** A variable or secret named like one a model profile writes (`ANTHROPIC_*`) overrides that profile for the command. `env exec` is for CLIs, not for starting an agent.
- Put `--` before the command: without it, teamai reads the command's own options as its own.
## Rotation
A token that was ever committed to the team repo stays in its git history: rotate it, then declare it here and have each member set the new value. After `teamai env set` with a new value, `teamai pull` writes it to the MCP servers.
+26 -6
View File
@@ -6,7 +6,7 @@
团队在团队仓库中声明成员需要哪些密钥,但不写值。每个成员在自己的机器上提供值。密钥的值不会写入团队仓库。
本文档随实现逐步补充,只描述当前版本已有的行为。目前包括声明密钥、成员为每个团队或为本机所有团队设置的值、MCP server 中的 `${VAR}`,pull 找不到已声明的密钥时保留 MCP 条目,以及告诉成员该运行什么命令。`env exec` 会在后续版本加入。
本文档随实现逐步补充,只描述当前版本已有的行为。目前包括声明密钥、成员为每个团队或为本机所有团队设置的值、MCP server 中的 `${VAR}`,pull 找不到已声明的密钥时保留 MCP 条目,告诉成员该运行什么命令,以及通过 `teamai env exec` 用团队的 env 和密钥运行 CLI。
## 声明密钥
@@ -23,7 +23,7 @@ secrets:
- `key` 必填,且必须是 shell 变量名(字母、数字和下划线,不以数字开头)。
- 条目带有其他任何键(包括 `value:`)时不会被声明,`pull` 和 `teamai doctor` 会指出文件、密钥和该键。值不应该写在这个文件里。
- 文件无法解析、没有顶层 `secrets:` 键,或同一个 key 定义了两次时,绝不会被当作"没有密钥":本次不解析密钥,`env.sh`、env 备份和 MCP server 保持原样,与 `env.yaml` 无法使用时相同。`pull` 会警告,`env list` 以非零状态退出,`teamai doctor` 的 `Team secrets can be resolved` 检查失败,三者都会指出文件和修复方法。
- 文件无法解析、没有顶层 `secrets:` 键,或同一个 key 定义了两次时,绝不会被当作"没有密钥":本次不解析密钥,`env.sh`、env 备份和 MCP server 保持原样,与 `env.yaml` 无法使用时相同;`env exec` 不应用任何密钥。`pull` 会警告,`env list` 以非零状态退出,`teamai doctor` 的 `Team secrets can be resolved` 检查失败,三者都会指出文件和修复方法。
- 空文件或 `secrets: []` 表示没有声明任何密钥。
使用单独的文件,是为了让旧版 CLI(只读取 `env.yaml`)忽略它,旧版的 `teamai env add` 或 `env remove`(会重写 `env.yaml`)也不会把它丢掉。
@@ -102,13 +102,13 @@ teamai env unset GITHUB_TOKEN [--global]
## 解析顺序
`mcp/mcp.yaml` 中的 `${VAR}` 按以下顺序解析已声明的密钥:
`mcp/mcp.yaml` 中的 `${VAR}` 和 [`env exec`](#用-env-exec-运行-cli) 按以下顺序解析已声明的密钥:
```text
成员为该团队设置的值 teamai env set KEY [--from-env VAR]
> 成员为本机设置的值 teamai env set KEY --global
> 成员自己的环境 不包括 teamai env.sh 导出的值
> missing 跳过该 server
> missing 跳过该 server;env exec 不带它运行命令
```
团队值优先于环境,因为它是针对该团队的明确选择:否则 `.zshrc` 中导出的个人 `GITHUB_TOKEN` 会覆盖成员为工作团队设置的 token。本机值适合成员在所有团队中都使用的 token;需要另一个账号的团队设置自己的值,该值优先。未声明为密钥的变量按原有方式解析。
@@ -119,7 +119,7 @@ teamai env unset GITHUB_TOKEN [--global]
**不绑定主机。** 密钥会发往 `mcp.yaml` 中指定的任何 server,与 `${VAR}` 一贯的行为相同。与模型配置的密钥不同,它不绑定网关,因此能修改 `mcp.yaml` 或添加 namespace 的人决定成员的 token 发往哪里。能推送到团队仓库的人本来就能下发在每个成员机器上运行的 hooks。
**仍可访问。** 解析后的值仍以明文写入各工具的 MCP 配置(新文件以 `0600` 创建)。
**仍可访问。** 解析后的值仍以明文写入各工具的 MCP 配置(新文件以 `0600` 创建)。在 `env exec` 下运行的命令会在环境变量中拿到它,它启动的每个进程也一样:agent 运行 `teamai env exec -- env` 就能读到。agent skill 禁止这样做,但没有任何机制强制。这让密钥不进入 git,而不是让它远离成员的机器或在上面运行的 agent。
## 缺少密钥时保留 MCP 条目
@@ -136,7 +136,7 @@ teamai env unset GITHUB_TOKEN [--global]
## 缺少密钥时告诉成员该运行什么
交互式 `pull`、`teamai mcp list`、`teamai env list` 和 `teamai doctor` 会为每个没有值的已声明密钥打印一行:用到它的 MCP server(如果有)、设置它的命令,以及声明中的 `url`。
交互式 `pull`、`teamai mcp list`、`teamai env list`、`teamai doctor` 和 `teamai env exec`(输出到 stderr)会为每个没有值的已声明密钥打印一行:用到它的 MCP server(如果有)、设置它的命令,以及声明中的 `url`。
```text
github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (https://github.com/settings/tokens).
@@ -150,6 +150,26 @@ GITLAB_TOKEN is not set. Run `teamai env set GITLAB_TOKEN`.
- 用 `--from-env` 保存、但对应变量未设置的密钥同样视为缺失。
- 声明或成员的值文件无法读取时不打印这一行:命令会改为报告该失败。
## 用 `env exec` 运行 CLI
`gh`、`glab` 或公司发布的 CLI 从自己的环境变量读取 token。`teamai env exec` 用当前目录的团队 env 运行它:
```text
teamai env exec -- gh pr create
teamai env exec -- glab mr list GITLAB_HOST 来自 env.yaml,GITLAB_TOKEN 来自成员,都按当前目录的团队
```
- **Scope。** 当前目录的 scope:teamai 在此处配置的项目(通过 git 查找,因此项目的每个 worktree 都解析到该项目),否则是用户 scope。
- **环境。** 命令继承 teamai 的环境,先叠加该 scope 的 `env.yaml` 变量(scope 变量覆盖继承的同名变量),再按[解析顺序](#解析顺序)叠加它的密钥。声明为密钥、但在该 scope 下没有值的 key 会从命令的环境中移除,因此命令永远拿不到 `teamai env list` 不会显示为该 scope 的值:另一个团队导出的值,或者该团队的值用 `--from-env` 指向另一个变量时成员自己导出的值。
- **缺少密钥。** 那一[行提示](#缺少密钥时告诉成员该运行什么)输出到 stderr,命令照常运行:`gh` 和 `glab` 仍可以使用它们自己的登录。
- **失败。** 声明失败时,只应用变量,不应用任何密钥;`env.yaml` 失败时,只应用密钥,不应用任何变量;值文件无法读取时,移除所有已声明的 key。每种情况都会在 stderr 上说明。项目配置存在但无法读取时,会在 stderr 上指出该文件,并以继承的环境运行命令:既不当作"没有 scope",也不回退到用户 scope。
- **没有 scope。** 既没有项目配置也没有用户配置时,命令以继承的环境运行,并在 stderr 上给出提示。这里不应用本机值,因为没有团队声明命令需要哪些 key。HTTP 团队仓库在这里同样不提供 env。
- **输出。** teamai 打印的所有内容都输出到 stderr,因此命令的 stdout 可以直接接管道。退出码就是命令的退出码;命令被信号终止时,teamai 以同一信号结束,teamai 收到的信号会转发给命令。无法启动的命令以 127 退出。
- **不写入值。** 不会把任何值写入磁盘或 `debug.log`。查找 scope 的行为与其他查找 scope 的命令相同:可能接管项目分区、保存用户 scope 的角色迁移,或为刚克隆的单仓项目完成配置;这些写入都不包含值。
- **原样继承,有三个例外。** 没有终端时(所有 agent 都是这种情况),teamai 会在 `GIT_TERMINAL_PROMPT=0`、`GIT_ASKPASS=echo` 和 `GCM_INTERACTIVE=never` 未设置时设置它们,让 git 子进程不会等待凭据提示。命令会继承它们。
- **不用于启动 agent。** 与模型配置写入的变量同名的变量或密钥(`ANTHROPIC_*`)会为该命令覆盖那个模型配置。`env exec` 用于 CLI,而不是用来启动 agent。
- 在命令前加 `--`:否则 teamai 会把命令自己的选项当作 teamai 的选项。
## 轮换
曾经提交到团队仓库的 token 会保留在 git 历史中:先轮换它,再在这里声明,并让每个成员设置新值。用 `teamai env set` 设置新值后,`teamai pull` 会把它写入 MCP server。
+18 -1
View File
@@ -1095,6 +1095,23 @@ as "no secrets": `env.sh` and the MCP servers keep what they had, `pull` warns,
and `teamai doctor` fails a check naming the file. `teamai push` picks up a
change to any secrets file. See [Team secrets](designs/team-secrets.md).
A CLI such as `gh` or `glab` gets this directory's variables and secrets when it
runs under `teamai env exec`, which finds the scope the same way for every
worktree of a project:
```bash
teamai env exec -- gh pr create
teamai env exec -- glab mr list
```
The command inherits your environment, overlaid with the scope's `env.yaml`
variables and its secrets in the order above; a declared secret with no value
for this scope is removed from it. A missing secret prints the `teamai env set`
line on stderr and the command runs anyway. Everything teamai prints goes to
stderr, and the exit code is the command's. With no teamai config here, the
command runs with your environment and a notice. No value is written to disk.
See [Running a CLI with `env exec`](designs/team-secrets.md#running-a-cli-with-env-exec).
A variable that no longer reaches this directory is removed from `env.sh` on
the next pull, even one that reports `Already synced` because the team repo has
not moved. Until that pull runs, `teamai doctor` reports a variable that
@@ -1202,7 +1219,7 @@ Claude Code also reads the root `.mcp.json`, so this file is shared by both tool
Copilot uses its native `mcpServers` schema: `stdio` becomes `type: "local"`, remote transports keep `http` or `sse`, and every managed entry gets the required `tools: ["*"]` allowlist. TeamAI honors `COPILOT_HOME`; project configuration uses Copilot CLI's documented `.github/mcp.json` repository location. See [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers). Codex supports `stdio` and `http`; `sse` is skipped. Qoder supports the Claude-compatible `mcpServers` format in its scope-specific `.qoder/settings.json`. Kiro supports the same `mcpServers` format in its dedicated, mcpServers-only `.kiro/settings/mcp.json` (see [Kiro's MCP configuration docs](https://kiro.dev/docs/mcp/configuration/)). OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`.
**Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. A key the team declares in `env/secrets.yaml` resolves from your value for this team (`teamai env set`), then your value for the machine (`teamai env set --global`), then your own environment, which leaves out values a teamai `env.sh` exported (see [Team secrets](designs/team-secrets.md#resolution)). Any other variable resolves from the environment, then from the team env variables this directory receives (`env/env.yaml` and the active `env/<ns>/env.yaml`). Unresolved variables skip the server with a hint. A declared secret is different: when a pull can't find it, the entry an earlier pull wrote stays as it is, so it may hold a value that was since rotated, until a pull finds the new one (see [Team secrets](designs/team-secrets.md#a-missing-secret-keeps-the-mcp-entry)). An interactive `pull`, `teamai mcp list`, `teamai env list` and `teamai doctor` name a declared secret with no value, the servers that use it and the command that sets it: `` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (<url>). ``
**Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. A key the team declares in `env/secrets.yaml` resolves from your value for this team (`teamai env set`), then your value for the machine (`teamai env set --global`), then your own environment, which leaves out values a teamai `env.sh` exported (see [Team secrets](designs/team-secrets.md#resolution)). Any other variable resolves from the environment, then from the team env variables this directory receives (`env/env.yaml` and the active `env/<ns>/env.yaml`). Unresolved variables skip the server with a hint. A declared secret is different: when a pull can't find it, the entry an earlier pull wrote stays as it is, so it may hold a value that was since rotated, until a pull finds the new one (see [Team secrets](designs/team-secrets.md#a-missing-secret-keeps-the-mcp-entry)). An interactive `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and `teamai env exec` name a declared secret with no value, the servers that use it and the command that sets it: `` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (<url>). ``
teamai **resolves every `${VAR}` to its value and writes it verbatim** into each tool's config (new files are created `0600`). It does not rely on any tool's own env-var expansion: that expansion is fragile — most decisively, IDEs launched from the GUI (Dock/Launchpad) never inherit your shell's exported variables, so a `${VAR}` placeholder expands to empty and the server 401s. Resolving to plaintext makes the token present no matter how the tool is started.
+14 -1
View File
@@ -998,6 +998,19 @@ teamai env unset GITHUB_TOKEN [--global]
`pull` 会警告,`teamai doctor` 的检查失败并指出该文件。`teamai push` 会带上任何密钥文件的改动。
见[团队密钥](designs/team-secrets.zh-CN.md)。
`gh`、`glab` 等 CLI 在 `teamai env exec` 下运行时,会拿到当前目录的变量和密钥;它对项目的每个 worktree
都以同样的方式找到 scope:
```bash
teamai env exec -- gh pr create
teamai env exec -- glab mr list
```
命令继承你的环境,并叠加该 scope 的 `env.yaml` 变量和按上述顺序解析的密钥;在该 scope 下没有值的已声明密钥
会从中移除。缺少密钥时,会在 stderr 上打印 `teamai env set` 那一行提示,命令照常运行。teamai 打印的所有内容
都输出到 stderr,退出码就是命令的退出码。这里没有 teamai 配置时,命令以你的环境运行,并给出提示。
不会把任何值写入磁盘。见[用 `env exec` 运行 CLI](designs/team-secrets.zh-CN.md#用-env-exec-运行-cli)。
不再下发到该目录的变量会在下一次 pull 时从 `env.sh` 中移除,即使这次 pull 因团队仓库
未变化而提示 `Already synced` 也一样。在那次 pull 之前,`teamai doctor` 会报告
`env.sh` 中仍在导出的这类变量,前一个项目的密钥不会悄无声息地继续生效。
@@ -1099,7 +1112,7 @@ TeamAI 不会迁移或删除旧文件。Claude Code 也读取根目录的 `.mcp.
Copilot 使用原生 `mcpServers` 结构:`stdio` 写成 `type: "local"`,远程传输保留 `http` 或 `sse`,每个 TeamAI 管理的条目都会带上必需的 `tools: ["*"]` 允许列表。TeamAI 遵循 `COPILOT_HOME`,项目配置使用 Copilot CLI 官方文档指定的 `.github/mcp.json` 仓库路径。详见 [GitHub Copilot CLI 添加 MCP Server](https://docs.github.com/zh/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)。Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。Qoder 使用对应作用域 `.qoder/settings.json` 中与 Claude 兼容的 `mcpServers` 格式。Kiro 在专用的、只含 `mcpServers` 的 `.kiro/settings/mcp.json` 中使用同一格式(见 [Kiro MCP 配置文档](https://kiro.dev/docs/mcp/configuration/))。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。
**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。团队在 `env/secrets.yaml` 中声明的 key 优先取你为该团队设置的值(`teamai env set`),其次取你为本机设置的值(`teamai env set --global`),再次取你自己的环境,不包括 teamai `env.sh` 导出的值(见[团队密钥](designs/team-secrets.zh-CN.md#解析顺序))。其他变量优先来自环境变量,其次是该目录收到的团队环境变量(`env/env.yaml` 与活动的 `env/<ns>/env.yaml`)。变量无法解析则跳过并提示。已声明的密钥不同:pull 找不到它时,之前某次 pull 写入的条目原样保留,因此里面可能是已经轮换掉的旧值,直到某次 pull 找到新值(见[团队密钥](designs/team-secrets.zh-CN.md#缺少密钥时保留-mcp-条目))。交互式 `pull`、`teamai mcp list`、`teamai env list` 和 `teamai doctor` 会指出没有值的已声明密钥、用到它的 server 以及设置它的命令:`` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (<url>). ``
**密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。团队在 `env/secrets.yaml` 中声明的 key 优先取你为该团队设置的值(`teamai env set`),其次取你为本机设置的值(`teamai env set --global`),再次取你自己的环境,不包括 teamai `env.sh` 导出的值(见[团队密钥](designs/team-secrets.zh-CN.md#解析顺序))。其他变量优先来自环境变量,其次是该目录收到的团队环境变量(`env/env.yaml` 与活动的 `env/<ns>/env.yaml`)。变量无法解析则跳过并提示。已声明的密钥不同:pull 找不到它时,之前某次 pull 写入的条目原样保留,因此里面可能是已经轮换掉的旧值,直到某次 pull 找到新值(见[团队密钥](designs/team-secrets.zh-CN.md#缺少密钥时保留-mcp-条目))。交互式 `pull`、`teamai mcp list`、`teamai env list`、`teamai doctor` 和 `teamai env exec` 会指出没有值的已声明密钥、用到它的 server 以及设置它的命令:`` github: GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (<url>). ``
teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。
+1
View File
@@ -209,6 +209,7 @@ Generated: do not edit by hand. Regenerate with
- `--global` — Set it for every team on this machine; a value set for a team still wins
- `teamai env unset <key>` — Remove your value for a secret, for this directory's team, from this machine
- `--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)
## hooks
+317
View File
@@ -0,0 +1,317 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fse from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import YAML from 'yaml';
import { envExec } from '../env-exec.js';
import { getMachineSecretsPath, getTeamSecretsPath, writeSecretStore, type SecretStore } from '../secret-store.js';
import { resolveAnchors } from '../utils/git.js';
import { _resetState, _setLogFilePath, setStderrOnly } from '../utils/logger.js';
import { projectDataHome } from '../utils/partition.js';
import type { LocalConfig } from '../types.js';
/**
* `teamai env exec -- <command>` (#875, #879 S8): the command runs with the
* inherited environment overlaid with this directory's team env variables and
* its secrets in the resolution order. Everything teamai prints goes to stderr.
*/
describe('teamai env exec', () => {
let tmpDir: string;
let home: string;
let out: string;
let stdout: string[];
let stderr: string[];
const GITHUB_SECRET = 'secrets:\n - key: GITHUB_TOKEN\n url: https://github.com/settings/tokens\n';
const GITHUB_LINE = 'GITHUB_TOKEN is not set. Run `teamai env set GITHUB_TOKEN` (https://github.com/settings/tokens).';
// Writes the child's environment to a file: the child's own stdout is the
// terminal the test runs in.
const DUMP = 'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))';
const git = (cwd: string, ...args: string[]): void => { execFileSync('git', args, { cwd, stdio: 'pipe' }); };
const text = (lines: string[]): string => lines.join('\n');
/** A team repo clone with these files, and a config for it. */
async function team(name: string, files: Record<string, string>): Promise<{ repoPath: string }> {
const repoPath = path.join(tmpDir, `${name}-repo`);
await fse.outputFile(path.join(repoPath, 'teamai.yaml'), `team: ${name}\nrepo: https://example.com/${name}.git\n`);
for (const [file, content] of Object.entries(files)) await fse.outputFile(path.join(repoPath, file), content);
return { repoPath };
}
async function userScope(repoPath: string, extra: Partial<LocalConfig> = {}): Promise<LocalConfig> {
const config = { repo: { localPath: repoPath, remote: 'https://example.com/user.git' }, username: 't', scope: 'user', additionalRoles: [], ...extra };
await fse.outputFile(path.join(home, '.teamai', 'config.yaml'), YAML.stringify(config));
return config as LocalConfig;
}
/** A git project with a linked worktree, set up as a teamai project in its partition. */
async function project(repoPath: string): Promise<{ root: string; worktree: string; config: LocalConfig; partition: string }> {
const root = path.join(tmpDir, 'api');
await fse.ensureDir(root);
git(root, 'init', '-q');
git(root, 'config', 'user.email', 't@example.com');
git(root, 'config', 'user.name', 't');
git(root, 'commit', '--allow-empty', '-q', '-m', 'init');
const worktree = path.join(tmpDir, 'api-feature');
git(root, 'worktree', 'add', '-q', worktree, 'HEAD');
const anchors = await resolveAnchors(root);
if (!anchors) throw new Error('no git anchors for the fixture project');
const partition = projectDataHome(anchors.projectAnchor);
const config = {
repo: { localPath: repoPath, remote: 'https://example.com/work.git' }, username: 't', scope: 'project',
projectRoot: root, additionalRoles: [],
};
await fse.outputFile(path.join(partition, 'config.yaml'), YAML.stringify(config));
return { root, worktree, config: config as LocalConfig, partition };
}
async function exec(cwd: string, script = DUMP, args: string[] = [out]): ReturnType<typeof envExec> {
return envExec([process.execPath, '-e', script, ...args], {}, cwd);
}
async function childEnv(cwd: string): Promise<Record<string, string>> {
const outcome = await exec(cwd);
expect(outcome).toEqual({ kind: 'exited', code: 0 });
return JSON.parse(await fse.readFile(out, 'utf8')) as Record<string, string>;
}
beforeEach(async () => {
tmpDir = fs.realpathSync(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-env-exec-')));
home = path.join(tmpDir, 'home');
out = path.join(tmpDir, 'child-env.json');
await fse.ensureDir(home);
vi.stubEnv('HOME', home);
vi.stubEnv('USERPROFILE', home);
for (const key of ['GITHUB_TOKEN', 'API_URL', 'WORK_GITHUB_TOKEN']) vi.stubEnv(key, undefined);
_setLogFilePath(path.join(home, '.teamai', 'debug.log'));
stdout = [];
stderr = [];
const record = (sink: string[]) => (...parts: unknown[]) => { sink.push(parts.map(String).join(' ')); };
vi.spyOn(console, 'log').mockImplementation(record(stdout));
vi.spyOn(console, 'info').mockImplementation(record(stdout));
vi.spyOn(console, 'error').mockImplementation(record(stderr));
vi.spyOn(console, 'warn').mockImplementation(record(stderr));
});
afterEach(async () => {
setStderrOnly(false);
_resetState();
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fse.remove(tmpDir);
});
it('overlays the scope variables on the inherited environment, and resolves a secret team > machine > environment', async () => {
const { repoPath } = await team('personal', { 'env/env.yaml': 'variables:\n - key: API_URL\n value: https://team.example\n', 'env/secrets.yaml': GITHUB_SECRET });
const config = await userScope(repoPath);
vi.stubEnv('API_URL', 'https://inherited.example');
vi.stubEnv('UNRELATED', 'kept');
vi.stubEnv('GITHUB_TOKEN', 'fixture-exported');
let env = await childEnv(home);
expect(env.API_URL).toBe('https://team.example');
expect(env.UNRELATED).toBe('kept');
expect(env.GITHUB_TOKEN).toBe('fixture-exported');
await writeSecretStore(getMachineSecretsPath(), { GITHUB_TOKEN: { value: 'fixture-machine' } });
env = await childEnv(home);
expect(env.GITHUB_TOKEN).toBe('fixture-machine');
await writeSecretStore(getTeamSecretsPath(config), { GITHUB_TOKEN: { env: 'WORK_GITHUB_TOKEN' } });
vi.stubEnv('WORK_GITHUB_TOKEN', 'fixture-from-env');
env = await childEnv(home);
expect(env.GITHUB_TOKEN).toBe('fixture-from-env');
expect(text(stderr)).not.toContain('is not set');
});
it('resolves the project scope from a linked worktree of the project, and the user scope elsewhere', async () => {
const personal = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
const user = await userScope(personal.repoPath);
const work = await team('work', { 'env/secrets.yaml': GITHUB_SECRET, 'env/env.yaml': 'variables:\n - key: API_URL\n value: https://work.example\n' });
const { root, worktree, config } = await project(work.repoPath);
await writeSecretStore(getMachineSecretsPath(), { GITHUB_TOKEN: { value: 'fixture-personal' } });
await writeSecretStore(getTeamSecretsPath(config), { GITHUB_TOKEN: { value: 'fixture-work' } });
expect(getTeamSecretsPath(config)).not.toBe(getTeamSecretsPath(user));
const sub = path.join(worktree, 'src');
await fse.ensureDir(sub);
expect((await childEnv(home)).GITHUB_TOKEN).toBe('fixture-personal');
expect((await childEnv(root)).GITHUB_TOKEN).toBe('fixture-work');
const fromWorktree = await childEnv(sub);
expect(fromWorktree.GITHUB_TOKEN).toBe('fixture-work');
expect(fromWorktree.API_URL).toBe('https://work.example');
});
it('prints the missing-secret line on stderr and still runs the command', async () => {
const { repoPath } = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
await userScope(repoPath);
const env = await childEnv(home);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(text(stderr)).toContain(GITHUB_LINE);
expect(stdout).toEqual([]);
});
it('removes a declared key whose only environment value is one teamai exported for another scope', async () => {
const { repoPath } = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
await userScope(repoPath);
await fse.outputFile(path.join(home, '.teamai', 'projects', 'other-0123456789', 'env.sh'), "export GITHUB_TOKEN='fixture-other-team'\n");
vi.stubEnv('GITHUB_TOKEN', 'fixture-other-team');
const env = await childEnv(home);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(text(stderr)).toContain(GITHUB_LINE);
});
it('removes a declared key whose team entry names an unset variable, rather than pass the inherited value', async () => {
const { repoPath } = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
const config = await userScope(repoPath);
await writeSecretStore(getTeamSecretsPath(config), { GITHUB_TOKEN: { env: 'WORK_GITHUB_TOKEN' } });
vi.stubEnv('GITHUB_TOKEN', 'fixture-personal-export');
expect((await childEnv(home)).GITHUB_TOKEN).toBeUndefined();
});
it('applies no secrets on a failed declaration, still overlays the variables, and names the failure', async () => {
const { repoPath } = await team('personal', {
'env/env.yaml': 'variables:\n - key: API_URL\n value: https://team.example\n',
'env/secrets.yaml': 'secrets:\n - key: 1BAD\n',
});
const config = await userScope(repoPath);
await writeSecretStore(getTeamSecretsPath(config), { GITHUB_TOKEN: { value: 'fixture-team' } });
vi.stubEnv('GITHUB_TOKEN', 'fixture-exported');
const env = await childEnv(home);
expect(env.API_URL).toBe('https://team.example');
expect(env.GITHUB_TOKEN).toBe('fixture-exported');
expect(text(stderr)).toContain('env/secrets.yaml');
expect(text(stderr)).toContain('The command runs without team secrets.');
});
it('removes every declared key when the values file cannot be read, and says why', async () => {
const { repoPath } = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
const config = await userScope(repoPath);
await fse.outputFile(getTeamSecretsPath(config), '{"GITHUB_TOKEN": {"value": fixture-corrupt}}');
vi.stubEnv('GITHUB_TOKEN', 'fixture-exported');
const env = await childEnv(home);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(text(stderr)).toContain(getTeamSecretsPath(config));
expect(text(stderr)).not.toContain('fixture-corrupt');
});
it('names a project config that cannot be read, applies no stored values, and runs the command', async () => {
const personal = await team('personal', { 'env/env.yaml': 'variables:\n - key: API_URL\n value: https://personal.example\n', 'env/secrets.yaml': GITHUB_SECRET });
await userScope(personal.repoPath);
await writeSecretStore(getMachineSecretsPath(), { GITHUB_TOKEN: { value: 'fixture-machine' } });
const work = await team('work', {});
const { root, partition } = await project(work.repoPath);
await fse.outputFile(path.join(partition, 'config.yaml'), 'repo: [not a config\n');
const env = await childEnv(root);
expect(env.API_URL).toBeUndefined();
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(text(stderr)).toContain(path.join(partition, 'config.yaml'));
expect(text(stderr)).not.toContain('No teamai config');
});
it('with no config at all, runs with the inherited environment, applies no machine value, and says so', async () => {
await writeSecretStore(getMachineSecretsPath(), { GITHUB_TOKEN: { value: 'fixture-machine' } });
vi.stubEnv('UNRELATED', 'kept');
const nowhere = path.join(tmpDir, 'nowhere');
await fse.ensureDir(nowhere);
const env = await childEnv(nowhere);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.UNRELATED).toBe('kept');
expect(text(stderr)).toContain('No teamai config');
expect(stdout).toEqual([]);
});
it('passes the exit code and the signal through', async () => {
const nowhere = path.join(tmpDir, 'nowhere');
await fse.ensureDir(nowhere);
expect(await exec(nowhere, 'process.exit(3)', [])).toEqual({ kind: 'exited', code: 3 });
expect(await exec(nowhere, 'process.kill(process.pid, "SIGTERM"); setTimeout(() => {}, 5000)', []))
.toEqual({ kind: 'signaled', signal: 'SIGTERM' });
});
it('reports a command that cannot be started, with exit code 127', async () => {
const nowhere = path.join(tmpDir, 'nowhere');
await fse.ensureDir(nowhere);
expect(await envExec(['teamai-no-such-command-875'], {}, nowhere)).toEqual({ kind: 'exited', code: 127 });
expect(text(stderr)).toContain('teamai-no-such-command-875');
});
it('prints nothing on stdout, the scope lookup included', async () => {
const { repoPath } = await team('personal', {
'env/secrets.yaml': GITHUB_SECRET,
'manifest/roles.yaml': 'version: 1\nroles:\n - id: hai\n description: default\n resources:\n knowledge: []\n skills: []\n',
});
await userScope(repoPath);
await childEnv(home);
expect(stdout).toEqual([]);
expect(text(stderr)).toContain('Migrated legacy teamai config');
});
it('writes no member value to disk or debug.log', async () => {
const personal = await team('personal', { 'env/secrets.yaml': GITHUB_SECRET });
const user = await userScope(personal.repoPath);
const work = await team('work', { 'env/secrets.yaml': GITHUB_SECRET });
const { root, config } = await project(work.repoPath);
await writeSecretStore(getMachineSecretsPath(), { GITHUB_TOKEN: { value: 'fixture-machine' } });
await writeSecretStore(getTeamSecretsPath(user), { GITHUB_TOKEN: { value: 'fixture-team' } } satisfies SecretStore);
await writeSecretStore(getTeamSecretsPath(config), { GITHUB_TOKEN: { env: 'WORK_GITHUB_TOKEN' } });
vi.stubEnv('WORK_GITHUB_TOKEN', 'fixture-parent-env-only');
const fixtures = ['fixture-machine', 'fixture-team', 'fixture-parent-env-only'];
const holding = async (): Promise<Map<string, string>> => {
const found = new Map<string, string>();
for (const file of await filesUnder([home, root, personal.repoPath, work.repoPath])) {
const content = await fse.readFile(file);
if (fixtures.some((fixture) => content.includes(fixture))) {
found.set(file, crypto.createHash('sha256').update(content).digest('hex'));
}
}
return found;
};
const before = await holding();
expect((await childEnv(home)).GITHUB_TOKEN).toBe('fixture-team');
expect((await childEnv(root)).GITHUB_TOKEN).toBe('fixture-parent-env-only');
await fse.remove(out);
expect(await holding()).toEqual(before);
for (const log of ['debug.log', 'debug.log.1']) {
const content = await fse.readFile(path.join(home, '.teamai', log), 'utf8').catch(() => '');
for (const fixture of fixtures) expect(content).not.toContain(fixture);
}
});
});
async function filesUnder(roots: string[]): Promise<string[]> {
const files: string[] = [];
const walk = async (dir: string): Promise<void> => {
for (const entry of await fse.readdir(dir, { withFileTypes: true }).catch(() => [])) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (entry.isFile()) files.push(full);
}
};
for (const root of roots) await walk(root);
return files;
}
+7 -3
View File
@@ -371,13 +371,17 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string
* longer exists (a hook payload naming a deleted worktree) holds no project
* config; git refuses to open it, so it is not asked. Hooks go through
* resolveHookConfig (dashboard-collector.ts), which gives such a payload the
* scope its session last recorded (#810).
* scope its session last recorded (#810). `onUnreadable` is told which file
* could not be read, for a caller that names it.
*/
export async function resolveConfigForDir(dir?: string): Promise<LocalConfig | null> {
export async function resolveConfigForDir(dir?: string, onUnreadable?: UnreadableConfigSink): Promise<LocalConfig | null> {
const target = dir ?? process.cwd();
if (!(await pathExists(target))) return loadLocalConfig();
let unreadable = false;
const project = await detectProjectConfig(target, () => { unreadable = true; });
const project = await detectProjectConfig(target, (configPath, error) => {
unreadable = true;
onUnreadable?.(configPath, error);
});
if (unreadable) return null;
return project ?? loadLocalConfig();
}
+147
View File
@@ -0,0 +1,147 @@
/**
* `teamai env exec -- <command>` (#875): run a CLI such as `gh` or `glab` with
* this directory's team env. The command inherits teamai's environment,
* overlaid with the scope's env.yaml variables and its team secrets in the
* resolution order (resources/secrets.ts). The scope is the one that governs
* the directory (resolveConfigForDir), so every worktree of a project resolves
* to that project.
*
* Everything teamai prints goes to stderr, so the command's stdout can be
* piped. No value is written anywhere: the child gets it in its environment
* only.
*/
import crossSpawn from 'cross-spawn';
import { resolveConfigForDir } from './config.js';
import { describeEnvAdvisory, envAdvisories } from './env-advisories.js';
import { describeEntryFailure, resolveEntriesFor } from './namespaced-entries.js';
import { envEntryReader } from './resources/env.js';
import { declaredSecretKeys, resolveSecretDeclarations, resolveSecretValues } from './resources/secrets.js';
import type { GlobalOptions, LocalConfig } from './types.js';
import { log, setStderrOnly } from './utils/logger.js';
/** How the command ended. */
export type ExecOutcome =
| { readonly kind: 'exited'; readonly code: number }
| { readonly kind: 'signaled'; readonly signal: NodeJS.Signals };
const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
export async function envExec(command: readonly string[], options: GlobalOptions, cwd = process.cwd()): Promise<ExecOutcome> {
// Before the scope lookup, which can print (a role migration, for one).
setStderrOnly(true);
const [file, ...args] = command;
if (!file) {
log.error('No command to run. Usage: teamai env exec -- <command> [args...]');
return { kind: 'exited', code: 2 };
}
const env = await commandEnvironment(cwd);
if (options.dryRun) {
log.info(`[dry-run] Would run ${file} with this directory's team env`);
return { kind: 'exited', code: 0 };
}
return run(file, args, env, cwd);
}
/** Exit the way the command did: its exit code, or the signal that ended it. */
export function exitLike(outcome: ExecOutcome): void {
switch (outcome.kind) {
case 'exited':
process.exitCode = outcome.code;
return;
case 'signaled':
process.kill(process.pid, outcome.signal);
return;
default: {
const unhandled: never = outcome;
return unhandled;
}
}
}
/** The environment the command runs with, reporting on stderr whatever it leaves out. */
async function commandEnvironment(cwd: string): Promise<NodeJS.ProcessEnv> {
const unreadable: string[] = [];
const localConfig = await resolveConfigForDir(cwd, (configPath, error) => { unreadable.push(`${configPath} could not be read: ${error}.`); });
if (unreadable.length > 0) {
log.warn(`${unreadable.join(' ')} No team env variables or secrets were applied; the command runs with the inherited `
+ 'environment. Fix the file, or run `teamai init` again in this project.');
return { ...process.env };
}
if (!localConfig) {
log.warn('No teamai config applies to this directory, so the command runs with the inherited environment and no team '
+ 'env variables or secrets.');
return { ...process.env };
}
if (localConfig.repo.kind === 'http') {
log.warn('An HTTP team repo delivers no env variables or secrets here, so the command runs with the inherited environment.');
return { ...process.env };
}
return overlayTeamEnv(localConfig);
}
/**
* The inherited environment with this scope's variables and secrets. A key the
* scope declares as a secret gets its resolved value or is removed, so the
* command never sees a value `teamai env list` doesn't show for this scope:
* another team's export, or the member's own export when this team's value
* names another variable. A key that is also an env.yaml variable resolves as
* a secret.
*/
async function overlayTeamEnv(localConfig: LocalConfig): Promise<NodeJS.ProcessEnv> {
const env = { ...process.env };
const variables = await resolveEntriesFor(envEntryReader, localConfig);
if (variables.kind === 'failed') {
log.warn(`${describeEntryFailure(variables.failure)} The command runs without the team's env variables.`);
}
const declarations = await resolveSecretDeclarations(localConfig);
const secretKeys = declaredSecretKeys(declarations);
if (declarations.kind === 'failed') {
log.warn(`${describeEntryFailure(declarations.failure)} The command runs without team secrets.`);
}
const received = variables.kind === 'resolved' ? variables.entries : [];
for (const variable of received) {
if (!secretKeys?.has(variable.name)) env[variable.name] = variable.entry.value;
}
if (!secretKeys || secretKeys.size === 0) return env;
const values = await resolveSecretValues(localConfig, secretKeys, received);
if (values.kind === 'store-unreadable') {
log.warn(`${values.reason} The command runs without team secrets.`);
}
for (const key of secretKeys) {
const secret = values.kind === 'resolved' ? values.values.get(key) : undefined;
if (secret) env[key] = secret.value;
else delete env[key];
}
if (values.kind === 'resolved') {
for (const advisory of await envAdvisories(localConfig, null)) {
if (advisory.kind === 'missing-secret') log.warn(describeEnvAdvisory(advisory));
}
}
return env;
}
/**
* Run the command with the terminal's stdio. A signal teamai receives is
* passed on, and teamai waits for the command to end rather than exit first.
*/
function run(file: string, args: readonly string[], env: NodeJS.ProcessEnv, cwd: string): Promise<ExecOutcome> {
return new Promise((resolve) => {
// cross-spawn: on Windows, npm installs CLIs as .cmd shims spawn can't start.
const child = crossSpawn(file, [...args], { cwd, env, stdio: 'inherit' });
const forward = (signal: NodeJS.Signals): void => { child.kill(signal); };
for (const signal of FORWARDED_SIGNALS) process.on(signal, forward);
let settled = false;
const settle = (outcome: ExecOutcome): void => {
if (settled) return;
settled = true;
for (const signal of FORWARDED_SIGNALS) process.off(signal, forward);
resolve(outcome);
};
child.on('error', (e) => {
log.error(`Could not run ${file}: ${e.message}`);
settle({ kind: 'exited', code: 127 });
});
child.on('exit', (code, signal) => settle(signal ? { kind: 'signaled', signal } : { kind: 'exited', code: code ?? 1 }));
});
}
+9
View File
@@ -716,6 +716,15 @@ envCmd
await envUnset(key, { ...globalOpts, ...cmdOpts });
});
envCmd
.command('exec <command...>')
.description("Run a command with this directory's team env variables and secrets (put -- before the command)")
.action(async (command: string[]) => {
const globalOpts = program.opts() as GlobalOptions;
const { envExec, exitLike } = await import('./env-exec.js');
exitLike(await envExec(command, globalOpts));
});
// ─── Hooks commands ─────────────────────────────────────
const hooksCmd = program