* fix(models): key team values by repo identity, migrating legacy slug names (#894) * fix(models): address review on the repo-identity values scheme (#894) - never migrate the legacy values file in a dry run: `pull --dry-run` and `models switch --dry-run` thread { dryRun } into migrateTeamValuesPath - match the legacy digest the old implementation actually keyed on — the repo: claim in teamai.yaml overrode the identity there, so an SSH remote beside an HTTPS repo: no longer orphans keys and switches - when several legacy files share a digest, migrate the newest one, so keys re-entered after a team rename win over stale copies - sync docs/designs/model-profiles.zh-CN.md with the hash-only file name 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): preview migration in dry runs, stop alias collisions, race-safe link - dry run returns the newest legacy file's path so it reads exactly the keys the real run would migrate and read, instead of reporting a key missing from a not-yet-existing hash-only file - a bare remote alias (`fork`) no longer names the values file: two checkouts sharing the alias hashed to one file and could read each other's keys, so hashing falls back to the URL, then the local path - migration links the legacy file to the target instead of renaming: a concurrent migration that links first makes ours fail with EEXIST, so stale keys can never overwrite fresh ones on POSIX 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): narrow the path digest to path-only configs, copy when links fail - the legacy local-path digest is a candidate only when the old implementation would have keyed on the path itself (no repo: claim, no configured remote, no URL): a default-path checkout re-initialized for another team with a URL no longer adopts the previous team keys - when a filesystem rejects hard links (EPERM, ENOTSUP, ...), migration falls back to an exclusive, never-overwriting copy (open wx mode, partial writes removed) instead of skipping every candidate and returning a path with no file behind it 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): publish the copy fallback atomically via a temp file The copy fallback wrote the target in place after an exclusive open, so a concurrent reader could observe the empty or partially written values file and fail to parse it. The content is now written to a unique temp file and linked into place: until the publish link succeeds the target does not exist, so a concurrent reader sees either the complete previous file or none, and two migrations still cannot overwrite each other (the loser publish link fails and its temp file is removed). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * refactor(models): read legacy values in place instead of migrating files Rename-and-migrate had to be atomic, no-clobber, link-optional, and partial-write-free all at once, and each fix round surfaced another way the migration could strand or clobber a secrets file. Drop the machine: while the hash-only file does not exist, findTeamValuesPath returns the newest legacy file and it is read where it lies; the next save writes the hash-only name, which then shadows the legacy file. Reads no longer mutate anything — a dry run needs no special casing — and no link, rename, or copy stands between the keys and their reader. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): identify alias-digest legacy files by slug as well Two checkouts that shared a bare non-origin alias (`fork`) hashed the same legacy digest, so findTeamValuesPath could read one team's values for the other. A repository-bound digest (URL, repo: claim, local path) still matches by digest alone, since the slug drifts on team renames; a file keyed by an alias digest is adopted only when its slug is this checkout's too, the discriminator the old scheme kept those files apart by. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): align switch-record matching with file matching, mirror old precedence - sameTeamIdentity applied the alias slug check only when reading a values file, not when matching a switch record: two teams sharing remote fork could treat each other managed switches as their own. The slug requirement now covers every non-repository-bound digest. - legacyTeamValueHashes mirrored the old precedence too loosely: with a repo claim present, the old implementation hashed only the claim, so the remote, URL, and path candidates never applied. A replaced checkout at the same path can no longer adopt a path-digest record the old code would never have produced for it. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): prefer the repo: claim over the local path for the values file name A config with remote origin and no repo.url hashed the checkout path, so reusing that path for another team shared the first team's hash-only values file and its identity: team B read team A's API keys and claimed its managed switches. The claim is the remaining repository identity before the path, so it now wins whenever the remote and the URL name no repository. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): qualify provider-relative identities with the provider A path-shaped identity like owner/repo names a different repository on each provider, so two teams with the same repo value on GitHub and GitCode hashed to one values file and one identity: opening team B read team A keys and accepted its managed-switch identity. The provider now qualifies path-shaped identities; host-bearing ones (URLs) already carry the host and stay as they are. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): use the effective provider and slug-check legacy claim digests - the values file identity qualified a path-shaped source with the local provider override, normally absent, so two teams declaring the same repo: value on GitHub and GitCode still hashed to one file; the teamai.yaml provider now wins for its own claim, and the local override applies only when the claim carries none - legacy candidates marked a path-shaped claim digest repository-bound, so sameTeamIdentity adopted another team's switches and findTeamValuesPath read its file under the matching digest; a URL-shaped claim stays repository-bound while a path-shaped one is matched under this team's slug, like an alias - also fixes the candidate guard inverted while landing the above: with a claim present the remote, URL, and path candidates were dropped, and without one they were duplicated 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): read legacy claim files by digest and slug-guard path-only records Path-shaped repo claims now adopt their legacy values files by digest alone, so a team rename no longer orphans its keys; the slug check moves to switch-record matching, where a claim digest names no single repository. Path-only configs mix the team slug into the hash-only identity, so a checkout path reused by a differently named team can no longer read the previous teams keys. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * docs(models): describe the path-only slug-fallback hash in both languages 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(models): slug-guard provider-ambiguous legacy files, repair renames by gateway origin A legacy file's digest hashes the bare path-shaped claim name with no provider, so GitHub team Alpha and GitCode team Beta can produce the same old digest. The previous fix read such files by digest across renames, letting the second provider adopt the first's file and API key; slug-guarding records instead orphaned a renamed team's own switches. Gate both on the one evidence that survives both a rename and a provider change: the gateway origin each stored key is bound to under `<team:<id>@<origin>`. Path-shaped claim digests now require this team's slug for files and switch records, and are re-admitted under a different slug only when the legacy file provably stores keys bound to this team's gateways — which a rename keeps and another provider cannot claim. Keys still in a beta-era `team:<id>` form (no origin) are not evidence, so their files are left unread rather than guessed at. Callers thread the resolved team profiles through `findTeamValuesPath`, `sameTeamIdentity`, `switchedGatewayOrigins`, `activeAgentsFor`, and the pull re-apply. * fix(models): prove rename-repaired legacy files from this machine's own switch history Gateway-origin key binding is not a team identity: two teams sharing a profile id and gateway URL behind the same path-shaped claim would pass the provenance check, letting the second read the first's file and claim its switches. But refusing differ-slug files entirely orphans a renamed team, whose legacy `<slug>-<hash>.json` carries its own old slug. The trustworthy, machine-local evidence is managed.json: this checkout records the exact identity each past `team:` switch used. A differ-slug provider-ambiguous file or switch record is re-admitted only when that identity appears in this checkout's own switch history — a renamed team's file, recognized without trusting a gateway a foreign team could share, a key binding another team's file may also carry, or a slug a rename just moved. Foreign teams hold only their own slug in managed.json and are refused; beta-era unbound keys need no binding to be recognized this way. Provider-relative remotes (`owner/repo`) also get the provider qualification already given to path-shaped claims, so two providers using the same checkout path and slug no longer share one values file. Design docs synced in both languages. * fix(models): never auto-adopt provider-ambiguous legacy values — adopt explicitly Two review rounds proved no silent rule can attribute a legacy file or switch record whose digest never encoded the provider: - a same-slug provider-ambiguous file is read across providers (a GitHub and a GitCode 'Alpha' on the claim 'acme/widgets' hash the same file; the slug is not provenance), and - machine switch history (managed.json) is global and self-validating: one file is shared by every checkout on the machine, so the record being proved sits in the same store a foreign team's checkout reads, letting a differently named team claim a same-digest record or file. Provider-ambiguous legacy values files (path-shaped claim, provider-relative remote, bare alias, path-only) are now never read by a silent rule, same slug or not. The CLI lists them once (unadoptedLegacyFiles) and the user explicitly adopts the exact `<slug>-<digest>` identity for this checkout; the read then happens where the file lies and the next save migrates the keys to the provider-qualified hash-only name, which shadows the legacy file forever (no re-ask, no ambiguity left). Non-interactive and --dry-run runs never adopt: they report the file and leave it unread. Differ-slug provider-ambiguous switch records are refused — no machine-global proof is ever consulted again. URL-bound legacy identities still match by digest alone: the host is in the identity. Design docs synced in both languages. Tests: same-slug cross-provider never auto-reads, foreign file/record refusal, explicit-adoption read + unbound-key migration, alias digest under this team's adoption. * fix(models): do not offer a legacy file for adoption once its target exists A past adoption and migration is the permanent record: the provider-qualified hash-only target shadows every legacy name forever, so unadoptedLegacyFiles returns no candidates when getTeamValuesPath exists. Without this, the in-memory adoption set (which resets per invocation) combined with the intentionally-kept legacy file re-prompted on every later interactive command and misled non-interactive/--dry-run runs with a stale warning. Test: after migration the file is shadowed and never offered again. * fix(models): scope legacy adoption to the provider-qualified team and config Two review findings on the adoption layer: 1. adoptedLegacyValues was global, keyed only by the legacy basename. In a pull with inheritUserScope, user scope runs first; a same-slug, same-claim team on a DIFFERENT provider resolves to the same adoption key, so confirming the user-scope file also silently authorized the project scope to read and migrate that key in its own provider space. Adoption is now keyed `${target}::<slug>-<digest>` — the adopting scope's own provider-qualified file path — so a confirmation scopes to exactly that team identity, and any other scope/provider must opt in separately. 2. adoptMigrated was a process-global boolean ("any adoption happened"). After adopting a user-scope file, declining a project-scope candidate still set it true, so the project loaded {} from its nonexistent target and wrote an empty hash-only file, permanently shadowing its legacy keys and killing future prompts. The write is now decided per config: only when readFrom differs from the target (a real legacy read) is the migration saved; a declined candidate is read nothing, written nothing, and re-offered next run. Tests: cross-provider scope regression (GitHub adoption under its target never admits the file for the GitCode scope; same-scope adoption of the same basename does), namespaced adoption keys everywhere, migration-shadow check. * fix(models): refuse shadowing writes while adoption pending; never claim ambiguous records by slug Two review findings: 1. A non-interactive `models configure team:<id> --from-env X` reads {} past an unadopted legacy file, then writes ONLY the configured key to the hash-only target — permanently shadowing the legacy file (the target's existence silences every future adoption prompt) and orphaning its other API keys without a warning anyone can act on. configure and switch now refuse the write while unadopted legacy files remain, naming the interactive adoption path. A pull is unaffected: its read-time save is the migration itself and already requires a real legacy read. 2. A same-slug provider-ambiguous switch record matched Team B's checkout. The old name never encoded the provider, so a GitHub and a GitCode team both named `Alpha` on the bare claim `acme/widgets` share the identical `alpha-<digest>` form: with Team B holding its own hash-only key, pulling B claimed an agent switched to Team A and overwrote it. A legacy record under a provider-ambiguous digest never matches now — slug equality is not ownership across providers, consistent with the differ-slug refusal. Repository-bound records still match by digest (the host is in the identity). Adoption of the values file re-establishes the team; its switched agents are re-recorded by the next `models switch`. Docs synced (en + zh-CN). Tests: path-only/alias/claim records all refused even under the exact slug, cross-provider same-slug record regression, and the 4 prior adoption-scoping expectations. * fix(models): a provider-relative remote is the top identity; never a Windows drive as a URL Two review findings on identity derivation: 1. A provider-relative remote never got its documented precedence. isRepoReference('owner/repo') is false, so the selector picked the URL or teamai.yaml claim instead; two checkouts with DIFFERENT remotes (acme/team-a vs acme/team-b) but the same claim/provider therefore shared one hash-only secrets file and could consume each other's keys. A configured non-origin remote now names the repository with the highest precedence whether URL-shaped or provider-relative (which names one repo together with its provider); only a bare alias (no slash, no scheme) falls through. Different remotes -> different files, always. 2. new URL('C:\\teams\\repo') accepts c: as a scheme, so Windows drive paths were hashed as phantom URLs and a path-only config skipped the provider-and-team-slug fallback; replacing that checkout with a differently named team reused the file. URL parsing now rejects single-letter schemes, keeping drive paths in the path form whose slug separates teams at one checkout. Tests: two different provider-relative remotes with a shared claim collide no more; same remote+provider names one repo across paths; bare aliases and drive paths never hash as URLs. Full suite 5285 passed. * fix(models): use the teamai.yaml provider for provider-relative remotes; bare aliases never key the file Two more identity findings: 1. A provider-relative remote ignored the provider declared in teamai.yaml when no local override existed, so GitHub and GitCode teams sharing `remote: owner/repo` both hashed `tgit:owner/repo`. The effective provider now consults the teamai.yaml `provider:` (via the claim or, when the claim is absent, directly) before the local override and the tgit default — the same qualification the path-shaped claim already applied. Provider survives a team rename, so the file stays bound to the repository. 2. A bare remote alias (`remote: fork`, no URL, no claim) was still hashed directly as `tgit:fork`, so two checkouts sharing the alias shared one hash-only secrets file. A bare alias names no repository, so it now falls through to the provider/slug/path identity, whose slug keeps teams sharing a checkout path apart. Legacy `<slug>-<digest>` files for alias-keyed checkouts remain migration candidates. Tests: each checkout declares its own teamai.yaml provider and stays distinct; a bare alias no longer changes the file over the path-only form and two teams sharing it stay separated by the slug. Full suite 5285 passed. * fix(models): partial adoption never orphans a declined legacy file; adoption merges all adopted keys Two defects in the read-time migration: 1. Declining one of several matching provider-ambiguous legacy files then saving the hash-only target stranded the declined file forever: the very existence of the target silences every future adoption candidate, so keys unique to the declined file became unreachable with no further prompt possible. The migration save now happens only when NO candidate remains for this team (adopted or gone); a declined file stays a candidate, keeps its keys reachable, and is re-offered on the next run. 2. A single-file read kept only the newest adopted identity's keys. When several files shared this checkout's digest, adopting all of them still dropped every non-newest identity's unique keys before the target shadowed them. Adoption now merges the keys of every adopted identity into the migrated target (new mergeModelInputs; the later map wins). Tests: with two matching files, adopting one still surfaces the other to the migration guard; adopting both leaves no candidates and the merge keeps both identities' keys. Full suite 5286 passed. * fix(models): member provider overrides the team's; declines are durable and no longer block migration Two review findings, one shared root: the migration's consent model could not distinguish "not yet decided" from "deliberately declined". 1. The effective provider put teamai.yaml's provider before the member's own, so a team declaring GitHub with the same bare claim made a member initialized with --provider gitlab share the GitHub team's hash-only file. The member's own provider now wins (localConfig.provider ?? team provider ?? tgit), as everywhere else in the CLI. 2. Requiring every same-digest file to be adopted blocked migration behind a foreign team's file: declining GitHub Alpha to adopt GitCode Beta left an unadopted candidate forever, so no target could ever be created. Declining now records a durable, target-scoped ${target}::<slug>-<digest> refusal in the models manifest. A declined identity is neither re-offered on every run (no re-prompt loop) nor silently shadowed when migration proceeds (the choice is explicit, visible state, reversible by removing the key or the file) and does not count toward the migration guard — adopting only your own team's file no longer forces you to merge a foreign team's keys. unadoptedLegacyFiles and findTeamValuesPath now take { adopted, declined }; findTeamValuesPath reads an adopted identity and skips a declined one. Tests: found the regression itself (adopt one of two, decline the other leaves no candidate and reads the adopted file only), the provider-override case, and a refuseLegacyValue/refusedLegacyValueKeys manifest round-trip with a fail-closed schema check. Full suite 5288 passed. * fix(models): serialize the migration target against a concurrent writer by merging before save The migration write into the new hash-only target is the one place a values file is created from a different file, so it must not clobber a target a concurrent process wrote between the legacy read and the save: writeJsonAtomic prevents torn files, not lost updates. A pull reading the legacy file while a configure creates the target could otherwise overwrite the newly configured key with its stale snapshot. The migration now re-reads the target and merges before saving (the concurrent content wins collisions). The merge runs only in the migration branch (readFrom !== target). When the run merely re-saves the file it already read, the target holds the same content this bind just consumed — merging would re-inject the raw legacy entries the bind renamed, so there is nothing to guard against there. Full suite 5288 passed. * fix(models): hold the team values lock across the whole read-modify-write Re-reading the migration target and saving it were still two separate operations, so the race the review flagged only shrank: a pull could re-read the target, a concurrent configure could save a new key, and the pull could then overwrite it with its stale merged snapshot. Team values writers now share an advisory lock on the target file, and each of them runs its entire read-modify-write cycle inside it: - the migration's was already read-merge-save; it now runs that whole cycle under the lock instead of only the merge step; - models configure and the models switch first-use prompt re-read the target inside the lock, so their write cannot clobber a migration or a sibling configure that landed in the same window. withTeamValuesLock reuses the existing owner-verified acquireLock/releaseLock primitive (dead-owner stale locks are reclaimed), so a crashed holder does not block pulls or configures. Local values writes are untouched; no legacy migration can race them. Full suite 5288 passed. * fix(models): resolve the profile from the key saved by the first interactive switch The lock round-trip saved the first-use key into the freshly re-read snapshot (current), but the rest of the command resolved the profile against the original in-memory values, which had no key yet — so the switch stored the key and then failed with "no API key", demanding a rerun. Apply the same key to the in-memory copy after the locked write, and add a regression test for the first interactive team switch (ask, save, and resolve in one run) so the behaviour cannot silently split again. Full suite 5289 passed. * fix(models): save the awaited key with the first interactive local switch Moving the getStoredApiKey update after the branch left the local path saving values.json without the key the prompt returned: the switch succeeded from the later in-memory copy, but the next invocation found values.json missing the key and reported it not configured. Put the key in the in-memory copy before the branch, so the local save already carries it; regression tests now cover the first interactive switch for both the personal and the team profile. Full suite 5290 passed. --------- Co-authored-by: Codebuff <noreply@codebuff.com>
TeamAI — Make Every Team AI Native
English | 中文 | 日本語 | 한국어 | ไทย
The shared foundation for how your team works, learns, and improves with AI.
TeamAI turns individual AI capabilities into shared team capabilities — across agents, machines, and team members.
Why TeamAI
Quick Start
Send this one line to your AI tool:
Install the teamai skill: https://github.com/Tencent/teamai-cli/tree/main/skills/teamai , load the teamai skill, then set up TeamAI for my team from scratch.
Once TeamAI is set up, just talk to the /teamai skill in your AI tool:
Set up a team from scratch
/teamai Help me set up TeamAI for my team from scratch
Join a team
/teamai Help me join my team's TeamAI, repo URL is https://github.com/your-org/your-repo
Share with the team
Skills, rules, MCP servers, and other agent resources can all be shared:
/teamai Share my xxx skill with the team
Open the dashboard
/teamai Open the TeamAI dashboard
Once a teammate is set up, they just open their agent and already have the team's full set of AI assets.
Command-line install
Install
npm install -g teamai-cli
Team admin / solo user
Create a shared-experience repo on your git host (GitHub, GitLab, GitCode, CNB, TGit, or a private Git service), grant write access to team members, then run teamai init https://github.com/your-org/your-repo.
No team repo yet? Start from a template pre-loaded with production-ready skills, rules, and review agents. Browse the teamai-hub org, click Fork, then
teamai initagainst your new repo.
Team members
# Choose one, depending on where you want resources installed
# Project-scope init (default, resources installed under the project directory)
cd /path/to/my-project
teamai init https://github.com/your-org/your-repo
# Or, user-scope init (resources installed under ~/)
teamai init https://github.com/your-org/your-repo --scope user
Once initialized, every AI session automatically pulls the latest skills / rules and other Harness updates published by admins — no manual sync needed.
Product Overview
Three layers of capability, built on Git:
- Team Execution — make every agent work the team's way: skills, rules, docs, env, agents, hooks, MCP, models.
- Team Context (beta) — make every agent understand the team: learnings, codebase graph, teamwiki.
- Team Improvement (beta) — make every execution improve the team: usage, sessions, dashboard.
| Agent | Team Execution | Team Context (beta) | Team Improvement (beta) | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| skills | rules | docs | env | agents | hooks | mcp | models | learnings | codebase | teamwiki | usage | sessions | dashboard | |
| Claude Code | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Codex | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Cursor | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| GitHub Copilot CLI | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| CodeBuddy | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| WorkBuddy | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| OpenCode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | — | — |
| Pi Coding Agent | ✓ | ✓ | ✓ | — | — | ✓ | — | — | — | — | — | — | — | — |
| OpenClaw | ✓ | ✓ | ✓ | ✓ | — | — | — | — | ✓ | ✓ | ✓ | — | — | — |
| Hermes | ✓ | — | ✓ | ✓ | — | — | — | — | ✓ | ✓ | ✓ | — | — | — |
| DeepSeek Harness | ✓ | — | ✓ | — | — | — | — | — | ✓ | ✓ | ✓ | — | — | — |
| Qoder | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Qoder CN | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Kiro | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ZCode | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Oh My Pi | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | — | — | — |
Learn More
- Usage Guide (中文版) — setup, onboarding, daily workflows, and commands
- Product Overview (中文版) — architecture, distribution controls, and capability details
- Git Providers — supported repository providers
- Windows Setup (中文版) — hooks and shell configuration
- Technical Designs — design documents and proposals
Contributors
Thanks to everyone who has contributed to TeamAI!
Made with contrib.rocks.
Contributing
Join the conversation, or open an issue or PR. See CONTRIBUTING.md for how to contribute.
