fix(env): edit no env file that does not parse, and no --project target after a failed refresh (#707)

env add and env remove read the target through parseEnvYaml, which answers an
empty list for a file that does not parse, then wrote that back: every
variable the file had was replaced. They now refuse and name the file.

--project resolves through manifest/projects.yaml. After a failed pull that
copy may be stale and name a namespace the project no longer uses, whose file
push would publish, so --project now changes nothing then. The root file and
--role do not depend on the manifest and still only warn.
This commit is contained in:
Saul Moro
2026-09-25 08:26:58 +02:00
parent 816926866d
commit c8fc79b417
5 changed files with 97 additions and 30 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file. See [standa
### ✨ Features
- Env variables, hooks and MCP servers are scoped the way skills and agents are. `env/<ns>/env.yaml`, `hooks/<ns>/hooks.yaml` and `mcp/<ns>/mcp.yaml` reach only members whose role or directory's project lists `<ns>` under `resources.env`, `resources.hooks` or `resources.mcp`; the root files still reach everyone. A namespace entry replaces the root entry of the same variable `key`, hook `id` or server `name`, whole: an MCP override carries its own `command`, `args`, `env` and `tools:`, and one without `tools:` reaches every tool. When a namespace stops being active the next pull, `Already synced` included, restores the root entries it overrode and removes the ones only it had; `env.sh` is rewritten even when `env/env.yaml` is missing or declares nothing, and MCP `${VAR}` reads the same resolved variables. `teamai env add` and `teamai env remove` take `--role <ns>` / `--project <id>` to edit a namespace file (`--role` warns when no role or project declares the namespace), `teamai push` picks up a change to any `env/<ns>/env.yaml`, and `teamai remove mcp <name>` removes from the root file when it defines the name, otherwise from the one namespace file that does, asking for `--role` / `--project` only when several do, and removing nothing by a bare name the root file does not define while an MCP file does not parse; a flag that names a file that does not parse says so instead of reporting the name as not found. `teamai env list`, `teamai mcp list`, `teamai hooks list` and `teamai list <env|hooks|mcp> --source repo` show each entry's namespace and whether it overrides the root, and `teamai status` and `teamai doctor` count per namespace. A hooks or MCP file that cannot be resolved makes `teamai hooks inject` and `teamai mcp inject` exit 1 instead of reporting success, and hooks or model profiles that cannot be resolved fail `teamai doctor`'s `Team hooks can be resolved` or `Team model profiles can be resolved`, where `teamai status` points. A declared namespace matches its directory case-folded, as docs namespaces do, so `env/Checkout/` serves `env: [checkout]` on Linux too, and `--role` / `--project` write into that directory. So a checkout project can point `API_BASE` or a shared MCP server at its own backend under the same name (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- Env variables, hooks and MCP servers are scoped the way skills and agents are. `env/<ns>/env.yaml`, `hooks/<ns>/hooks.yaml` and `mcp/<ns>/mcp.yaml` reach only members whose role or directory's project lists `<ns>` under `resources.env`, `resources.hooks` or `resources.mcp`; the root files still reach everyone. A namespace entry replaces the root entry of the same variable `key`, hook `id` or server `name`, whole: an MCP override carries its own `command`, `args`, `env` and `tools:`, and one without `tools:` reaches every tool. When a namespace stops being active the next pull, `Already synced` included, restores the root entries it overrode and removes the ones only it had; `env.sh` is rewritten even when `env/env.yaml` is missing or declares nothing, and MCP `${VAR}` reads the same resolved variables. `teamai env add` and `teamai env remove` take `--role <ns>` / `--project <id>` to edit a namespace file (`--role` warns when no role or project declares the namespace; neither edits a file that does not parse, and `--project` changes nothing when the team repo cannot be refreshed), `teamai push` picks up a change to any `env/<ns>/env.yaml`, and `teamai remove mcp <name>` removes from the root file when it defines the name, otherwise from the one namespace file that does, asking for `--role` / `--project` only when several do, and removing nothing by a bare name the root file does not define while an MCP file does not parse; a flag that names a file that does not parse says so instead of reporting the name as not found. `teamai env list`, `teamai mcp list`, `teamai hooks list` and `teamai list <env|hooks|mcp> --source repo` show each entry's namespace and whether it overrides the root, and `teamai status` and `teamai doctor` count per namespace. A hooks or MCP file that cannot be resolved makes `teamai hooks inject` and `teamai mcp inject` exit 1 instead of reporting success, and hooks or model profiles that cannot be resolved fail `teamai doctor`'s `Team hooks can be resolved` or `Team model profiles can be resolved`, where `teamai status` points. A declared namespace matches its directory case-folded, as docs namespaces do, so `env/Checkout/` serves `env: [checkout]` on Linux too, and `--role` / `--project` write into that directory. So a checkout project can point `API_BASE` or a shared MCP server at its own backend under the same name (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- An item in an active namespace replaces the root item of the same name for skills, agents, rules and CLAUDE.md fragments too, so contradictory versions are no longer delivered side by side. An agent in `agents/<ns>/` replaces the root agent of the same stem instead of failing the pull, and deactivating the namespace brings the root agent back; a root file of the same stem no longer withdraws a placement record. `rules/<ns>/<name>.md` replaces `rules/<name>.md`, in Hermes' `SOUL.md` block too; deeper paths replace nothing. In the rule directories shared with a member's own rules (JoyCode, OMP, Pi, Copilot), the replaced root copy is removed while it is what teamai delivered, now or at the last pull, and an edited copy is kept and named on each pull. `claudemd/<ns>/<name>.md` replaces `claudemd/<name>.md` in the managed block. A root skill received through a tag is replaced by an active namespace skill of the same name; root skills are still not delivered by default in role or project mode, and among tag matches the root skill wins over one in an inactive namespace. Installing a skill removes the files that another team version of it has and the new one lacks, when they match that version byte for byte, so switching between versions leaves nothing of the other behind, while a file you added or edited stays; one at a path another version has is named on each pull. `teamai push` writes an edit of a replacing item back to its namespace and never to the root, and the skills push scan covers project namespaces as well as role ones. `teamai recall` indexes the skills and rules you receive rather than every one in the repo, so a replaced root rule or a rule of an inactive namespace is not returned. Two namespace rules or CLAUDE.md files of one name are both delivered, since each keeps its own place. `teamai doctor` lists every replacement as a note, in `--json` under `notes`; without roles or projects nothing changes, and the notes list each name the team repo defines more than once. Keep content a project may override at the root: a namespace item never gives way, so a rule in `rules/common/` is delivered beside a project's rule of the same name (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- `docs/<ns>/` can be scoped: once any role or project lists `<ns>` under `resources.docs`, those docs reach only the members who have that namespace active. A `docs/<dir>/` that no role or project lists stays shared, so existing subdirectories keep reaching everyone. When the namespace stops being active, the next pull removes the local copies that still match the team file byte for byte, now or in an earlier team commit, and keeps an edited one, naming it. The docs mirror of `sharing.docs.localDir` copies only the docs a member receives and never removes such an edited copy. `teamai recall` and `teamai doctor`'s `Team docs delivered` follow the same filter, and doctor does not report a kept copy as stale. `team-codebase` cannot be a docs namespace, since `docs/team-codebase/` is the legacy codebase output; a manifest that declares it fails to load (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- Team model profiles can be scoped: `models/<ns>/models.yaml`, declared under `resources.models`, replaces the root profile of the same `id` for members with that namespace active. Agents switched to `team:<id>` follow the override on the next pull and return to the root profile when it deactivates; a profile that existed only in a namespace you left keeps your agent settings, and pull says it `is no longer active in your namespaces`. A stored team API key is bound to the profile `id` and the origin (scheme, host, port) of its `base_url`, so an override never sends your key to another gateway: when a profile moves to an origin you have no key for, pull leaves the agents switched to it alone and prints ``Run `teamai models switch team:<id>` to set a key for it.``, and the key for the first gateway is kept for when you leave the namespace. The same applies when the team moves the root profile to another origin, with or without roles and projects, so each member runs the switch once per new gateway. Model profiles exist only in the 0.26.0 betas, so no stable release is affected. A key stored by a beta is bound once, at the first command or pull that reads it: to the gateway TeamAI last wrote it into for your agents, or, if no agent was switched to that profile, to the root profile's current gateway. If the team moved the root profile to another origin since your agent was switched, pull leaves that agent alone and asks for the switch, rather than sending the old key to the new host. `teamai models list` shows the file each team profile comes from, and `teamai push` refuses any invalid models file (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
+4 -2
View File
@@ -948,8 +948,10 @@ Variables live in the team repo's `env/env.yaml`, and per namespace in
`teamai env add` and `teamai env remove` edit the root file, or with
`--role <ns>` / `--project <id>` that namespace's file; `--project` uses the one
env namespace the project declares, and `--role` warns when no role or project
declares that namespace, since its file then reaches nobody. `teamai push` picks
up a change to any of them.
declares that namespace, since its file then reaches nobody. Neither command
edits a file that does not parse, and `--project` changes nothing when the team
repo cannot be refreshed, since a stale `manifest/projects.yaml` may name the
wrong namespace. `teamai push` picks up a change to any of them.
```yaml
variables:
+3 -2
View File
@@ -886,8 +886,9 @@ teamai push
(见 [Env、hooks 与 MCP server 按 namespace 划分](#envhooks-与-mcp-server-按-namespace-划分))。`teamai env add` 与
`teamai env remove` 编辑根文件,加上 `--role <ns>` / `--project <id>` 时编辑对应
namespace 的文件;`--project` 使用该项目声明的唯一 env namespace;`--role` 指定的 namespace
若没有任何角色或项目声明,会给出警告,因为该文件不会送达任何人。`teamai push` 会带上
其中任何一个文件的改动。
若没有任何角色或项目声明,会给出警告,因为该文件不会送达任何人。两个命令都不会编辑无法解析的文件;
团队仓库无法刷新时 `--project` 不做任何修改,因为过期的 `manifest/projects.yaml` 可能指向错误的 namespace。
`teamai push` 会带上其中任何一个文件的改动。
```yaml
variables:
+40
View File
@@ -378,6 +378,46 @@ scope: 'user',
.toEqual([{ key: 'DB_URL', value: 'db' }, { key: 'API_BASE', value: 'x' }]);
});
// --project resolves through manifest/projects.yaml: a stale copy may name
// a namespace the project no longer uses, and push would publish that file.
it('env add --project changes nothing when the team repo cannot be refreshed', async () => {
await writeProjects();
vi.mocked(pullRepo).mockRejectedValueOnce(new Error('network down'));
await envAdd('API_BASE', 'x', { project: 'checkout' });
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('network down'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Nothing was changed'));
expect(await fse.pathExists(nsFile('checkout-env'))).toBe(false);
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
// Writing the parsed result back would replace every variable the file had.
it('env add refuses to write into a namespace file that does not parse, and leaves it as it was', async () => {
const broken = 'API_BASE: root\nDB_URL: db\n';
await fse.outputFile(nsFile('checkout'), broken);
await envAdd('NEW_KEY', 'x', { role: 'checkout' });
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('env/checkout/env.yaml'));
expect(await fse.readFile(nsFile('checkout'), 'utf-8')).toBe(broken);
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
it('env remove refuses to write into a namespace file that does not parse, and leaves it as it was', async () => {
const broken = 'variables:\n - key: API_BASE\n value: [\n';
await fse.outputFile(nsFile('checkout'), broken);
await envRemove('API_BASE', { role: 'checkout' });
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('env/checkout/env.yaml'));
expect(await fse.readFile(nsFile('checkout'), 'utf-8')).toBe(broken);
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
it('env add --project refuses a project that declares no env namespace, and writes nothing', async () => {
await writeProjects();
+49 -25
View File
@@ -2,9 +2,9 @@ import { requireInit, detectProjectConfig } from './config.js';
import { pullRepo } from './utils/git.js';
import { pathExists } from './utils/fs.js';
import { log, spinner } from './utils/logger.js';
import { EnvHandler, maskEnvValue, ENV_KEY_RE, envEntryReader } from './resources/env.js';
import { EnvHandler, maskEnvValue, ENV_KEY_RE, envEntryReader, type EnvYaml } from './resources/env.js';
import { describeEntryFailure, describeOrigin, entryFileAbsolutePath, entryFilePath, entryNamespaceFromFlags, resolveEntriesFor } from './namespaced-entries.js';
import type { GlobalOptions } from './types.js';
import type { GlobalOptions, LocalConfig } from './types.js';
import { isSelfMode } from './types.js';
const envHandler = new EnvHandler();
@@ -73,23 +73,15 @@ export async function envAdd(
const localConfig = projectConfig ?? (await requireInit()).localConfig;
const repoPath = localConfig.repo.localPath;
// Pull latest
if (!isSelfMode(localConfig)) {
const pullSpin = spinner('Pulling latest...').start();
try {
await pullRepo(repoPath);
pullSpin.succeed('Up to date');
} catch (e) {
pullSpin.warn(`Pull failed: ${(e as Error).message}`);
}
}
if (!await refreshTeamRepo(localConfig, options.project)) return;
const target = await envFileFromFlags(repoPath, options);
if (!target) return;
const { envYamlPath, where } = target;
// Parse the target env.yaml (or create new)
const envConfig = await envHandler.parseEnvYaml(envYamlPath);
// The target env.yaml, or a new one when it does not exist.
const envConfig = await readEnvFileForEdit(envYamlPath);
if (!envConfig) return;
// Check if key already exists
const existingIdx = envConfig.variables.findIndex(v => v.key === key);
@@ -130,16 +122,7 @@ export async function envRemove(key: string, options: GlobalOptions & { role?: s
const localConfig = projectConfig ?? (await requireInit()).localConfig;
const repoPath = localConfig.repo.localPath;
// Pull latest
if (!isSelfMode(localConfig)) {
const pullSpin = spinner('Pulling latest...').start();
try {
await pullRepo(repoPath);
pullSpin.succeed('Up to date');
} catch (e) {
pullSpin.warn(`Pull failed: ${(e as Error).message}`);
}
}
if (!await refreshTeamRepo(localConfig, options.project)) return;
const target = await envFileFromFlags(repoPath, options);
if (!target) return;
@@ -150,7 +133,8 @@ export async function envRemove(key: string, options: GlobalOptions & { role?: s
return;
}
const envConfig = await envHandler.parseEnvYaml(envYamlPath);
const envConfig = await readEnvFileForEdit(envYamlPath);
if (!envConfig) return;
const idx = envConfig.variables.findIndex(v => v.key === key);
if (idx === -1) {
@@ -170,6 +154,46 @@ export async function envRemove(key: string, options: GlobalOptions & { role?: s
log.info('Run `teamai push` to sync to team repo.');
}
/**
* Pull the team repo before an edit. A failure only warns, except with
* `--project`: that resolves through manifest/projects.yaml, and a stale copy
* may name a namespace the project no longer uses, whose file push would then
* publish. Returns false when the edit must not go ahead.
*/
async function refreshTeamRepo(localConfig: LocalConfig, project: string | undefined): Promise<boolean> {
if (isSelfMode(localConfig)) return true;
const pullSpin = spinner('Pulling latest...').start();
try {
await pullRepo(localConfig.repo.localPath);
pullSpin.succeed('Up to date');
return true;
} catch (e) {
if (project === undefined) {
pullSpin.warn(`Pull failed: ${(e as Error).message}`);
return true;
}
pullSpin.fail(`Pull failed: ${(e as Error).message}`);
log.error(
`The team repo could not be refreshed (${(e as Error).message}), so the env namespace of project "${project}" `
+ 'may be out of date. Nothing was changed. Fix the pull (run `teamai pull` to see why) and retry, or pass --role <ns>.',
);
process.exitCode = 1;
return false;
}
}
/**
* The env file to edit, or null when it does not parse: writing back what
* could be read would replace every variable it has.
*/
async function readEnvFileForEdit(envYamlPath: string): Promise<EnvYaml | null> {
const read = await envHandler.readEnvYaml(envYamlPath);
if (read.ok) return { variables: read.variables };
log.error(`${read.reason}. Nothing was changed. Fix the file in the team repo, then retry.`);
process.exitCode = 1;
return null;
}
/**
* The env file `--role <ns>` / `--project <id>` name, or env/env.yaml without
* either. Reports the reason and returns null when the flags name none.