Keep T3 Code running. Select the computers you want to set up above.
@@ -648,13 +648,12 @@ function PairingForm({
-
- On the computer with your code, start T3 Code or run{" "}
- npx t3 serve, then run:
-
-
+ On the computer with your code:
+
- On a tailnet, run npx t3 pair --tailscale instead.
+ Start T3 Code or run t3 serve there before{" "}
+ t3 pair. On a tailnet, run{" "}
+ t3 pair --tailscale instead.
diff --git a/docs/operations/observability.md b/docs/operations/observability.md
index 3edebc634cd7..18063b8e9c6e 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -156,7 +156,7 @@ You do not need any extra env vars. Just run the app normally and inspect `serve
Examples:
```bash
-npx t3
+t3
```
```bash
@@ -191,7 +191,7 @@ Default Grafana login:
For endpoint settings and startup precedence, see the
[user telemetry guide](../user/telemetry.md#export-diagnostics-to-your-own-receiver).
-In a worktree, use `vp run dev` instead of `npx t3` to keep state isolated.
+In a worktree, use `vp run dev` instead of `t3` to keep state isolated.
```bash
export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces
@@ -212,7 +212,7 @@ export T3CODE_TRACE_TIMING_ENABLED=true
CLI:
```bash
-npx t3
+t3
```
Monorepo web/server dev:
diff --git a/docs/user/faq.md b/docs/user/faq.md
index 3f5c1fcf8898..8d34de5e2ea4 100644
--- a/docs/user/faq.md
+++ b/docs/user/faq.md
@@ -13,7 +13,7 @@ Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigra
We were inspired by the Codex desktop app, Conductor, Claude Desktop, and Cursor Glass, but none met our bar. What you get:
- Bring your own sub. Claude, Codex, and whatever else you already have installed.
-- Best in class remote. Run `npx t3 up` on any box and drive it from anywhere.
+- Best in class remote. Run `t3 up` on any box and drive it from anywhere.
- Best in class mobile app.
- Truly 100% open source. If we go the wrong direction, fork it.
@@ -37,7 +37,7 @@ T3 Code drives Claude through Anthropic's Agent SDK. I confirmed it's a real bug
## How do I use T3 Code remotely?
-Your agents run wherever T3 Code runs: your desktop, a server, a VPS. You connect to it from anywhere. I do this constantly. Less than 5% of my prompts run on my laptop. Run `npx t3 up` on that machine and you get really good remote support. T3 Connect is the easy path. LAN pairing and Tailscale work too, and [app.t3.codes](https://app.t3.codes) is the web client. [Remote access](./remote-access.md)
+Your agents run wherever T3 Code runs: your desktop, a server, a VPS. You connect to it from anywhere. I do this constantly. Less than 5% of my prompts run on my laptop. Run `t3 up` on that machine and you get really good remote support. T3 Connect is the easy path. LAN pairing and Tailscale work too, and [app.t3.codes](https://app.t3.codes) is the web client. [Remote access](./remote-access.md)
## How do I use T3 Code on my phone?
diff --git a/docs/user/install.md b/docs/user/install.md
index 4689a2965889..d26e1b13d4dc 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -20,9 +20,12 @@ On Windows, in PowerShell:
irm https://t3.codes/install.ps1 | iex
```
-This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found`
-afterwards, that directory is not on your `PATH` yet; the installer prints the
-line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or
+This installs the `t3` binary to `~/.local/bin`. If that folder is not on your
+`PATH` yet, the installer says so and, for zsh, bash on Linux, and fish, prints
+a command to add it. On Windows, `t3` works in the same PowerShell window right
+away; add the folder to your `PATH` for new windows. If a `t3` that the
+installer or the desktop app did not create is already there, it leaves that
+one alone and stops. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or
`T3CODE_VERSION` to pin an exact version.
| Task | Command |
@@ -44,7 +47,11 @@ instead. Stop it before starting a replacement, or use a different `--base-dir`
for an independent server.
To try T3 Code once without installing it, run `npx t3@latest` instead (needs
-Node.js for `npx`).
+Node.js for `npx`). It offers to install the `t3` command first. If you say no,
+it asks again at the next minor release, and `npx t3 install` installs it later.
+`npx t3` always runs the version npm picked, even when a `t3` is installed. On
+the nightly train, run your installed `t3` (for example `t3 connect`), not
+`npx t3`, which runs the latest stable release.
### Intel Macs
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index 649dfbca6378..75910d9405c3 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -288,9 +288,8 @@ command to run on the host:
sudo t3 browser setup
```
-The server shows the exact line for how you started it, such as
-`sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only
-for your user. Where `t3` is not on your `PATH`, such as with only the
+The server shows the exact line for how you started it, and keeps your `PATH`
+when you started it with `npx` and Node is installed only for your user. Where `t3` is not on your `PATH`, such as with only the
desktop app installed, it names the full path of the app's own `t3` instead. It allows Chrome's sandbox with an AppArmor profile and installs
any missing libraries and system fonts with apt; pages that load without text
can mean the host has no fonts. It is safe to run again. Without `sudo`, it only
@@ -379,11 +378,11 @@ expires.
To choose a token's permissions, pass `--scope` once for each scope you want:
```sh
-npx t3 pair --scope orchestration:read --scope relay:read
+t3 pair --scope orchestration:read --scope relay:read
```
The selected scopes replace the default permissions. The same option works with
-`npx t3 auth pairing create` and `npx t3 auth session issue`; each command's
+`t3 auth pairing create` and `t3 auth session issue`; each command's
`--help` lists the available scopes. Without `--scope`, pairing tokens and issued
bearer sessions get every permission, including managing access and T3 Connect.
A terminal or a full-access agent on the host can reach those as well, so a
diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md
index 2d4b589d922f..6e3a9dc12f56 100644
--- a/docs/user/welcome-wizard.md
+++ b/docs/user/welcome-wizard.md
@@ -4,8 +4,8 @@ T3 Code shows a setup flow when you open a new installation. Existing
workspaces skip this flow.
The hosted app at app.t3.codes does not open setup. With no computer connected,
-it asks you to sign in to T3 Connect, then to run `npx t3 connect` on your
-computer. From home, it adds compatible computers linked to your account and
+it asks you to sign in to T3 Connect, then to install T3 Code and run
+`t3 connect` on your computer. From home, it adds compatible computers linked to your account and
opens the app.
Close setup while no import is running to go straight to the app. You can connect
diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts
index 16dc69ab14c0..d4e831f2af4b 100644
--- a/packages/shared/src/shell.test.ts
+++ b/packages/shared/src/shell.test.ts
@@ -18,6 +18,8 @@ import { describe, expect, it, vi } from "vite-plus/test";
import {
applyShellEnvironment,
+ pathSetup,
+ pathSetupCommand,
CommandAvailability,
CommandResolutionCache,
type CommandAvailabilityChecker,
@@ -1160,3 +1162,57 @@ describe("desktop shell environment updates", () => {
},
);
});
+
+describe("pathSetupCommand", () => {
+ // Undefined where a pasted line could break things: Windows (it would flatten %VAR% entries in
+ // the registry PATH), macOS bash (several startup files), other shells, and unsafe paths.
+ it.each([
+ [
+ "/home/theo/.local/bin",
+ "/bin/bash",
+ "linux",
+ `printf '\\n%s\\n' 'export PATH="$PATH:/home/theo/.local/bin"' >> ~/.bashrc`,
+ ],
+ [
+ "/Users/theo/.local/bin",
+ "/usr/bin/fish",
+ "linux",
+ `mkdir -p ~/.config/fish && printf '\\n%s\\n' 'fish_add_path --append --path $HOME/.local/bin' >> ~/.config/fish/config.fish`,
+ ],
+ [
+ "/opt/t3/bin",
+ "/bin/zsh",
+ "linux",
+ `printf '\\n%s\\n' 'export PATH="$PATH:/opt/t3/bin"' >> ~/.zshrc`,
+ ],
+ ["/Users/theo/.local/bin", "/bin/tcsh", "darwin", undefined],
+ ["/Users/theo/.local/bin", "/bin/bash", "darwin", undefined],
+ ["C:\\Users\\theo\\.local\\bin", "pwsh", "win32", undefined],
+ ["/Users/theo/it's here", "/bin/zsh", "darwin", undefined],
+ ["/opt/a$PWD/bin", "/bin/zsh", "linux", undefined],
+ // Relative: PATH would follow the working directory into any checkout's `bin`.
+ ["bin", "/bin/zsh", "linux", undefined],
+ ] as const)("%s with %s on %s", (directory, shell, platform, expected) => {
+ expect(pathSetupCommand({ directory, home: "/Users/theo", shell, platform })).toBe(expected);
+ });
+
+ it("writes the startup file fish and zsh read, under XDG_CONFIG_HOME or ZDOTDIR", () => {
+ const setup = { directory: "/opt/t3/bin", home: "/home/theo", shell: "/usr/bin/fish" } as const;
+ expect(pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/cfg" })?.file).toBe(
+ "/home/theo/cfg/fish/config.fish",
+ );
+ expect(pathSetup({ ...setup, platform: "linux" })?.file).toBe("~/.config/fish/config.fish");
+ // zsh reads .zshrc from ZDOTDIR when it is set.
+ expect(
+ pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/.zsh" })
+ ?.file,
+ ).toBe("/Users/theo/.zsh/.zshrc");
+ // A set folder that cannot be written safely means no setup, never the unread default.
+ expect(
+ pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/my zsh" }),
+ ).toBeUndefined();
+ expect(
+ pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/my cfg" }),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts
index ad30c152a07c..cee692c2dd38 100644
--- a/packages/shared/src/shell.ts
+++ b/packages/shared/src/shell.ts
@@ -1030,3 +1030,65 @@ export function applyShellEnvironment(
else env[key] = value;
}
}
+
+interface PathSetupInput {
+ readonly directory: string;
+ readonly home: string;
+ readonly shell: string | undefined;
+ readonly platform: NodeJS.Platform;
+ /** `XDG_CONFIG_HOME`, where fish reads its startup file when set. */
+ readonly configHome?: string | undefined;
+ /** `ZDOTDIR`, where zsh reads `.zshrc` when set. */
+ readonly zdotdir?: string | undefined;
+}
+
+/**
+ * The startup file (`~/...`, or absolute under `configHome`) and line that put `directory` last on
+ * PATH for new zsh, bash, or fish terminals, so it never shadows another `t3`. Undefined on Windows (writing PATH there would flatten the registry's
+ * `%VAR%` entries), for other shells, macOS bash, or unsafe paths.
+ */
+export function pathSetup(
+ input: PathSetupInput,
+): { readonly file: string; readonly line: string } | undefined {
+ // A relative folder on PATH would follow the working directory into any checkout.
+ if (input.platform === "win32" || !input.directory.startsWith("/")) return undefined;
+ const underHome = input.directory.startsWith(`${input.home}/`);
+ const rest = underHome ? input.directory.slice(input.home.length) : input.directory;
+ if (!/^[\w./-]+$/.test(rest)) return undefined;
+ const directory = underHome ? `$HOME${rest}` : rest;
+ const shell = input.shell?.split("/").pop();
+ // A set folder the shell reads from replaces the default; one unsafe to write means no setup,
+ // since the default file would never be read.
+ const readFrom = (value: string | undefined, fallback: string) =>
+ !value ? fallback : value.startsWith("/") && /^[\w./-]+$/.test(value) ? value : undefined;
+ if (shell === "fish") {
+ const configHome = readFrom(input.configHome, "~/.config");
+ if (configHome === undefined) return undefined;
+ // --path changes $PATH itself; fish_user_paths would go ahead of the system folders.
+ return {
+ file: `${configHome}/fish/config.fish`,
+ line: `fish_add_path --append --path ${directory}`,
+ };
+ }
+ // macOS bash reads only the first of .bash_profile, .bash_login, and .profile; no file is safe.
+ const zdotdir = readFrom(input.zdotdir, "~");
+ const file =
+ shell === "zsh"
+ ? zdotdir && `${zdotdir}/.zshrc`
+ : shell === "bash" && input.platform !== "darwin"
+ ? "~/.bashrc"
+ : undefined;
+ return file === undefined ? undefined : { file, line: `export PATH="$PATH:${directory}"` };
+}
+
+/** A command to paste that does what {@link pathSetup} describes. */
+export function pathSetupCommand(input: PathSetupInput): string | undefined {
+ const setup = pathSetup(input);
+ if (!setup) return undefined;
+ // The leading newline keeps the line out of a final comment with no newline after it.
+ const append = `printf '\\n%s\\n' '${setup.line}' >> ${setup.file}`;
+ // fish's config folder may not exist yet; `&&` works in fish 3 too.
+ return setup.file.endsWith(".fish")
+ ? `mkdir -p ${setup.file.slice(0, setup.file.lastIndexOf("/"))} && ${append}`
+ : append;
+}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index fe452674c203..7816ae60e9f6 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -20,6 +20,8 @@ $repo = "pingdotgg/t3code"
$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" }
$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" }
+# Absolute, so PATH never gets a folder that changes with the working directory.
+$binDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($binDir)
function Fail([string] $message) {
Write-Error "t3 install: $message"
@@ -216,19 +218,50 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
Step "Setting up the t3 command..."
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$shim = Join-Path $binDir "t3.cmd"
+# Replace only a shim that runs a version in this home; never the desktop app's or another t3.
+if (Test-Path -LiteralPath $shim) {
+ $existingTarget = ""
+ # The shim is UTF-8 without a BOM; Windows PowerShell 5.1 reads files as ANSI by default.
+ # Only a shim exactly as written here counts; a user's wrapper that also runs t3.exe does not.
+ $existing = [System.IO.File]::ReadAllText($shim, [System.Text.Encoding]::UTF8)
+ if ($existing -match '^@echo off\r?\n"([^"]+)" %\*\s*$') { $existingTarget = $Matches[1] }
+ # Full paths, so a target like versions\..\other cannot pass as ours.
+ $ownedPrefix = [System.IO.Path]::GetFullPath($versionsDir).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
+ if ($existingTarget) { $existingTarget = [System.IO.Path]::GetFullPath($existingTarget) }
+ # Exactly