Changsu SeongandCodebuff 92e6e2f060 fix(models): key team values by repo identity, migrating legacy slug names (#894) (#895)
* 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>
2026-09-29 21:16:59 +08:00
…

teamai-cli

TeamAI — Make Every Team AI Native

Tencent%2Fteamai-cli | Trendshift

English | 中文 | 日本語 | 한국어 | ไทย

CI npm version npm downloads License: MIT

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

Eight everyday scenarios, before and after 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 init against 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)
skillsrulesdocsenvagentshooksmcpmodels learningscodebaseteamwiki usagesessionsdashboard
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

Contributors

Thanks to everyone who has contributed to TeamAI!

Contributors

Made with contrib.rocks.

Contributing

Join the conversation, or open an issue or PR. See CONTRIBUTING.md for how to contribute.

License

MIT

S
Description
GitHub Trending: Tencent/teamai-cli
Readme MIT
25 MiB
Languages
TypeScript 99.7%
Python 0.2%
JavaScript 0.1%