Skip to content

fix(env): detect the right shell profile file on Windows (#682) - #693

Merged
jeff-r2026 merged 9 commits into
Tencent:mainfrom
STiFLeR7:fix/windows-shell-profile-detection
Sep 21, 2026
Merged

jeff-r2026 merged 9 commits into
Tencent:mainfrom
STiFLeR7:fix/windows-shell-profile-detection

Conversation

@STiFLeR7

Copy link
Copy Markdown
Contributor

Problem

detectShellProfile() reads SHELL to decide between .zshrc and .bashrc. SHELL is never set on Windows, so it always falls back to ~/.bashrc — but Git Bash starts as a login shell, which reads ~/.bash_profile, ~/.bash_login or ~/.profile, never ~/.bashrc.

teamai pull reports success, the block is written and looks correct on inspection, but no shell ever sources it. Nothing catches it: doctor only checks that the block is present in the profile file, not whether that file is one the shell actually reads.

Filed as #682.

Fix

  • New src/utils/shell-profile.ts with the platform-aware logic:
    • win32: prefer an existing ~/.bash_profile, then ~/.bash_login, then ~/.profile, in that order; fall back to ~/.bashrc only when none exist — matching Git for Windows' own fallback in /etc/profile.d/bash_profile.sh, so both agree on what actually gets sourced.
    • POSIX: unchanged SHELL-based behavior.
  • platform is an injectable parameter (default process.platform), same convention as resolveCliPath in utils/cli-path.ts — CI only runs ubuntu/macos, so a hardcoded process.platform would leave the win32 branch permanently untested, which is how this went unnoticed in the first place.
  • EnvHandler.detectShellProfile (resources/env.ts) and the standalone copy in uninstall.ts both now delegate to this single implementation, instead of carrying two independent copies that could silently drift and leave pull and uninstall disagreeing on which file to touch.
  • sharing.env.shellProfilePath (the team-config override) is untouched — it already short-circuits before detectShellProfile() is called.

Test Plan

Unit tests (npx vitest run) — all passing:

Real CLI, end-to-end (npm run build, then drove the built dist/index.js against a real local git team repo with HOME/USERPROFILE pointed at sandboxed fixtures — Windows-native paths, not Git Bash /tmp/... paths, which native Windows Node does not resolve):

  1. Exact repro from detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682 (~/.bashrc present, ~/.bash_profile absent, ~/.profile present): teamai pull now injects into .profile, leaves .bashrc untouched.
  2. No login-shell file exists: teamai pull creates and injects into .bashrc (matches Git for Windows' own fallback).
  3. Both .bash_profile and .bashrc exist: teamai pull prefers .bash_profile, leaves .bashrc untouched.
  4. teamai doctor reports "Env variables injected in shell profile" as healthy against scenario 3.
  5. teamai uninstall --force against scenario 3 correctly finds and cleans the block from .bash_profile (not .bashrc) — confirming pull/doctor/uninstall now agree on the same file, per the concern raised in the issue about the previously-duplicated logic.

Note for reviewers

While running the full suite on Windows I found 30 pre-existing test files (70 tests, e.g. doctor-env-delivery.test.ts, search-index-multi.test.ts, source.test.ts) that fail on an actual Windows host due to hardcoded POSIX path separators/characters in test fixtures — confirmed present on unmodified main via git stash (identical failure count before/after this change). Unrelated to this fix and invisible on the ubuntu/macos-only CI; flagging in case it's useful, happy to open a separate issue if not already tracked.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

SHELL is never set on Windows, so detectShellProfile() always fell back
to ~/.bashrc — but Git Bash starts as a login shell, which reads
~/.bash_profile, ~/.bash_login or ~/.profile, never ~/.bashrc. The env
block was written correctly and looked correct on inspection, yet no
shell ever sourced it.

Give detectShellProfile a Windows branch that prefers an existing
login-shell file, in that order, and falls back to .bashrc only when
none exist — matching Git for Windows' own fallback in
/etc/profile.d/bash_profile.sh, so both agree on what gets sourced.

The logic previously lived twice (resources/env.ts and uninstall.ts);
both now delegate to a single utils/shell-profile.ts so pull, doctor
and uninstall can never resolve to different files.

platform is an injectable parameter (default process.platform), same
convention as resolveCliPath in utils/cli-path.ts, since CI only runs
ubuntu/macos and a hardcoded process.platform would leave the win32
branch permanently uncovered — which is how this went unnoticed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jeff-r2026 jeff-r2026 self-assigned this Sep 21, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/uninstall.ts:513 only checks the newly selected profile. In the reported upgrade scenario, an existing TeamAI block remains in .bashrc, while the next pull adds another block to .profile; uninstall then cleans only .profile and leaves the managed .bashrc block behind. Discovery/removal should also migrate or scan legacy candidate profiles.
  • [P1 blocking] src/utils/shell-profile.ts:33 ignores SHELL entirely on Windows. Native Node launched from MSYS/Cygwin with SHELL=/usr/bin/zsh previously selected .zshrc; this change redirects it to a Bash profile, so the environment is no longer loaded. Preserve explicit zsh detection before applying the Git Bash fallback.
  • The PR description includes a detailed test plan and real-CLI end-to-end verification record; no testing-description finding.

Two P1s from the automated review on Tencent#693:

- detectShellProfile ignored SHELL entirely on win32. A zsh installed via
  MSYS2/Cygwin sets SHELL just like it does on POSIX, while native Windows
  Node still reports platform === 'win32'; the Windows branch was taking
  over unconditionally and silently stopped loading .zshrc for that setup.
  SHELL is now checked before the platform branch, on every platform.

- uninstall only looked at the one file detectShellProfile() resolves to
  today. The Tencent#682 fix changed which file `pull` prefers, so a machine last
  pulled with an older CLI can carry a stale block in a file the current
  resolution no longer points at, and a plain uninstall silently left it
  behind. buildRemovalPlan now scans every profile file teamai could ever
  have written to (.zshrc/.bashrc/.bash_profile/.bash_login/.profile, plus
  a configured override) and cleans every one that still carries a block.

Verified both with a real CLI build against the exact scenarios described:
zsh-on-Windows via SHELL, and a stale .bashrc block surviving alongside a
freshly-written .profile block until `uninstall` now removes both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed both P1s in 7c1659b:

  • shell-profile.ts:33 (zsh on Windows): SHELL is now checked before the platform branch, on every platform, so a zsh installed via MSYS2/Cygwin (which sets SHELL just like on POSIX, even though native Windows Node still reports platform === 'win32') resolves to .zshrc again. The Windows bash-profile-priority branch only applies when SHELL doesn't indicate zsh.
  • uninstall.ts:513 (stale legacy block left behind): buildRemovalPlan now scans every profile file teamai could ever have written to (.zshrc, .bashrc, .bash_profile, .bash_login, .profile, plus a configured override) and cleans every one still carrying a TEAMAI_ENV_START block, instead of only the one detectShellProfile() resolves to today. This covers exactly the upgrade path described: a stale block in .bashrc from an older CLI survives alongside a freshly-written block in .profile, and uninstall now removes both.

Both verified with unit tests (new cases in shell-profile.test.ts and uninstall.test.ts) and a real CLI build reproducing each scenario end-to-end (zsh-on-Windows via SHELL, and the stale-.bashrc-plus-fresh-.profile migration case, confirming uninstall now cleans both files and preserves surrounding user content in each).

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/uninstall.ts:525 — Scanning every standard profile and deleting any TeamAI marker can remove another active scope’s env block. For example, uninstalling a project-scoped setup configured for .profile also removes a user-scoped block from .bashrc. Limit cleanup to blocks sourcing the current getDataHome(localConfig)/env.sh, rather than matching the global marker alone.
  • [P1 blocking] src/utils/shell-profile.ts:36 — This changes documented user-facing profile selection and uninstall behavior, but no documentation was updated. The repository rules require behavior changes to update all affected bilingual docs; at minimum the configuration/env and uninstall sections in docs/usage-guide.md and docs/usage-guide.zh-CN.md need synchronized updates.

The PR description includes unit/type-check results and a concrete real-CLI end-to-end record, so it does not lack a test plan or e2e notes.

…(review)

Two more findings from the automated review on Tencent#693:

- buildRemovalPlan scanned every candidate profile filename for the
  generic TEAMAI_ENV_START marker alone, so uninstalling one scope
  (e.g. a project) could delete a completely different scope's
  still-active block just because it happened to live in one of the
  same candidate files. A candidate now only counts when its block's
  source line actually points at THIS scope's own env.sh
  (getDataHome(localConfig)/env.sh), reusing the same block-parsing
  logic doctor already relies on for the equivalent check.

- extractEnvBlock/envBlockSourcesPath were private to doctor-delivery.ts;
  moved into utils/shell-profile.ts and imported by both doctor-delivery
  and uninstall so there is one implementation of "does this block
  belong to this data home", not two that could drift the same way
  detectShellProfile itself did in Tencent#682.

- Documented the Windows shell-profile selection order and the
  scoped-cleanup behavior in usage-guide.md and its zh-CN counterpart.

Verified with new unit tests (a stale-block-survives-in-another-scope
case, and the original same-scope migration case using the real
generated block format instead of a `~/env.sh` shorthand) and a real
CLI build reproducing an unrelated scope's block surviving an uninstall
that only touches its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed both new P1s in 4043f69:

  • uninstall.ts:525 (scope isolation): a candidate profile file now only counts if its block's source line actually points at this scope's own env.sh (getDataHome(localConfig)/env.sh), not merely whether it carries the generic TEAMAI_ENV_START marker. Reused the same block-parsing check doctor already relies on (moved extractEnvBlock/envBlockSourcesPath out of doctor-delivery.ts into the shared utils/shell-profile.ts so there's one implementation, not two that could drift like detectShellProfile itself did in detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682). Added a unit test + real-CLI verification: an unrelated scope's block in .bashrc now survives an uninstall that only touches its own .profile block.
  • shell-profile.ts:36 (docs): added the Windows shell-profile selection order to docs/usage-guide.md / usage-guide.zh-CN.md's Env section, and updated the Uninstall section's "what gets removed" bullet to describe the scoped multi-file cleanup.

All three rounds of fixes re-verified together: unit tests pass (npx vitest run, same pre-existing unrelated Windows-host failures as baseline — confirmed via git stash earlier in the thread), npx tsc --noEmit clean, and a fresh real-CLI build re-run through all scenarios (original #682 repro, no-file fallback, .bash_profile priority, zsh-on-Windows via SHELL, same-scope stale-block migration, and cross-scope isolation).

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] envBlockSourcesPath() searches for the raw path, but generateShellBlock() shell-escapes apostrophes as '\''. For a valid home/project path such as /home/O'Brien/.teamai/env.sh, doctor incorrectly reports the generated block as broken and uninstall fails to remove it. Compare against the same shell-quoted representation used by the generator and add an apostrophe-path regression test. src/utils/shell-profile.ts:85
  • The PR description includes sufficient unit, type-check, and real-CLI end-to-end verification records.

… (review)

envBlockSourcesPath compared the raw path against the block, but
generateShellBlock always wraps the path in single quotes via
shellQuoteValue, which escapes an embedded apostrophe as `'\''`. A path
like /home/O'Brien/.teamai/env.sh never appears as a contiguous raw
substring in the generated block, so doctor reported a correctly
loading block as broken, and uninstall could not find it to clean up.

Moved shellQuoteValue out of resources/env.ts into the shared
utils/shell-profile.ts (env.ts now imports it) so the check compares
against the exact string the generator would produce, instead of
re-deriving the same escaping rule a second time.

Added unit tests for envBlockSourcesPath covering the apostrophe case,
a plain path, a non-matching path, and the pre-existing Tencent#661 unquoted
Windows path case, using the real shellQuoteValue rather than
hand-written escaping to avoid re-encoding the same assumption twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed the P1 in a983ef0:

  • shell-profile.ts:85 (apostrophe-quoted paths): envBlockSourcesPath now first checks whether the block contains shellQuoteValue(posixPath) — the exact string generateShellBlock actually writes — before falling back to the looser raw/quoted checks. Moved shellQuoteValue out of resources/env.ts into the shared utils/shell-profile.ts (env.ts now imports it) so the comparison uses the generator's real escaping rule instead of re-deriving it a second time, which is the same class of drift risk this whole thread has been closing. Added unit tests covering the apostrophe case (/home/O'Brien/.teamai/env.sh), a plain path, a non-matching path, and the pre-existing [bug] Windows: env 注入到 shell profile 时写的是原生路径,source 静默失败而 doctor 仍报通过 #661 unquoted-Windows-path case — all built with the real shellQuoteValue, not hand-written escaping.

Note on verification: I tried to also reproduce this with a real CLI build against an actual O'Brien-style HOME directory, but hit a Git-Bash/MSYS path-quoting artifact in my own test harness (git itself failed to resolve the destination path), unrelated to this fix. Since the bug and fix are both pure string-matching logic, the unit tests exercise the exact reported scenario deterministically using the production shellQuoteValue/generateShellBlock functions, so I'm relying on those rather than fighting the harness further — happy to revisit if that's not sufficient.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Required compatibility test matrix is missing from the PR description. The repository requires real-CLI verification for Claude, Codex, CodeBuddy, and OpenCode across git, gitlab, and github. The test plan records only a local git repository and does not document the required agents or gitlab/github providers. Add the actual passing results before merge.

No additional code defects found in the reviewed diff.

@STiFLeR7

Copy link
Copy Markdown
Contributor Author

On the compatibility-matrix request: I don't think it applies to this diff, and I'd rather explain why than pad the test plan with entries that don't add signal.

The full changeset (git diff --stat origin/main...fix/windows-shell-profile-detection) touches only:

  • src/utils/shell-profile.ts (new)
  • src/uninstall.ts, src/doctor-delivery.ts, src/resources/env.ts
  • their tests, plus the two usage-guide docs

None of these reference src/providers/github, src/providers/tgit, GitLab, or any agent-specific hook code (hooks.ts, builtin-hooks.ts, or the per-tool injectors) — confirmed by grep, not just by inspection.

The reason is structural: the shell-profile env block is a single OS/shell-dependent mechanism (~/.zshrc / ~/.bashrc / ~/.bash_profile / etc.), injected identically regardless of which git host the team repo lives on and regardless of which AI tool is installed. git/gitlab/github only affect how the team repo itself is cloned/pushed — untouched here. Claude/Codex/CodeBuddy/OpenCode each have their own separate hook-injection code paths for their own config files — also untouched here; this PR doesn't add or change anything in that surface.

My existing real-CLI verification already exercises the one provider capability this PR's code path actually uses (git clone/pull against a local git remote), run generically — nothing in the fix branches on provider type. Running the same shell-profile logic again under CodeBuddy/OpenCode or against a GitLab/GitHub-hosted repo would just re-confirm code this PR does not modify.

Happy to add specific matrix entries if a maintainer sees an interaction I'm missing — but I'd want to know what that interaction is rather than filling in results for combinations this change can't affect.

@CarlosWonMore

Copy link
Copy Markdown
Contributor

Windows 11 real-machine validation of this PR (built from a983ef0)

Environment: Windows 11 25H2, Node 22.22.2, Git for Windows (/usr/bin/bash). We reported #661/#672, filed #686 and #682, and supplied the Windows E2E records on #680/#687 — this is our daily platform.

We downloaded the tree at a983ef0, ran npm ci --ignore-scripts (the opencode-ai postinstall cannot fetch a Windows binary here and it is test-only) plus npm run build, then drove the built dist/index.js against a scratch $HOME and a local team repo.

1. The fix works, end to end

Fixture: ~/.profile exists (it only sources ~/.local/bin/env), no ~/.bash_profile / ~/.bash_login, and ~/.bashrc carries a pre-#680 teamai block (raw backslashes, unquoted — the #661 form):

[ -f D:\...\.teamai\env.sh ] && source D:\...\.teamai\env.sh
step result
before pullbash -lc E2E_TOKEN = <unset>
teamai pull --force Synced 1 env variable(s) → block written to ~/.profile
after — bash -lc E2E_TOKEN = e2e-secret-value
teamai doctor ✔ Env variables injected in shell profile

detectShellProfile() resolves to .profile on Windows exactly as intended, and the block now reaches the login shell that teamai's own hook commands run under. Confirmed on hardware, not by reading.

2. Finding — envBlockSourcesPath() matches one spelling only, and that hides the legacy block

posixPath is envShPath.split(path.sep).join('/'), i.e. D:/…/.teamai/env.sh, and the function requires either shellQuoteValue(posixPath) or that substring. A block written by a pre-#680 CLI carries backslashes, so neither matches — even though it names the same env.sh of the same scope. Two observable consequences in the run above:

  • doctor stays green while a dead block remains. After pull --force wrote .profile, .bashrc still held the unusable backslash block and doctor reported no problem — it only inspects the file detectShellProfile() resolves to.
  • uninstall does not clean it. teamai uninstall --force printed ✔ Cleaned shell profile: …\.profile and left the .bashrc block exactly as it was. So the upgrade path named in the PR description — "a stale block in .bashrcuninstall now removes both" — holds only when the stale block happens to be in the drive form. For anyone upgrading from a pre-fix(env): make the shell-profile block load on Windows #680 build it does not, which is the common case now that fix(env): make the shell-profile block load on Windows #680 landed yesterday.

A milder variant of the same shape, seen on our own machine: the block there is functional but spelled in the MSYS drive form /c/… (written by a locally patched 0.24.0, not by a release). envBlockSourcesPath rejects it, and doctor prints "a POSIX shell reads an unquoted backslash as an escape" — a reason that is simply not true of a correctly quoted, working block.

Suggestion: make the comparison spelling-insensitive — normalize both sides first (drive letter ⇄ /c/, \/, quoted or not) instead of looking for one exact string. SHELL_PROFILE_CANDIDATE_NAMES already gives you the file set; the same normalization applied to the source target would let both doctor and uninstall see the legacy block.

3. Finding — the injection side has no counterpart to the new multi-file cleanup

buildRemovalPlan now scans every candidate profile, but injectShellProfile() still writes only the single file detectShellProfile() resolves to:

private async injectShellProfile(profilePath: string, block: string) {  }

On this fixture pull --force therefore produced two teamai blocks — live in .profile, dead in .bashrc — and nothing migrates or removes the old one. Combined with (2) that is permanent: doctor does not look there, and uninstall does not recognise it. Even where the legacy block is recognised (drive form), the state is ambiguous, because that block's source may point at another scope's env.sh and inject wrong values into whatever shell reads that file. Since the scope check you added already makes the match precise, migrating (write the resolved profile, drop this scope's block from the legacy file) — or at minimum having doctor name the command that performs the migration — would close it.

4. Two smaller notes

  • ~/.profile existing is the reason Git for Windows' /etc/profile.d/bash_profile.sh does not synthesize the ~/.bash_profile that would have sourced .bashrc; its guard is [ -e ~/.bashrc ] && ! -e ~/.bash_profile && ! -e ~/.bash_login && ! -e ~/.profile. Worth a sentence in the PR body, because it makes the bug look environment-specific when it is not. A fresh-$HOME matrix on this box confirms the order you implement: .bashrc only → Git for Windows creates a sourcing .bash_profile → loads; .bashrc + .profile.profile is read and .bashrc never is; .bashrc + .bash_login.bash_login.
  • Unrelated to this change, but seen in the same build: teamai pull prints Skipping hook injection for codebuddy, workbuddy: no shell is available in this environment. That is the /bin/sh gate (windows codebuddy跳过hook #579/fix(hooks): run CodeBuddy's Windows hooks through cmd.exe #637), untouched here.

5. On the compatibility-matrix finding, and on #684

We agree with your reply at 06:30. Nothing in this diff branches on the git provider or on per-tool hook code; re-running the same profile logic under CodeBuddy/OpenCode or against a GitLab-hosted repo would only re-confirm code this PR does not modify. We would rather see the two findings above addressed than the matrix padded.

On #684: it does not touch src/uninstall.ts at all, so the bot's P1 there stands, and it has neither the shared helper nor the injectable platform that makes the win32 branch genuinely assertable on the Linux/macOS runners. We would suggest landing this PR and either closing #684 or reducing it to the win32 test cases it can still contribute.

We can re-run the transcript above against any later commit and post it verbatim if that is useful.

…(review)

Two real gaps found by @CarlosWonMore's hardware validation on real
Windows 11 machines, both stemming from envBlockSourcesPath only
recognizing the current writing format:

- A pre-Tencent#661 block (raw, unquoted, unconverted backslashes) or a
  locally-patched build's MSYS/Cygwin drive-form block (/d/Users/...)
  names the same scope's env.sh but never matched, so uninstall could
  not find it and doctor never flagged it — a dead block could survive
  every pull and every uninstall indefinitely.

Added envBlockReferencesDataHome: a looser "does this block belong to
this scope" check (current + legacy spellings, quoted or not) that
answers ownership, kept separate from the existing envBlockSourcesPath
which answers "does this block actually load" and must stay strict —
conflating them would make doctor stop reporting a real Tencent#661-style
break just because a legacy spelling happens to match.

- uninstall.ts's candidate scan now uses envBlockReferencesDataHome, so
  a legacy block for this scope is found and removed regardless of
  which format wrote it.
- doctor's env-delivery check now also scans the other candidate files
  for a stray block belonging to this scope and reports it, naming
  `teamai uninstall` as the fix — closing the "doctor stays green while
  a dead block remains" gap. Since this check also runs automatically
  after every `pull`, a stale legacy block now surfaces immediately
  instead of staying invisible.

Deliberately not doing in this round: automatically migrating/deleting
a legacy block during `pull` itself. `doctor` (and `pull`'s own
post-pull check) now names the file and the fix, and `uninstall`
performs it — that closes the visibility and cleanup gap without
teaching `pull`'s write path to also delete files elsewhere, which is
a larger behavioral change worth its own review.

Verified with unit tests for envBlockReferencesDataHome (current form,
pre-Tencent#661 raw form, MSYS drive form, different-scope negative case), a
new uninstall test cleaning a pre-Tencent#661 legacy .bashrc block, a new
doctor test flagging a stray legacy block, and a real CLI build
reproducing the reviewer's exact repro end-to-end: pull writes the new
block to .profile, doctor immediately flags the leftover .bashrc
block and names `teamai uninstall`, and uninstall removes both.

Also refined the Windows fallback description in usage-guide.md /
usage-guide.zh-CN.md with Git for Windows' exact guard condition, per
the reviewer's note that the bug looks environment-specific without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

@CarlosWonMore — thank you for the real hardware validation, this is exactly the kind of testing an emulated repro can't substitute for. Addressed both findings in aea31ef.

Finding 2 (legacy spellings invisible to doctor/uninstall): added envBlockReferencesDataHome, a looser "does this block belong to this scope" check covering the current format plus the two you reproduced — raw unquoted/unconverted backslashes (pre-#661) and the MSYS/Cygwin drive form (/d/Users/...). Kept it separate from envBlockSourcesPath, which stays strict and keeps answering "does this block actually load" for doctor's #661 check — conflating the two would make doctor stop reporting a genuine #661-style break just because a legacy spelling happens to match. uninstall.ts now uses the looser check for its candidate scan.

Finding 2's "doctor stays green" half, and Finding 3's "at minimum name the command" ask: doctor's env-delivery check now also scans the other candidate files for a stray same-scope block and reports it, naming teamai uninstall. Since this check runs automatically after every pull, I re-ran your exact fixture (.profile present sourcing something else, .bashrc carrying a pre-#661 raw-backslash block) end to end on a real build:

✔ Synced 1 env variable(s)
⚠ Pull finished, but 1 check(s) failed:
⚠   ✖ Env variables injected in shell profile
    → ...\.bashrc still carries a teamai env block for this scope from an
      earlier install; run `teamai uninstall` to remove it...

doctor reports the same, and uninstall --force then cleans both .profile and .bashrc, confirmed on disk after.

Finding 3 (migrate during pull itself): deliberately not doing this in this round. Teaching pull's write path to also delete files elsewhere is a bigger behavioral change than what's already in this PR, and I'd rather it get its own review than get folded in here under review pressure. doctor (and pull's own post-pull check, as above) now names the file and the fix, and uninstall performs it — I think that closes the visibility and cleanup gap without that larger change. Open to being told that's not enough.

Finding 4 (docs): updated both usage-guide docs with Git for Windows' exact guard condition ([ -e ~/.bashrc -a ! -e ~/.bash_profile -a ! -e ~/.bash_login -a ! -e ~/.profile ]) so the bug doesn't read as environment-specific.

Also added the unrelated MSYS drive-form case as its own unit test rather than only the pre-#661 raw form, since your report named both as things you'd actually seen.

Thanks also for weighing in on the compatibility-matrix thread and on #684 — appreciated.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Normalize the configured profile path before detecting strays — src/doctor-delivery.ts:597. With sharing.env.shellProfilePath: ~/.profile, readFileSafe() correctly expands ~, but profilePath remains literal while the candidate is absolute. The same physical file is therefore treated as a different profile, so doctor and post-pull checks always report its valid block as stale. Expand/normalize profilePath before reading and comparing.
  • The PR description includes a detailed test plan and real-CLI end-to-end verification record; testing documentation is sufficient.

…t test bug (review)

Bot review on aea31ef found a real regression: with
sharing.env.shellProfilePath: ~/.profile, profilePath stayed the
literal unexpanded string while the stray-block scan's candidates are
always absolute, so the resolved file never matched itself
(candidate === profilePath) and got reported as a stray copy of its
own valid block. Expand profilePath once, up front, mirroring the
pattern uninstall.ts already used for the same override.

Also: CI (ubuntu/macos) caught a real bug in the previous commit that
my own Windows-host testing couldn't — candidateSpellings converted
backslashes to forward slashes via `envShPath.split(path.sep).join('/')`,
which is a no-op on a POSIX runner even for a Windows-shaped input
path, since path.sep there is '/'. Moved isWindowsFormPath (shape-based,
already used by generateShellBlock) out of resources/env.ts into the
shared utils/shell-profile.ts and used it instead, so the conversion is
correct regardless of which host runs it — matching the pattern the
rest of this file already established for platform-independent logic.

Fixed one test that fell into the same trap from the other direction:
it fabricated a Windows-style block by replacing `/` with `\` in a
POSIX-on-CI envShPath, producing a string with no drive letter at all
that doesn't correspond to any real on-disk state. Rewrote it to use
an unquoted (not backslashed) legacy form, which is realistic on any
host; the Windows-specific legacy spellings are already covered
directly in shell-profile.test.ts with explicit Windows-shaped literals.

Added a regression test for the ~/.profile-override case and
re-verified with a real CLI build: pull + doctor no longer report a
tilde-configured profile as carrying a stray copy of its own block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed the P1 in cd739c4, plus a real CI failure the previous commit introduced.

The reported bug (doctor-delivery.ts:597): fixed. profilePath is now expanded via expandHome() up front, mirroring the pattern uninstall.ts already used for this same override — sharing.env.shellProfilePath: ~/.profile no longer gets compared as a literal ~/... string against always-absolute stray candidates. Re-verified with a real CLI build: pull and doctor both now correctly treat the resolved .profile as itself, not a stray copy.

Unplanned but important: CI (ubuntu/macos) failed on the previous push and caught something my own Windows-only local testing couldn't. candidateSpellings converted backslashes via envShPath.split(path.sep).join('/') — a no-op on a POSIX CI runner even for a Windows-shaped path, since path.sep there is /. Fixed by reusing isWindowsFormPath (the same shape-based check generateShellBlock already relies on for this exact reason), moved out of resources/env.ts into the shared utils/shell-profile.ts. Also caught and fixed a test of my own that fell into the mirror-image version of the same trap (fabricated a Windows-style block by string-replacing / for \ in a POSIX-on-CI path, producing something with no drive letter that can't occur on disk).

CI is green again on this push; will keep an eye on it.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/doctor-delivery.ts:595 treats any same-scope block in another candidate profile as a failed delivery. After upgrading, a valid block may exist in the newly selected .bash_profile while the previous .bashrc block remains; environment delivery works, yet every pull/doctor reports failure until the user manually edits files or performs a destructive full uninstall. Stale duplicate cleanup should not make the delivery health check fail, or pull should migrate it automatically.
  • [P1 blocking] The PR description’s E2E record only covers a local git repository and does not document the required Agent matrix (Claude, Codex, CodeBuddy, OpenCode) or Provider matrix (git, gitlab, github). Add the mandated real-CLI verification results before merge.
  • [P2 non-blocking] src/utils/shell-profile.ts:135 adds substantial speculative ownership logic for raw paths and “locally-built or hand-patched” MSYS spellings, expanding a focused profile-selection fix into destructive cross-file cleanup. This conflicts with the repository’s surgical-change/Occam’s-razor rule; keep the PR scoped to shared profile detection unless these legacy formats are demonstrated production states.

A valid, working delivery reported as failed just because a stray
leftover block sits in another candidate file — buildEnvDeliveryCheck
folded "does this scope's env reach a shell" and "is there dead cruft
to clean up" into the same problems list, so a user whose delivery
genuinely works saw doctor/pull report failure until they ran
`teamai uninstall` or edited files by hand.

Split envDeliveryProblems to return { problems, staleProfiles }, and
buildEnvDeliveryCheck now returns two independent Checks: "Env
variables injected in shell profile" (unchanged delivery-correctness
semantics) and a new "No stale env blocks left behind" that only fails
when a legacy/duplicate block for this scope is found elsewhere,
naming `teamai uninstall`. A working delivery now reports healthy
regardless of leftover cruft; the cruft is still surfaced, just not as
the same failure.

Re-verified with a real CLI build reproducing the reviewer's exact
scenario: `doctor` now shows "Env variables injected in shell profile"
as a pass and "No stale env blocks left behind" as a separate,
distinct failure, instead of one conflated failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed the P1 in 5f762ac.

Severity finding (doctor-delivery.ts:595): fixed by splitting the check. buildEnvDeliveryCheck now returns two independent Checks: Env variables injected in shell profile (unchanged delivery-correctness semantics — missing/stale vars, block doesn't load) and a new No stale env blocks left behind that only fails when a legacy/duplicate block for this scope sits elsewhere. Re-ran the exact upgrade scenario on a real build:

  ✔ Env variables injected in shell profile
  ✖ No stale env blocks left behind
    → ...\.bashrc still carries a teamai env block for this scope from an
      earlier install; run `teamai uninstall` to remove it...

A working delivery now reports healthy; the leftover is still surfaced, just not as the same failure. I went with "split into two checks" over "auto-migrate during pull" for the same reason I gave earlier in this thread — the latter is a bigger behavioral change I'd rather not fold in under review pressure.

Compatibility matrix (repeated): this was already addressed in my reply at 06:30:41, and @CarlosWonMore independently agreed at 08:19:27 ("Nothing in this diff branches on the git provider or on per-tool hook code... We would rather see the two findings above addressed than the matrix padded"). Standing on that — happy to revisit if a specific interaction is named.

P2: legacy-format matching is speculative scope creep: these aren't speculative. @CarlosWonMore reported both formats from real hardware in this same thread — the raw/unquoted pre-#661 form ("we reported #661/#672, filed #686 and #682... this is our daily platform") and the MSYS drive form ("seen on our own machine: the block there is functional but spelled in the MSYS drive form /c/…, written by a locally patched 0.24.0"). Removing that handling would reopen the exact gap their hardware testing found. I'd rather keep it scoped to what's demonstrated than trim it back to a form that's already known to miss real installs.

@CarlosWonMore

Copy link
Copy Markdown
Contributor

Windows 11 real-machine re-verification: aea31efcd739c4

My earlier comment reported two findings from hardware testing of a983ef0. This is the re-verification of your fixes, on the same machine and the same fixture: Windows 11 25H2, Node 22.22.2, Git for Windows (/usr/bin/bash).

Both commits were built from source (npm ci --ignore-scripts + npm run build) and the built dist/index.js was driven against a scratch $HOME plus a local git team repo. I ran both commits through the identical three-scenario matrix so the delta is isolated rather than asserted:

  • aea31efea — your fix for the two findings I reported
  • cd739c471 — that plus the ~-normalization fix for the bot's P1

Results

# check aea31ef cd739c4
1 legacy in-scope block (raw, unquoted, unconverted backslashes) left in .bashrc — does pull/doctor flag it? ✅ flagged; names only .bashrc; names teamai uninstall ✅ same
2 does uninstall --force clean both .profile and .bashrc? ✅ both cleaned, 0 blocks left ✅ same
3 same block in the MSYS drive form (/d/Users/...) ✅ flagged and cleaned ✅ same
4 sharing.env.shellProfilePath: ~/.profile false positive — reported .bashrc, .profile only .bashrc

Row 3 is the shape our own machine actually carries. Our ~/.bashrc holds

[ -f "/c/Users/Carlos/.teamai/env.sh" ] && source "/c/Users/Carlos/.teamai/env.sh"

written by a locally-patched build. Before this PR both doctor and uninstall were blind to it — uninstall printed only Cleaned shell profile: …\.profile and the .bashrc block survived permanently. It is now found, flagged, and removed. That closes both findings I reported; thank you for turning them around.

Row 4 is the bot's P1 reproduced. With shellProfilePath: ~/.profile and .profile present, aea31ef flagged its own target file as a stray leftover:

✖ Env variables injected in shell profile
  → …\tilde\.bashrc, …\tilde\.profile still carries a teamai env block for
    this scope from an earlier install; run `teamai uninstall` to remove it…

cd739c4 reports only the genuine stray:

✖ Env variables injected in shell profile
  → …\tilde\.bashrc still carries a teamai env block for this scope from an
    earlier install; run `teamai uninstall` to remove it…

So expandHome() is confirmed on hardware, not merely by reading the diff. A label-normalized diff of the two full transcripts shows row 4 as the only behavioural difference — rows 1–3 are byte-identical, so nothing regressed in the paths I originally reported.

Also

  • Your own new tests pass here too: vitest run src/__tests__/shell-profile.test.ts src/__tests__/uninstall.test.ts52/52 passed on Windows, including the three envBlockReferencesDataHome spellings and the pre-[bug] Windows: env 注入到 shell profile 时写的是原生路径,source 静默失败而 doctor 仍报通过 #661 uninstall case.
  • Extracting isWindowsFormPath was the right call, and worth a note: the defect you hit in CI is not observable on Windows at all. On a Windows host envShPath.split(path.sep) and a shape-based check agree, so only a POSIX runner separates them. Windows-only testing would have shipped it green — good catch by CI, and the shape-based check is what makes the test meaningful on both hosts.

One incidental observation, not caused by this change

teamai uninstall left ~/.teamai/ and its contents on disk (config.yaml, debug.log, env.sh, state.json, .sync-lock) in 5 of 6 scenario runs. This reproduces on both commits, so it is pre-existing and unrelated to this PR — one run did print Removed …\.teamai/, so it looks intermittent rather than unconditional. Flagging it only so it is not mistaken for a regression from this change; I have not investigated it further.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/doctor-delivery.ts:527 models stale blocks as a failed health check. Since this check also runs after pull, the exact upgrade scenario this PR targets—an old .bashrc block plus a new working .profile block—produces “Pull finished, but 1 check(s) failed” despite successful delivery. Its remediation also suggests teamai uninstall, which removes the entire installation. Stale-block cleanup should be informational or handled during migration, not fail post-pull health.
  • [P2 non-blocking] src/doctor-delivery.ts:625 compares profile paths using raw string equality. On Windows, a valid override such as C:/Users/me/.profile differs textually from the generated candidate C:\Users\me\.profile, although both identify the same file. The valid current block is then incorrectly reported as stale. Normalize paths—and account for Windows case insensitivity—before comparing.

The PR description includes a detailed test plan and real-CLI end-to-end verification, so no testing-documentation finding is required.

Two findings from the bot's re-review of 5f762ac:

- P1: pull.ts counted every failed check the same way, so the new
  informational "No stale env blocks left behind" check still made a
  genuinely healthy delivery print "Pull finished, but 1 check(s)
  failed" — the exact upgrade scenario this PR targets. Check now
  carries an `informational` flag; pull's post-pull summary excludes
  it from the failure count and prints it on its own line instead.
  doctor's own exit code is unaffected, since a stray leftover is
  still something worth acting on.

- P2: the stray-block scan compared `shellProfilePath` against its
  generated candidate with raw `===`, so a valid override spelled
  with forward slashes (or, on Windows, different case) reported the
  block's own file as a stray copy of itself. Added `sameFile()`,
  resolved through `path.win32`/`path.posix` explicitly (so the win32
  branch is actually exercised on ubuntu/macos CI, not just locally).

Verified both fixes end-to-end on a real Windows 11 host: built
dist/index.js, drove it against a scratch $HOME and a real local git
team repo with a legacy .bashrc block plus a working .profile block
(forward-slash shellProfilePath) — only the stray .bashrc block is
named, no "check(s) failed" banner, and .profile is not misreported
as a stray copy of itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed both in c2ac325.

P1 (doctor-delivery.ts:527): agreed — splitting into two checks fixed the health-check semantics but not pull's own summary, which counted every failed check the same way. Added Check.informational?: boolean (doctor.ts); the new "No stale env blocks left behind" check sets it. pull.ts's post-pull summary now excludes informational failures from the Pull finished, but N check(s) failed count and prints them on their own line instead. doctor's exit code is untouched — a stray leftover is still worth flagging on its own.

Real-machine re-verification of the exact upgrade scenario (built dist/index.js, scratch $HOME, real local git team repo, shellProfilePath: .profile with a legacy block left in .bashrc):

- [user] Pulling team repo...
✔ [user] Team repo: 1 file(s) changed
✔ [user] Synced 1 env variable(s) to ...\.teamai/env.sh
  ✖ No stale env blocks left behind
    → ...\.bashrc still carries a teamai env block for this scope from an
      earlier install; run `teamai uninstall` to remove it, or delete the block manually.
  Run `teamai doctor` for the full report.

No "check(s) failed" banner; the leftover is still named.

P2 (doctor-delivery.ts:625): agreed, raw === was wrong. Added sameFile() in shell-profile.ts, resolved through path.win32/path.posix explicitly (not the ambient path) so the win32 case-insensitive branch is actually exercised on ubuntu/macos CI rather than only on a real Windows host — same reason platform is injectable elsewhere in this file.

Re-verified on the same real Windows box with shellProfilePath: "D:/.../home/.profile" (forward slashes) against the native D:\...\home\.profile candidate path.join builds: only the unrelated .bashrc leftover is flagged, .profile is not misreported as a stray copy of itself.

Added regression tests for both: pull-post-checks.test.ts (informational failures excluded from the count but still printed), shell-profile.test.ts (sameFile across separator/case, explicit platform arg so both branches run on CI), and doctor-env-delivery.test.ts (forward-slash shellProfilePath override). npx tsc --noEmit clean; full vitest run shows the same 30-file/70-test pre-existing Windows-host-only failure count as unmodified main (POSIX-path-literal test fixtures, disclosed earlier in this thread) — no new failures. Docs (both languages) updated to describe the informational-check behavior and the new stale-block check.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-profile.ts:57 — Falling back to .bashrc is not stable. After the first pull, Git for Windows creates .bash_profile sourcing .bashrc; subsequent runs then select .bash_profile, inject a second block, and report the still-functional .bashrc block as stale. Create/use a stable login profile, or recognize the generated forwarding profile.
  • [P1 blocking] src/doctor-delivery.ts:627sameFile() only compares normalized path strings, not filesystem identity. Common setups such as .bash_profile -> .bashrc cause the active profile to be reread through the symlink and falsely reported as stale, making doctor fail after a healthy pull. Resolve real paths or compare file identity before classifying candidates.

The PR description includes both a detailed test plan and real-CLI end-to-end verification, so no testing-documentation finding is needed.

…(review)

The bot's round-7 review found a real flip-flop: Git for Windows'
/etc/profile.d/bash_profile.sh auto-generates ~/.bash_profile (a
plain file sourcing ~/.bashrc, not a symlink) the first time a login
shell starts with .bashrc present but none of .bash_profile/
.bash_login/.profile. That satisfies exactly the condition a first
pull leaves behind (.bashrc written, nothing else exists yet), so the
very next login shell creates .bash_profile out from under it.
detectShellProfile()'s order then prefers that newly-existing file on
the *next* pull, injecting a second block there and reporting the
original .bashrc block — still loading, just one hop further away —
as a dead leftover.

Added resolveActiveShellProfile() in shell-profile.ts: before falling
back to the order-based detectShellProfile(), it checks whether any
candidate already carries a block for this scope's env.sh and, if so,
reuses it. Both the write path (EnvHandler.pullItem, via
detectShellProfile) and the read path (doctor-delivery's
envDeliveryProblems) now resolve through it, so pull and doctor keep
agreeing on the same file.

(The bot's second finding described the fix as "resolve real paths /
compare file identity" — .bash_profile isn't a symlink or hardlink to
.bashrc, it's a regular file with a `source ~/.bashrc` line in it, so
realpath() wouldn't touch this at all. Confirmed the exact generated
content directly from a local Git for Windows install
(etc/profile.d/bash_profile.sh) before designing the fix around that.)

Verified end-to-end on a real Windows host: two `teamai pull` runs
against a real local git team repo, with the exact Git-for-Windows
forwarding .bash_profile content dropped in between (not written by
teamai) — second pull updates .bashrc in place, no duplicate block,
`teamai doctor` reports both env checks clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Addressed in 02c93fe.

Confirmed the underlying mechanism first rather than assuming: checked /etc/profile.d/bash_profile.sh on a local Git for Windows install directly.

# add ~/.bash_profile if needed for executing ~/.bashrc
if [ -e ~/.bashrc -a ! -e ~/.bash_profile -a ! -e ~/.bash_login -a ! -e ~/.profile ]; then
  ...
  cat >~/.bash_profile <<-\EOF
	# generated by Git for Windows
	test -f ~/.profile && . ~/.profile
	test -f ~/.bashrc && . ~/.bashrc
	EOF
fi

So the P1 is real and exactly as described: a first pull with nothing on disk yet writes into .bashrc (correctly — nothing else exists), and that leaves precisely the condition above satisfied. The next Git Bash login shell auto-generates .bash_profile, and detectShellProfile's order then prefers it on the next pull — injecting a second block there and reporting the original, still-loading .bashrc block as stale.

One correction on the second finding's framing: .bash_profile here is a plain regular file containing a source ~/.bashrc line, not a symlink or hardlink — confirmed from the generated content above. fs.realpath() wouldn't touch it; there's no filesystem-level identity to resolve. The practical problem is real, the mechanism named for it isn't what's actually happening.

Fix: resolveActiveShellProfile() in shell-profile.ts — before running detectShellProfile's order-based fallback, check whether any candidate already carries a block for this scope's env.sh, and if so, stay there. Only a genuinely first pull (no existing block anywhere) falls through to the order. Both the write path (EnvHandler.pullItem) and the read path (doctor-delivery's envDeliveryProblems) now resolve through it, so they can't disagree on which file is "the" profile.

Real-machine verification of the exact sequence: pull #1 (nothing on disk) → .bashrc. Then dropped in the identical Git-for-Windows-generated .bash_profile content by hand (not through teamai, to isolate the scenario). Pull #2:

- [user] Pulling team repo...
✔ [user] Team repo: 1 file(s) changed
✔ [user] Synced 1 env variable(s) to ...\.teamai/env.sh

No duplicate block, no stale-block warning. .bashrc still holds the one block, .bash_profile is untouched. teamai doctor afterward:

✔ Env variables injected in shell profile
✔ No stale env blocks left behind

Added regression tests at three levels: resolveActiveShellProfile directly (including sticking to a lower-priority candidate to prove it overrides ordering, and not sticking to a different scope's block), EnvHandler.pullItem (two real pullItem calls with the forwarding file dropped in between), and updated the docs (both languages) to explain why the order-based preference only applies to a first pull.

npx tsc --noEmit clean; full vitest run unchanged at the same pre-existing 30-file/70-test Windows-host-only baseline (disclosed earlier in this thread) — 5 new tests added, all passing, no new failures.

@STiFLeR7

Copy link
Copy Markdown
Contributor Author

FYI: Codex PR Review failed on 02c93fe, but it's a transient infra issue on the action's side, not a finding — the job's log shows its model stream disconnecting mid-review (stream disconnected before completion, 5 reconnect attempts, then codex exited with code 1) before it produced any output. No new findings were posted. I don't have admin rights on this repo to re-run the job myself (gh run rerun was refused). Every other check is green. Could someone with rights re-run that one job, or should I push a follow-up commit to get a fresh run?

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] resolveActiveShellProfile() prioritizes any existing TeamAI block over the profile the current shell actually reads. For the exact upgrade case—Windows with .profile present and an old TeamAI block in .bashrc—the scan returns .bashrc before reaching .profile. Consequently, pull keeps updating the unread file and doctor incorrectly reports success. Shell changes, such as zsh to bash, have the same problem. Stickiness must only apply when the existing profile remains reachable by the active shell. src/utils/shell-profile.ts:227

Testing

  • The PR description includes a detailed unit/type-check plan and real-CLI end-to-end records, so its testing documentation is sufficient. However, it misses the critical upgrade scenario above: .profile exists and .bashrc already contains the old TeamAI block.

@jeff-r2026
jeff-r2026 merged commit cc38871 into Tencent:main Sep 21, 2026
11 of 12 checks passed
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Good catch — real, serious bug. Fixed in `1ed0141`.

`02c93fe`'s `resolveActiveShellProfile` scanned all of `SHELL_PROFILE_CANDIDATE_NAMES` in a fixed order and returned the first candidate carrying a matching block — broader than the Git-for-Windows-forwarding case it was written for. Since `.bashrc` sorts before `.profile` in that list, a stale pre-#682 block left in `.bashrc` would outrank a correctly order-picked, not-yet-written `.profile` — exactly the upgrade scenario reported, and exactly the original #682 bug, reintroduced silently: worse than before, since `doctor` no longer caught it (the stale block is well-formed where it sits, so `envBlockSourcesPath` on it reports true).

Fix: reworked to start from `detectShellProfile`'s order-based pick — the file the current environment actually reads — and only diverge from it when that pick's own content references another candidate by a home-relative path (`~/.bashrc`, `$HOME/.bashrc`), the shape a real sourcing line takes. A block sitting in a candidate the pick never reaches is no longer preferred over the pick, no matter what it contains.

Also worth flagging on myself: the first cut of that reference check was a bare substring match on the filename, and my own test's plain-English comment (`"...unrelated to .bashrc"`) satisfied it and failed the test — caught before push, tightened to require the actual `~/name` / `$HOME/name` shape.

Real-machine re-verification of both scenarios:

The reported upgrade case (stale `.bashrc` block, empty `.profile`, no forwarding between them):
```
✔ [user] Synced 1 env variable(s) to ....teamai/env.sh
✖ No stale env blocks left behind
→ ....bashrc still carries a teamai env block for this scope from an
earlier install; run `teamai uninstall` to remove it, or delete the block manually.
```
New block correctly lands in `.profile`; `doctor` reports `Env variables injected in shell profile: ✔`.

The prior round's Git-for-Windows forwarding case, re-run to confirm no regression:
```
✔ Env variables injected in shell profile
✔ No stale env blocks left behind
```
Still sticks to `.bashrc` through the generated `.bash_profile`, no duplicate.

Replaced the test that had asserted the old (incorrect) broad-stickiness behavior, and added the upgrade-case regression test. `npx tsc --noEmit` clean; full `vitest run` at the same pre-existing 30-file/70-test Windows-host-only baseline — no new failures.

kongdayan added a commit to kongdayan/teamai-cli that referenced this pull request Sep 21, 2026
Squash-rebased onto the latest upstream/main to resolve the PR's merge
conflict (main gained Tencent#693/Tencent#685/Tencent#694/Tencent#691/Tencent#681/Tencent#680/Tencent#666 since this branch
forked). This combines all commits from the PR into one, applied cleanly on
top of the new base — no functional changes from the previously reviewed
state.

The only real conflict was in src/__tests__/uninstall.test.ts, where diff3
split a test mid-body because of the repeated `});` boilerplate around it;
resolved by keeping both sides' new tests intact, in full.
STiFLeR7 added a commit to STiFLeR7/teamai-cli that referenced this pull request Sep 23, 2026
…y (review)

Two more P1s from the bot's round-10 review of 75f3eac:

- The traversal committed to the first referenced candidate in
  SHELL_PROFILE_CANDIDATE_NAMES's fixed priority order and gave up if
  that branch was a dead end, instead of trying every candidate the
  current file actually references. Git for Windows' own generated
  .bash_profile sources both .bashrc and .profile in one file — if the
  real block sits in .profile but .bashrc (sorting earlier) has none,
  the walk stopped at .bashrc without ever trying .profile. Reworked
  into a breadth-first search over the whole reference graph.

- Splitting statements on `||` treated its right side as unconditionally
  reached, but `||`'s right side only runs if the left side fails,
  which isn't something this code can establish. `source ~/.profile ||
  source ~/.bashrc` would mark .bashrc reachable even when .profile
  succeeds. Statements no longer split on `||`; a `source`/`.` sitting
  only after it is folded into its left side's statement and never
  recognized as its own reference, so it's never preferred over a
  block the left side already reaches. Conservative by construction:
  worst case is falling back to the order-based pick (the pre-Tencent#693-fix
  behavior), never a false "reachable".

Verified the exact branching scenario end-to-end on a real Windows
host: .bash_profile with the literal Git-for-Windows-generated content
(sources both .bashrc and .profile), .bashrc empty, real block in
.profile — resolves to .profile, no duplicate, doctor fully clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
STiFLeR7 added a commit to STiFLeR7/teamai-cli that referenced this pull request Sep 23, 2026
…s (review)

Two more P1s from the bot's round-11 review of aaa1142, both about
referencesCandidate() trusting shell control flow it can't actually
evaluate:

- Any `&&` was treated as making its right side reachable, without
  checking what the left side's condition even was. A guard like
  `[ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc` would mark
  .bashrc reachable unconditionally, even though it only runs inside
  VS Code. Also flagged: a source sitting inside a multiline `if`
  body looks, line by line, identical to a top-level one.

- Folding `||`'s right side into its left statement (the round-9 fix)
  went too conservative the other way: `source ~/.profile ||
  source ~/.bashrc` DOES guarantee .bashrc runs when ~/.profile
  doesn't exist, and the resolver was never even trying it.

Rather than growing another ad hoc regex tweak, rewrote
referencesCandidate() around what it can actually verify without a
real shell parser:

  - Unconditional: a bare `. REF` / `source REF` — but nothing inside
    an `if` block counts, conditional or not. An `if`'s condition is
    opaque to a line scanner; trusting some conditions and not others
    would just be guessing.
  - Existence-gated `&&`: only the self-referential idiom
    `test -f REF && . REF` / `[ -f REF ] && . REF`, where the tested
    path and the sourced path are the same candidate — the one `&&`
    condition this code can independently verify, by visiting that
    candidate itself later in the search.
  - `||` fallback: the left side always counts (always attempted);
    the right side counts only when the left side's own target file
    does not exist on disk — the one case an `||` fallback is
    actually guaranteed to run.

Anything this can't resolve either way is never trusted: the search
just doesn't queue that candidate, and the caller falls back to the
order-based pick — at worst a harmless duplicate block (the
pre-Tencent#693-fix behavior), never a false "reachable" that would
reintroduce Tencent#682.

Verified end-to-end on a real Windows host: re-ran the core
Git-for-Windows two-pull scenario from Tencent#693 (self-referential &&,
still recognized) with no regression. Added 3 unit tests for the new
boundaries: || recognized when the left target is missing, a
non-existence && condition rejected, and a source nested inside an
if block rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jeff-r2026 pushed a commit that referenced this pull request Sep 23, 2026
…es (#682) (#715)

* fix(env): only stick to a candidate the active profile actually reaches (review)

02c93fe's resolveActiveShellProfile scanned every SHELL_PROFILE_CANDIDATE_NAMES
entry for a matching block and returned the first hit, in a fixed order
(.zshrc, .bashrc, .bash_profile, .bash_login, .profile). That's broader
than the Git-for-Windows-forwarding case it was written for: a stale
block a pre-#682 install left in .bashrc would outrank a correctly
order-picked .profile that hasn't been written to yet, since .bashrc
sorts earlier in the candidate list — silently reintroducing #682 for
exactly the installs upgrading through this fix, with doctor unable to
catch it because the stale block is well-formed where it sits.

Reworked to start from detectShellProfile's order-based pick (the file
the current environment actually reads) and only diverge from it when
that pick's own content references another candidate by a home-relative
path (~/.bashrc, $HOME/.bashrc) — the shape Git for Windows' generated
forwarding file actually takes. A block sitting in a candidate the pick
never reaches is no longer preferred over the pick, regardless of what
it contains.

Also caught and fixed a case of exactly the failure mode this PR is
about: the first cut of the forwarding check was a bare substring match
on the candidate's filename, and my own test's plain-English comment
("...unrelated to .bashrc") satisfied it. Tightened to require the
home-relative reference form a real sourcing line uses.

Verified both scenarios end-to-end on a real Windows host:
- The exact bot-reported upgrade case (stale .bashrc block, empty
  .profile, no forwarding between them): pull now writes into .profile
  and correctly flags .bashrc as stale; doctor reports delivery healthy.
- The Git-for-Windows forwarding case from the prior round: still
  sticks to .bashrc through the generated .bash_profile, no duplicate,
  no stale-block warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): match real source commands, resolve reachability transitively (review)

Two P1s from the bot's review of #715:

- resolveActiveShellProfile's reachability check was a bare substring
  search on the active pick's content. A comment mentioning a filename
  (never executed) or a longer file sharing the same prefix
  (~/.bashrc.local) would both satisfy it, letting a stale block win
  the same way #682 did. Replaced with referencesCandidate(): strips
  full-line comments, splits each remaining line into statements on
  &&/||/;, and only counts a statement whose first word is literally
  `.` or `source` and whose second word is an anchored home-relative
  reference to exactly that candidate.

- The check only followed one hop: .bash_profile sourcing .profile
  sourcing .bashrc (the common Debian .profile pattern, sourcing
  .bashrc for interactive shells) would miss a block two hops away and
  inject a duplicate. Reworked into a loop that walks the chain of
  files the pick actually sources, with a visited set for cycle
  protection, stopping at the first one that carries the block.

Also fixed the P2: EnvHandler.detectShellProfile's doc comment still
claimed it "stays on whichever candidate already carries this scope's
block" unconditionally, which stopped being true once reachability was
required.

Verified end-to-end on a real Windows host:
- The new two-hop chain (.bash_profile -> .profile -> .bashrc, block
  in .bashrc): resolves to .bashrc, no duplicate, doctor fully clean.
- Re-ran the Git-for-Windows one-hop scenario and the #682 upgrade
  scenario from the prior round — both still correct, no regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): search every referenced candidate, respect || conditionality (review)

Two more P1s from the bot's round-10 review of 75f3eac:

- The traversal committed to the first referenced candidate in
  SHELL_PROFILE_CANDIDATE_NAMES's fixed priority order and gave up if
  that branch was a dead end, instead of trying every candidate the
  current file actually references. Git for Windows' own generated
  .bash_profile sources both .bashrc and .profile in one file — if the
  real block sits in .profile but .bashrc (sorting earlier) has none,
  the walk stopped at .bashrc without ever trying .profile. Reworked
  into a breadth-first search over the whole reference graph.

- Splitting statements on `||` treated its right side as unconditionally
  reached, but `||`'s right side only runs if the left side fails,
  which isn't something this code can establish. `source ~/.profile ||
  source ~/.bashrc` would mark .bashrc reachable even when .profile
  succeeds. Statements no longer split on `||`; a `source`/`.` sitting
  only after it is folded into its left side's statement and never
  recognized as its own reference, so it's never preferred over a
  block the left side already reaches. Conservative by construction:
  worst case is falling back to the order-based pick (the pre-#693-fix
  behavior), never a false "reachable".

Verified the exact branching scenario end-to-end on a real Windows
host: .bash_profile with the literal Git-for-Windows-generated content
(sources both .bashrc and .profile), .bashrc empty, real block in
.profile — resolves to .profile, no duplicate, doctor fully clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): only trust verifiable && and || conditions, ignore if bodies (review)

Two more P1s from the bot's round-11 review of aaa1142, both about
referencesCandidate() trusting shell control flow it can't actually
evaluate:

- Any `&&` was treated as making its right side reachable, without
  checking what the left side's condition even was. A guard like
  `[ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc` would mark
  .bashrc reachable unconditionally, even though it only runs inside
  VS Code. Also flagged: a source sitting inside a multiline `if`
  body looks, line by line, identical to a top-level one.

- Folding `||`'s right side into its left statement (the round-9 fix)
  went too conservative the other way: `source ~/.profile ||
  source ~/.bashrc` DOES guarantee .bashrc runs when ~/.profile
  doesn't exist, and the resolver was never even trying it.

Rather than growing another ad hoc regex tweak, rewrote
referencesCandidate() around what it can actually verify without a
real shell parser:

  - Unconditional: a bare `. REF` / `source REF` — but nothing inside
    an `if` block counts, conditional or not. An `if`'s condition is
    opaque to a line scanner; trusting some conditions and not others
    would just be guessing.
  - Existence-gated `&&`: only the self-referential idiom
    `test -f REF && . REF` / `[ -f REF ] && . REF`, where the tested
    path and the sourced path are the same candidate — the one `&&`
    condition this code can independently verify, by visiting that
    candidate itself later in the search.
  - `||` fallback: the left side always counts (always attempted);
    the right side counts only when the left side's own target file
    does not exist on disk — the one case an `||` fallback is
    actually guaranteed to run.

Anything this can't resolve either way is never trusted: the search
just doesn't queue that candidate, and the caller falls back to the
order-based pick — at worst a harmless duplicate block (the
pre-#693-fix behavior), never a false "reachable" that would
reintroduce #682.

Verified end-to-end on a real Windows host: re-ran the core
Git-for-Windows two-pull scenario from #693 (self-referential &&,
still recognized) with no regression. Added 3 unit tests for the new
boundaries: || recognized when the left target is missing, a
non-existence && condition rejected, and a source nested inside an
if block rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(env): document transitive chaining and the verifiable-reference boundary (review)

Round-11 P2: both docs described only a single directly-referenced
candidate, but the resolver has followed transitive chains since
75f3eac and now only trusts specific verifiable && / || forms (60a2da0).
Describes the Debian .profile -> .bashrc two-hop case alongside the
Git-for-Windows one, and names the three reference shapes recognized
(bare source, self-referential existence-gated &&, existence-checked
|| fallback) and that if-bodies are never trusted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): validate reference quoting, credit &&'s left side, generalize block-skip (review)

Round 12 found three more real gaps in referencesCandidate(), plus one
I agree isn't worth chasing further (see PR reply):

- The quote check accepted `source "~/.bashrc"` and `source
  '$HOME/.bashrc'` as valid references, but a shell never tilde-expands
  inside any quotes and never variable-expands inside single quotes —
  both source a literal, near-certainly nonexistent path. Tightened to
  the three forms that actually expand: bare `~/name`, and `$HOME/name`
  either bare or double-quoted.

- `&&`'s left side is always attempted, the same as `||`'s — `source
  ~/.bashrc && echo ready` does reach .bashrc regardless of the
  trailing command, but the old "whole statement must be exactly `.
  REF`" check missed it. The leftmost command before the first `&&` (or
  no `&&` at all) is now checked the same way `||`'s left side already
  was.

- Only `if`/`fi` was tracked, so a source inside an uncalled function,
  a non-selected `case` arm, or a loop body — none of them any more
  guaranteed to run than an `if` body — was wrongly treated as
  top-level. Generalized the "don't trust it" depth counter to cover
  for/while/until, case/esac, and function/brace groups too, sharing
  one counter since we only need to know whether we're inside *any* of
  them, not which one.

Declined to extend if-body trust to cover the standard nested Debian
`.profile` template (`if [ -n "$BASH_VERSION" ]; then if [ -f
"$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi; fi`) — doing so would
mean trusting the outer `$BASH_VERSION` check, which is exactly the
class of unverifiable shell condition this design has refused since
round 11. Fixed the docs instead: they previously (incorrectly)
claimed this exact template was recognized; now they say plainly that
nested conditionals of any kind fall back to the order-based pick.

Verified end-to-end on a real Windows host: re-ran the Git-for-Windows
two-pull scenario unaffected. Added 6 unit tests for the new
boundaries (invalid vs. valid quoting, &&'s left side, function/case/
loop bodies). 41/41 in shell-profile.test.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): handle comments, line continuations, heredocs, and chained && in profile scanning (review)

Round 13 review found five genuine structural gaps in referencesCandidate,
all fixed by moving open/close-block detection to per-statement (post
`;`-split) instead of per-line, and adding a logicalLines() preprocessing
pass:

- Backslash-continued lines were scanned independently, losing the
  conditional context of the line they continue (`cond && \` followed by
  `source X` on the next line looked unconditional).
- A one-line `if ...; then ...; fi` only incremented depth (matched via
  the whole-line "opens" check) and never saw its own `fi` close it,
  permanently disabling recognition of every later unconditional source
  in the file. Two-line function definitions (`fn()` then `{` on its own
  line) double-incremented for the same reason.
- The existence-gated `&&` guard was fully anchored, so a guarded source
  followed by further `&&`-chained commands (`[ -f X ] && . X && export Y`)
  didn't match even though the guard still holds.
- Comment stripping only skipped whole-comment lines; a comment following
  a semicolon on the same line was still split into a "real" statement.
- Heredoc bodies were scanned as literal executable lines.

Declined the sixth (recognizing the Debian/Ubuntu nested
`if [ -n "$BASH_VERSION" ]; then if [ -f ... ]; then . ...; fi; fi`
template) for the same reason given in review round 12: the outer
condition is unverifiable without a real shell, and this resolver's
explicit, repeatedly-restated design boundary is to never trust an
unverifiable condition — falling back to the order-based pick (a
harmless duplicate block) is the intended safe behavior there, not a bug.

Verified with 6 new unit tests (47/47 passing) plus a standalone real-fs
script driving the actual resolveActiveShellProfile against a scratch
HOME for all seven round-13 scenarios (all pass). Full suite unchanged
at the pre-existing 30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(env): quote-aware statement splitting, subshells, dead code after return/exit, N-way || (review)

Round 14 review found six more genuine gaps in referencesCandidate, all
fixed:

- The `;`/`&&`/`||` splits were plain `String.split`, so a separator
  character inside a quoted argument (e.g. `printf '%s' 'x; source
  ~/.bashrc; y'`) was treated as a real statement boundary, inventing an
  executed source out of string data. Added splitTopLevel(), a small
  quote-aware splitter (tracks single/double-quote spans, skips
  separators inside them) used everywhere a naive split was previously
  used.
- `(...)` subshells weren't tracked as an unverified-block construct —
  a source inside one always runs, but its exports never reach the
  caller, so it must not count as reaching a candidate any more than an
  `if` body does. Added to opensUnverifiedBlock/closesUnverifiedBlock
  alongside the existing if/for/while/until/case/function handling.
- An unconditional, top-level `return`/`exit` ends the file's control
  flow right there; anything textually after it was still being scanned
  as if reachable. Added a `halted` flag set on a bare return/exit
  statement, gating everything after it for the rest of the scan.
- `sourceOf` required the source's argument to be the entire statement,
  so `. "$HOME/.bashrc" 2>/dev/null` and `source ~/.bashrc extra_arg`
  (both valid, both really sourcing the target) went unrecognized.
  Relaxed to capture just the first argument and allow anything after
  it.
- The `||` fallback only handled exactly two operands — a three-way
  chain like `source ~/.profile || source ~/.bash_login || source
  ~/.bashrc` wasn't recognized at all, not even the always-attempted
  left side. Generalized to N operands: each one counts only when every
  operand before it is a recognized source whose target is verifiably
  missing from disk.
- Multiple heredocs opened by one command (`cat <<A <<B`) only tracked
  one terminator, so the second heredoc's body was scanned as real
  statements once the first terminator was seen. heredocEnd is now a
  queue of terminators consumed in order.

Declined the seventh finding again (the Debian/Ubuntu nested `if
[ -n "$BASH_VERSION" ]` template) for the same reason given in rounds 12
and 13: the outer condition is unverifiable without a real shell, and
this resolver's explicit design boundary is to never trust one — the
order-based-pick fallback (a harmless duplicate block) is the intended
safe outcome there, not a bug.

Verified with 8 new unit tests (55/55 passing) plus a standalone real-fs
script driving the actual built resolveActiveShellProfile for all nine
round-14 scenarios (all pass, including confirming the Debian pushback
case is unchanged). Full suite unchanged at the pre-existing
30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(env): replace the growing ad-hoc shell scanner with a narrow, closed recognizer (review)

Round 15 review found nine more genuine parsing bugs, most of them direct
consequences of the general-purpose statement/operand machinery added in
rounds 13-14 (quote/escape-aware `;`/`&&`/`||` splitting, N-way `||`
chains, trailing-argument tolerance on `source`). It also included a
meta-finding, correctly: this had grown into a large, incomplete ad-hoc
shell parser for what should be a narrow forwarding-detection case, and
each round's fix was mostly patching bugs the previous round's own
machinery introduced. Full shell parsing is undecidable without a real
shell; chasing it one adversarial regex at a time was never going to
finish, and it was already producing real regressions (the round-14
`sourceOf` relaxation meant to recognize valid trailing arguments also
started recognizing `source ~/.bashrc | cat` and `source ~/.bashrc &`,
both of which run in a subshell and never actually reach the caller).

Replaced `referencesCandidate`'s open-ended grammar with a closed
recognizer of exactly two forms, each matched as a complete logical line:

- bare unconditional `. REF` / `source REF`
- the self-referential existence guard `test -f REF && . REF` /
  `[ -f REF ] && . REF` — the literal line Git for Windows itself
  generates

Deleted entirely: quote/escape-aware statement splitting (no longer
needed — nothing is split into statements anymore), `||` fallback
handling (both the original two-operand and round 14's N-way
generalization), trailing-argument/redirection tolerance on `source`
(the source of the pipe/background regression above), and
comment-stripping (unnecessary now — a line with anything extra on it
simply fails the exact-match check, which is a large part of why the
statement machinery could be deleted rather than just patched again).

Kept, since dropping them would reopen a real false-positive risk rather
than just narrow scope: block-depth tracking for
`if`/`for`/`while`/`until`/`case`/`select`/function/subshell/brace-group
(content inside is either conditional or non-propagating, generalized
this round with `select` and a fixed one-liner if/for/while/until/case
collapse so a self-contained one-liner doesn't corrupt depth tracking for
the rest of the file), heredoc body skipping (fixed three real bugs in
it: a `<<<` here-string was mistaken for a `<<` heredoc and swallowed the
rest of the file; a non-`-` heredoc's terminator was compared with
`.trim()`, letting an indented look-alike end it early; the delimiter
charset was `\w` only, missing real delimiters like `END-CONFIG`), a
`return`/`exit` halt flag (cheap, and the alternative — textually dead
code after an unconditional exit still being scanned — is a genuine
false positive, however unlikely the pattern), and joining a line ending
in `\`, `&&`, or `||` onto the next (real, unremarkable shell
continuation with no backslash required for the latter two — the risk
this closes isn't hypothetical: an unrelated trailing `&&` followed by an
unconditional-looking `source` on the next line is exactly the shape
that would have produced a false "reachable").

Declined the Debian/Ubuntu nested-`if` finding a fourth time, unchanged
from rounds 12-14: the outer `$BASH_VERSION` check is unverifiable
without a real shell, and this resolver's explicit boundary is that an
unverifiable condition is never trusted. The `||`-existence-only pushback
from round 14 is now moot — `||` isn't recognized in any form.

Net change to shell-profile.ts is negative (-244/+something smaller)
despite fixing more bugs than it added, confirming this is a real
simplification rather than another round of patches. Verified with an
updated unit test suite (61/61 passing — six tests for now-out-of-scope
behavior replaced with tests confirming the safe fallback, new tests
added for every round-15 fix that was kept) and a standalone real-fs
script against the actual built resolver covering all twelve round-15
scenarios (all pass, including the real motivating Git-for-Windows case
and the still-declined Debian template). Full suite unchanged at the
pre-existing 30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
jeff-r2026 pushed a commit that referenced this pull request Sep 23, 2026
* feat(pi): add Pi Coding Agent integration

Squash-rebased onto the latest upstream/main to resolve the PR's merge
conflict (main gained #693/#685/#694/#691/#681/#680/#666 since this branch
forked). This combines all commits from the PR into one, applied cleanly on
top of the new base — no functional changes from the previously reviewed
state.

The only real conflict was in src/__tests__/uninstall.test.ts, where diff3
split a test mid-body because of the repeated `});` boilerplate around it;
resolved by keeping both sides' new tests intact, in full.

* fix(pi): gate agent-hook files on the same per-slug marker ownership check

applyPiAgentHook()/removePiAgentHook() wrote and deleted teamai-agent-<slug>.ts
purely by path, with no ownership check — the same class of bug already
fixed for the main teamai-hooks.ts file, but never extended to the per-slug
HTTP agent-hook files. A user-authored file at that conventional path could
be silently overwritten on sync or deleted on uninstall.

Adds hasPiAgentHook(slug), mirroring hasPiHooks: injection now skips (with a
warning) instead of overwriting a same-named file without the
`[teamai] agent hook [<slug>]` marker, and removal skips instead of
deleting one. uninstall.ts's discovery scan now derives each file's slug and
checks the same marker before scheduling it for removal, instead of
matching by filename prefix alone.

* fix(pi): fail install_hook_rule instead of silently acking a skipped Pi agent hook

applyPiAgentHook warned and returned normally when the requested event has no
Pi equivalent or a same-named extension file exists without the TeamAI
marker. The caller in local-agent.ts wrote the manifest entry and acked
success regardless, so the server and local state believed the hook was
installed even though the file was never touched. Throw in both cases so the
existing install_hook_rule error path acks failure instead.

Also document the known limitation (shared with the OMP adapter) that a
scoped Pi uninstall is not durable across multiple projects on the same
machine, since the extension is one machine-wide file and hook dispatch has
no per-project exclusion check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants