Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
34c32b3
fix(manifest): reject namespace strings that are not safe path segments
SaulMoro Sep 21, 2026
af2db67
docs(changelog): note the manifest namespace guard
SaulMoro Sep 22, 2026
79fb8a4
fix(manifest): guard namespaces against traversal only, and say what …
SaulMoro Sep 22, 2026
399eb8e
fix(manifest): reject DEL and C1 controls in a namespace too
SaulMoro Sep 22, 2026
a4f579e
fix(manifest): reject the Win32 dot/space aliases of . and ..
SaulMoro Sep 22, 2026
b52a3b1
fix(manifest): keep the project id rule untouched, scope the role docs
SaulMoro Sep 22, 2026
391bafd
docs(manifest): state the id and namespace rules separately
SaulMoro Sep 22, 2026
bf0d79f
fix(manifest): reject a namespace with a trailing '.' or space
SaulMoro Sep 22, 2026
d9c2747
fix(pull): a roles manifest that does not parse must not widen delivery
SaulMoro Sep 22, 2026
32efce7
fix(manifest): narrow 'absent' to ENOENT, refuse Windows device names
SaulMoro Sep 22, 2026
83cb66c
fix(manifest): narrow every roles fallback, drop COM0/LPT0 from the d…
SaulMoro Sep 22, 2026
b7bb7b2
fix(manifest): prove absence before trusting it, add the superscript …
SaulMoro Sep 22, 2026
4ea91e2
fix(init): a broken roles manifest must stop init, a skipped prompt m…
SaulMoro Sep 22, 2026
0f478c3
chore(ci): re-run review against the rebased head
SaulMoro Sep 22, 2026
45fd115
fix(manifest): expand '~' when reading a manifest, guard role ids use…
SaulMoro Sep 22, 2026
afef8b6
fix(config): expand '~' in repo.localPath at the config boundary
SaulMoro Sep 22, 2026
9bee58c
fix(manifest): reject the CONIN$ and CONOUT$ console devices too
SaulMoro Sep 22, 2026
ff05b3e
fix(push): guard the role id silent mode uses as a namespace
SaulMoro Sep 22, 2026
27112c8
fix(manifest): reject namespaces that differ only by case, classify t…
SaulMoro Sep 22, 2026
d606798
fix(pull): check roles.yaml against projects.yaml for role-less membe…
SaulMoro Sep 22, 2026
1ce9c4c
fix(manifest): fold case the way filesystems do when comparing namesp…
SaulMoro Sep 23, 2026
e6ef558
fix(config): keep the config loadable when the roles manifest is broken
SaulMoro Sep 23, 2026
2d0bb35
fix(status): report a resource type it cannot scan instead of crashing
SaulMoro Sep 23, 2026
0f80dcc
fix(status): do not report "(none)" when a resource type could not be…
SaulMoro Sep 23, 2026
2965e4d
fix(config): a role the manifest could not resolve matches no role-sc…
SaulMoro Sep 23, 2026
736710c
Merge origin/main into fix/manifest-namespace-segments
SaulMoro Sep 23, 2026
f3f8dcd
Merge remote-tracking branch 'origin/main' into fix/manifest-namespac…
SaulMoro Sep 23, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. See [standa

## [Unreleased]

### 💥 Breaking Changes

- `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills/<namespace>/`, `agents/<namespace>/`, `learnings/<namespace>/`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together.

### ✨ Features

- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, removing only files whose content a release shipped (an edited file, or a member's own skill under an old name, stays), archiving each removed file under `~/.teamai/removed-skills/<run>/…` first, and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are still written in Simplified Chinese, and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)).
Expand All @@ -24,6 +28,7 @@ All notable changes to this project will be documented in this file. See [standa
### 🐛 Bug Fixes

- `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)).
- A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role <ns>`, since the scan needs it to tell which namespaces are the member's.
- `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735).
- Cache GC now rejects partial integers such as `12abc`, decimals, zero and unsafe integers for `--max-bytes` and `--stale-days` before deleting anything. An invalid `TEAMAI_CACHE_MAX_BYTES` value falls back to the default 5 GB limit instead of using a numeric prefix.
- Usage reporting scopes sessions by path on Windows too. The project/user scope filter compared an event's `cwd` against `projectRoot` with a hard-coded `/` separator, so on Windows only a session started in the project root itself matched: every session started in a subdirectory was dropped from the project team's report and counted in the user scope's instead, which is the isolation the usage guide promises. Windows paths are also compared case-insensitively, so a drive letter or a directory name spelled with different case in the two sources no longer leaks a project session into the user scope. POSIX paths keep their own rules: case-sensitive, and a `\` in a filename stays part of the name.
Expand Down
21 changes: 21 additions & 0 deletions docs/designs/multi-project-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ projects:
agents: [hai-inference] # optional; agents/<namespace>/ scoped to this project
```

The id and every namespace are refused at the manifest boundary unless they can
name a directory without escaping it, since each becomes a directory component. A
namespace must be a single path segment: no `/`, `\`, `:` or control character,
no trailing `.` or space, and not a Windows device name (`CON`, `NUL`, `COM1`, …).
Two namespaces of one resource type may not differ only by case, within a manifest
or between `roles.yaml` and `projects.yaml`, since case-insensitive filesystems
would give both the same directory.
Win32 strips a trailing period or space from every component, so `.. ` would
arrive as `..` and `frontend.` as `frontend`, escaping the parent in the first
case and another namespace's directory in the second; `.` and `..` fall out of the
same rule. A manifest file that exists but cannot be read, or is empty, is an
error rather than an absent manifest: treating it as absent would drop the
filtering the manifest exists to apply. Absence means the path is genuinely not
there — a dangling symlink, on the file or on `manifest/` itself, reads as ENOENT
but is an error. The id keeps the
older, narrower rule it has always had — letters, digits, `.`, `_`, `-`, and not
`.` or `..` — because it is also typed on the command line and split on commas.
The namespace guard applies to `manifest/roles.yaml`'s active namespaces
(`knowledge`, `skills`, `agents`); its `learnings:` is kept for backward
compatibility, ignored at runtime, and therefore unchecked.

Agent push uses the same role/project namespace resolution as pull and skips ambiguous source destinations. Placement follows it: a new agent pushed with `--role`/`--project` lands under `agents/<namespace>/` (the project's `agents` axis), the same way a new rule resolves from `knowledge` and a new skill from `skills` (issue #649). On a role or project change, agent cleanup checks each tool destination independently, including YAML `targets` and legacy format support. Locally edited copies are preserved.

Directory layout reuses the existing namespace convention, adding one learnings layer:
Expand Down
39 changes: 37 additions & 2 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ projects:
agents: [hai-inference] # optional
```

The project id and every namespace under `resources:` become a directory name
(`skills/<namespace>/`, `learnings/<namespace>/`, `agents/<namespace>/`), so
neither may escape the directory it names.

A **namespace** must be a single path segment: no `/`, `\`, `:` or control
character, no trailing `.` or space, and not a Windows device name (`CON`, `NUL`,
`AUX`, `PRN`, `CONIN$`, `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9`, including the
superscript forms Windows also reads as device numbers, with or without an extension).
Windows strips a trailing period or space from every path component, so `.. `
would arrive as `..` and escape the parent while `frontend.` would arrive as
`frontend` and land in another namespace's directory; the same rule rules out `.`
and `..`. Anything else a filesystem accepts stays valid — a non-ASCII name, one
holding a space inside it, or one that merely starts like a device (`console`).
Two namespaces of the same resource type may not differ only by case (`frontend`
and `Frontend`, or under Unicode case folding `σ` and `ς`): on the default Windows and macOS filesystems they are one
directory, so a role or project scoped to one would read the other's resources.
The check spans both manifests, since `roles.yaml` and `projects.yaml` share the
same `skills/`, `knowledge/` and `agents/` directories.

A **project id** keeps its own older and narrower rule, because it is also typed
on the command line and split on commas: letters, digits, `.`, `_` and `-`, and
not `.` or `..`.

A manifest that breaks either rule fails to parse, and the error names the
offending entry.

**Commands** (low-frequency correction/query, mirroring `teamai roles …`):

```bash
Expand Down Expand Up @@ -622,7 +648,7 @@ Choose namespace [1-3] (default: 1 = common):
- A single namespace is auto-selected; use `--role <id>` to choose one explicitly
- Modifying an existing resource automatically keeps its original namespace
- The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md`
- A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set <role>`, or pass `--role <ns>`. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior
- A roles manifest that exists but cannot answer stops the push instead of falling back to the shared root. One that is missing the configured role: fix `manifest/roles.yaml`, run `teamai roles set <role>`, or pass `--role <ns>`. One that cannot be read or parsed, or is empty, stops the push at its scan (exit 2), before `--role` is consulted, because the scan needs the manifest to tell which namespaces are yours: fix `manifest/roles.yaml` first. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior
- `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses
- When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role <ns>`
- `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so
Expand Down Expand Up @@ -854,7 +880,7 @@ servers:

`projects` lists project ids from `manifest/projects.yaml` and follows the same rule on the other axis: a server ships to a directory when one of the projects it is bound to (`teamai projects set`) is listed; `projects: []` ships to nobody; a directory bound to no project receives every server. `teamai projects set` to another project removes the ones that no longer match on the next pull. An id that is not in `projects.yaml` produces one warning per pull, and so does a `projects:` key in a team that has no `projects.yaml` at all, where no id can be checked.

One caveat on the empty list, which applies to `roles: []` just as it always has. "Ships to nobody" holds among members who use that axis. A member who has not configured it at all is unfiltered and still receives the entry, because an unconfigured axis filters nothing. Use `tools: []` or remove the entry if you need it to reach no one at all.
One caveat on the empty list, which applies to `roles: []` just as it always has. "Ships to nobody" holds among members who use that axis. A member who has not configured it at all is unfiltered and still receives the entry, because an unconfigured axis filters nothing. A legacy role that could not be resolved because `manifest/roles.yaml` does not load is not "unconfigured": that member receives no role-scoped entry until the manifest is fixed. Use `tools: []` or remove the entry if you need it to reach no one at all.

A missing `projects.yaml` does not switch the key off. A directory's active projects come from its own `config.yaml`, so a directory bound to `billing` still filters out a `projects: [checkout]` server whether or not the manifest is there. What the manifest gives you is the ability to check the ids.

Expand Down Expand Up @@ -1526,6 +1552,15 @@ roles:
agents: [common, frontend] # optional; omitted = root-level agents only
```

Every namespace that takes effect — `knowledge`, `skills` and `agents` — becomes a
directory name, so it must be a single path segment: no `/`, `\`, `:` or control
character, no trailing `.` or space, and not a Windows device name, and no two
namespaces of one resource type may differ only by case — in `manifest/roles.yaml`
exactly as in `manifest/projects.yaml`, and across the two. A role's
`learnings:` is accepted for backward compatibility and ignored at runtime
(learnings are namespaced by project, not by role), so it names no directory and
is not checked.

`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `<name>.toml` for Codex tools, `<name>.json` for Kiro, `<name>.agent.md` for Copilot, and `<name>.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role <ns>` or `--project <id>` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents <name>` records a tombstone. A namespaced agent can be named as `<namespace>/<name>`; a bare name that only one namespace has resolves to it, and a bare name found in several places is refused, with the qualified names listed, rather than removed from all of them. The next pull on every other machine deletes `<name>.agent.md`, `<name>.md`, `<name>.toml` and `<name>.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `<namespace>/<name>` only, so the same name in another namespace is untouched; a member's flattened `<name>` copy is cleaned, and not pushed again, when it can be that agent's copy (the namespace is active for them, or their machine placed the agent) and their directory does not still receive an agent of that name from another active namespace. A member who never had that namespace keeps their own agent of the same name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`.

### GitHub Copilot CLI
Expand Down
Loading
Loading