diff --git a/.github/workflows/installation.yml b/.github/workflows/installation.yml
new file mode 100644
index 0000000000..8d004cc417
--- /dev/null
+++ b/.github/workflows/installation.yml
@@ -0,0 +1,65 @@
+name: Script installation
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: installation-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ installation:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ steps:
+ - name: Check out
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Set up the environment
+ uses: ./.github/actions/setup-python-env
+
+ - name: Build the installation fixture
+ shell: bash
+ env:
+ # Qualify this commit using release-shaped metadata, without publishing it.
+ SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0"
+ run: uv build --wheel --out-dir dist/installation
+
+ - name: Check shared installer behavior and source resolution
+ shell: bash
+ run: >-
+ uv run --no-sync pytest -q tests/test_installation_contract.py tests/native/test_install_sources.py
+ --junitxml "$RUNNER_TEMP/installation-contract.xml"
+ --basetemp "$RUNNER_TEMP/installation-contract-tests"
+
+ - name: Exercise bootstrap, version selection, profiles, and persistent memory
+ shell: bash
+ env:
+ POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/installation/powercontext-1.2.0-py3-none-any.whl
+ run: >-
+ uv run --no-sync pytest -q tests/native/test_installation.py
+ --junitxml "$RUNNER_TEMP/installation.xml"
+ --basetemp "$RUNNER_TEMP/installation-tests"
+
+ - name: Upload installation diagnostics after failure
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: installation-${{ matrix.os }}
+ path: |
+ ${{ runner.temp }}/installation.xml
+ ${{ runner.temp }}/installation-contract.xml
+ ${{ runner.temp }}/installation-tests/**/*.log
+ if-no-files-found: ignore
diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml
index b4fae40996..ac778d2950 100644
--- a/.github/workflows/native-service.yml
+++ b/.github/workflows/native-service.yml
@@ -44,6 +44,8 @@ jobs:
echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV"
- name: Install the built distribution in a fresh virtual environment
+ env:
+ SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0"
shell: bash
run: |
uv build --wheel
@@ -58,6 +60,16 @@ jobs:
--basetemp "$RUNNER_TEMP/powercontext-native-tests"
tests/native/test_personal_service_lifecycle.py
+ - name: Exercise script installation and service reconciliation
+ env:
+ POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/powercontext-1.2.0-py3-none-any.whl
+ shell: bash
+ run: >-
+ .native-venv/bin/python -m pytest -q
+ --junitxml "$RUNNER_TEMP/installer-service.xml"
+ --basetemp "$RUNNER_TEMP/installer-service-tests"
+ tests/native/test_service_installation.py
+
- name: Report Linux lifecycle failures
if: failure()
run: >-
@@ -76,7 +88,10 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: linux-native-service-logs
- path: ${{ runner.temp }}/powercontext-native-tests/**/logs/*
+ path: |
+ ${{ runner.temp }}/powercontext-native-tests/**/logs/*
+ ${{ runner.temp }}/installer-service-tests/**/*.log
+ ${{ runner.temp }}/installer-service.xml
if-no-files-found: ignore
macos-launchagent:
@@ -97,6 +112,8 @@ jobs:
uses: ./.github/actions/setup-python-env
- name: Install the built distribution in a fresh virtual environment
+ env:
+ SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0"
shell: bash
run: |
uv build --wheel
@@ -113,6 +130,16 @@ jobs:
--basetemp "$RUNNER_TEMP/powercontext-native-tests"
tests/native/test_personal_service_lifecycle.py
+ - name: Exercise script installation and service reconciliation
+ env:
+ POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/powercontext-1.2.0-py3-none-any.whl
+ shell: bash
+ run: >-
+ .native-venv/bin/python -m pytest -q
+ --junitxml "$RUNNER_TEMP/installer-service.xml"
+ --basetemp "$RUNNER_TEMP/installer-service-tests"
+ tests/native/test_service_installation.py
+
- name: Report macOS lifecycle failures
if: failure()
run: >-
@@ -129,7 +156,10 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: macos-native-service-logs
- path: ${{ runner.temp }}/powercontext-native-tests/**/logs/*
+ path: |
+ ${{ runner.temp }}/powercontext-native-tests/**/logs/*
+ ${{ runner.temp }}/installer-service-tests/**/*.log
+ ${{ runner.temp }}/installer-service.xml
if-no-files-found: ignore
windows-task-scheduler:
diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md
index 84d74c205d..d79a351e68 100644
--- a/docs/en/docs/get-started/install-and-run.md
+++ b/docs/en/docs/get-started/install-and-run.md
@@ -22,6 +22,151 @@ Windows CLI, Server, and personal-service support is experimental. Each Agent Ho
requirements. Examples using Bash syntax require a Bash environment and cannot be pasted directly into PowerShell.
Embedded seekDB is unavailable on Windows.
+## Install with the recommended script
+
+The script installs the CLI and local Server in an isolated uv tool environment. It reuses compatible uv/Python,
+or installs uv and Python 3.12 in user-owned directories. It does not need administrator privileges.
+
+macOS or Linux (Bash; `curl` or `wget` is required):
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash
+```
+
+Windows (PowerShell 5.1 or newer):
+
+```powershell
+powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))"
+```
+
+Apply the PATH command printed at completion before running `powercontext` in your terminal. The installer does not
+edit shell startup files or the persistent Windows PATH. `UV_INSTALL_DIR` selects where a missing uv is installed;
+`UV_TOOL_BIN_DIR` selects the PowerContext executable directory.
+
+Installation runs unattended by default. Pass `--host codex` (repeatable) to install selected Agent integrations.
+Only Agent setup needs Git and each host's prerequisites. `--no-hosts` remains an optional way to state the default;
+it cannot be combined with `--host`. The [capability matrix](../integrations/capabilities.md) describes host support.
+
+For an existing remote Server, add `--profile client` to install only the CLI and Client dependencies. The default
+`--profile local` includes the local Server. Neither profile starts a Server, registers a service, or overwrites
+configuration or data. For a local installation, continue with `powercontext config init` and [Quick Start](quickstart.md).
+Client-only installations use endpoint settings in the [remote connection guide](../operate/connect-remote-server.md).
+
+## Configure and install a personal service
+
+For personal macOS/Linux use, the recommended lifecycle is a native current-user service. The installer can
+explicitly compose configuration and service registration:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --configure --service --env-file "$HOME/.config/powercontext/powercontext.env"
+```
+
+`--configure` opens the existing wizard through the controlling terminal and requires `--env-file`. Save the file
+to continue; cancellation stops service and host setup. Choose loopback binding for a personal service. Linux needs
+an available `systemd --user` manager. In a non-interactive environment, provide an existing protected file instead:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --service --env-file /path/to/powercontext.env
+```
+
+The installer validates that file, runs `service install`, checks `service status`, and runs `doctor --env-file`
+against the Server settings in the same file. An explicit `--host` also receives this file and the installed release
+ref. Client-only installations reject `--configure` and `--service` before installation. `--env-file` alone can supply
+connection settings to an explicitly selected host without installing a local service.
+
+Windows supports the same explicit options experimentally; `--service` opts into login auto-start. Run `--configure`
+in an interactive console. Protect an existing file using the ACL steps in [Deploy the Server](../operate/deploy-server.md).
+
+These options need an installed release providing `config init --require-write` and `doctor --env-file`. If the selected
+release lacks them, the installer reports the failed stage and retains the Runtime; use a compatible release or the
+separate configuration/service commands. Runtime installation, saved configuration, service registration, and Server
+readiness are separate outcomes. `degraded` diagnostics return nonzero. A failed post-install stage does not roll back
+packages, delete configuration, or remove an already committed service registration.
+
+After any file edit, including writing returned Scope IDs, reconcile using the original file:
+
+```bash
+powercontext service install --env-file /path/to/powercontext.env
+powercontext service status
+powercontext doctor --env-file /path/to/powercontext.env
+```
+
+`doctor --env-file` gives the file authority over shell defaults. A Server file selects its listener; a client-only
+file selects its Client URL. `--server-url` can explicitly override the diagnostic target. Generated next-step instructions
+cover Scope creation and Agent acceptance; a running service does not establish an Agent workflow.
+
+## Installer version selection
+
+The default `--version latest` installs or upgrades to the newest stable release available from the selected index
+and compatible with the chosen Python. It excludes prereleases. `--version` accepts an exact release, including an
+explicit prerelease such as `1.3.0rc1`; it never substitutes a different version if that release is unavailable.
+
+For example, install PowerContext 1.4.0 with its Codex integration:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.4.0 --host codex
+```
+
+This selects package `1.4.0` and Git tag `powercontext-v1.4.0`. With `latest`, the installer reads the installed CLI's
+version and uses its matching tag; it never uses the moving `master` branch for host setup. To add an integration later:
+
+```bash
+powercontext setup codex --ref "powercontext-v$(powercontext --version)"
+```
+
+Use `powercontext setup --ref "powercontext-v$(powercontext --version)"` for an interactive host picker.
+
+In PowerShell, the same double-quoted expression works. An integration failure leaves the installed Runtime usable
+and exits with an error and a retry instruction. Installation success does not establish Server readiness or host
+workflow correctness; use the checks below and the integration's own guide.
+
+The installer verifies the installed version and advertised commands before reporting success. A package can install
+successfully while command verification fails; in that case the old executable may already have been replaced. Review
+the named command error and rerun with a known working `--version`. The installer does not roll package files back.
+
+## Retry dependency downloads with a mirror
+
+Package indexes, uv binaries, and Python distributions are separate downloads. Changing the package index does not
+change the uv or Python download location.
+
+| Download | Explicit setting | Automatic China source | Global source |
+| --- | --- | --- | --- |
+| PowerContext and Python packages | `--index-url URL`, uv index environment variables or `uv.toml` | Tsinghua PyPI mirror | PyPI |
+| uv installer | `POWERCONTEXT_UV_INSTALLER_URL` | USTC uv release mirror | Astral installer |
+| uv binaries | `UV_DOWNLOAD_URL` or `UV_INSTALLER_GITHUB_BASE_URL` | USTC uv release mirror | Astral release channels |
+| Python distributions | `UV_PYTHON_INSTALL_MIRROR` | NJU python-build-standalone mirror | uv default channels |
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --region cn
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --index-url https://pypi.org/simple
+```
+
+`--region auto|cn|global` overrides `POWERCONTEXT_INSTALL_REGION`. Auto selection uses a named local timezone, then the
+locale territory, then global; it makes no geolocation request. An unavailable automatic mirror can fall back to the
+official source. A reachable China package mirror remains selected even if it lacks the requested release; uv reports
+resolution or artifact failures without retrying against another index. Use `--region global` or an explicit source
+to select another index. Explicit sources never fall back automatically.
+A mirror may lag PyPI: `latest` means the newest compatible stable release on the chosen index.
+
+Existing uv index settings and configuration files take precedence over automatic package mirrors. An explicit
+`--index-url` overrides the default index only; additional uv indexes keep their priority. Private index credentials
+belong in uv's authentication configuration, not in script arguments. uv does not read `PIP_INDEX_URL` or
+`PIP_EXTRA_INDEX_URL`. Existing Python mirror settings and `uv.toml` also suppress automatic Python mirror selection.
+`UV_ASTRAL_MIRROR_URL` is passed through for uv versions that support it. These settings are scoped to the installation;
+your persistent package-manager configuration is unchanged. An existing uv is reused without upgrading it.
+
+To inspect the installer or pass several options in PowerShell, save it first:
+
+```powershell
+irm https://powercontext.oceanbase.io/install.ps1 -OutFile install.ps1
+powershell -ExecutionPolicy Bypass -File .\install.ps1 --region cn --version 1.4.0
+```
+
+Cached reinstallation with `UV_OFFLINE=1` can work when uv, compatible Python, and all required packages are already
+present. This is not an offline distribution bundle; missing downloads fail explicitly.
+
## Choose a version
These instructions use PowerContext 1.4.0. Keep the package and Agent integration on
diff --git a/docs/en/rfcs/1892-script-installation.md b/docs/en/rfcs/1892-script-installation.md
new file mode 100644
index 0000000000..5dad92b3ae
--- /dev/null
+++ b/docs/en/rfcs/1892-script-installation.md
@@ -0,0 +1,304 @@
+---
+title: Script Installation
+description: Install the latest or an exact release with Bash and PowerShell, independent package and runtime mirrors, and explicit Agent selection.
+---
+
+- Proposal Name: `script_installation`
+- Start Date: 2026-10-09
+- RFC PR: [oceanbase/powercontext#1892](https://github.com/oceanbase/powercontext/pull/1892)
+- Related RFCs: [RFC 1733](1733-usability-and-agent-workflows.md), [RFC 1299](1299_local_server_availability_and_service_installation.md)
+
+# Summary
+
+Provide distribution-owned Bash and PowerShell entry points that install PowerContext through uv. The default
+release is the latest stable version from the selected package index; `--version` selects an exact release.
+The scripts provision missing uv and Python, install the selected Runtime profile, and optionally invoke the
+existing Agent integration adapters with a tag matching the installed Runtime.
+
+Installation, configuration, diagnostics, and service operation have separate responsibilities. Installing software
+does not configure inference providers, start a Server, or register a persistent service by default. Explicit
+`--configure` and `--service` compose those existing CLI operations using one selected `--env-file`. The scripts and their
+installation guide are the first recommended path in the READMEs and Quick Start.
+
+# Motivation
+
+A normal installation should not require users to coordinate Python discovery, uv installation, package extras,
+package indexes, and integration Git refs. Package downloads, uv binaries, and Python distributions also use different
+sources: changing the PyPI index alone cannot fix an inaccessible Python download.
+
+Installation places a versioned component and registers explicitly selected integrations. Configuration controls
+mutable values such as Server URL, Scope, and capture policy. Diagnostics observe the resulting environment.
+Keeping these responsibilities distinct lets installation improve independently of configuration storage, shared
+Hooks, an operations tool, or service lifecycle changes.
+
+The intended outcome is one installation entry point per shell, reuse of existing dependencies, predictable version
+selection, explicit host selection, recoverable partial failures, and verification through the installed product.
+
+# Guide-level explanation
+
+## Install a Runtime profile
+
+On macOS or Linux:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash
+```
+
+On Windows, using PowerShell 5.1 or newer:
+
+```powershell
+powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))"
+```
+
+`local`, the default profile, installs CLI, Client, and local Server dependencies. `--profile client` installs CLI and
+Client dependencies for an existing Server. Profile and host selection are independent: choosing a database role does
+not select an Agent host, and selecting Codex does not change the Runtime profile.
+
+The installer reuses uv and compatible system or uv-managed Python. Otherwise it provisions uv and Python 3.12 in
+user-owned locations. It prints the actual Runtime version, profile, executable directories, PATH command, and next
+configuration command. It does not change persistent PATH settings. Apply the printed PATH command before continuing.
+
+## Select releases and integrations
+
+`--version latest` is the default. Repeating it checks for newer stable releases on the configured index. For a
+repeatable package selection, use an exact version:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.2.0 --host codex
+```
+
+Installation runs unattended by default, without Git or a host picker. `--host` is repeatable and opts into integration
+setup; selected hosts require Git and their own prerequisites. `--no-hosts` explicitly states the default and cannot
+be combined with `--host`. For an interactive picker after installation, run
+`powercontext setup --ref "powercontext-v$(powercontext --version)"`.
+
+The installed CLI supplies the exact version for `powercontext-vVERSION`. Host setup never resolves `latest` again
+and never defaults to `master`. If a host installation fails, the Runtime remains installed and the script returns
+nonzero with instructions to retry setup at that tag. Each adapter supplies its existing host-specific result.
+
+For personal macOS/Linux, configure a protected file, then run `powercontext service install --env-file .env`,
+`powercontext service status`, and `powercontext doctor --env-file .env`. The native user manager owns the Server lifecycle.
+Development, debugging, temporary use, and unavailable managers keep the foreground `server run` path.
+Client-only installations follow the remote connection guide. Service registration remains explicit.
+
+## Explicit configuration and personal service setup
+
+The installer supports three composable options:
+
+| Option | Contract |
+| --- | --- |
+| `--configure` | Run `config init --require-write --output PATH` through a controlling terminal |
+| `--service` | Validate the selected file, install the native user service, inspect status, and diagnose readiness |
+| `--env-file PATH` | Select the same explicit configuration for configuration, service, diagnostics, and host setup |
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --configure --service --env-file "$HOME/.config/powercontext/powercontext.env"
+```
+
+`--configure` and `--service` require the local profile and an explicit file. Unattended setup requires an existing
+file and omits `--configure`. Invalid combinations, a missing existing file, and unavailable interactive input fail
+before Runtime installation. The Bash wizard receives `/dev/tty` independently of the downloaded script's stdin;
+PowerShell requires an interactive console. Ordinary installation remains unattended.
+
+The configuration CLI exposes `--require-write` without changing its normal cancellation behavior. With this option,
+cancelled saving returns 130 and prevents service/host changes, including when a previous valid file remains present.
+Both wrappers report saved configuration, validation, service verification, and any later failure separately.
+
+Service setup delegates protected-file loading, loopback restrictions, manager support, registration ownership,
+reconciliation, and startup to the existing service layer. Windows remains experimental; the wrapper's explicit
+`--service` also opts into login startup. A registered service is not sufficient for full completion: `service status`
+must pass and `doctor --env-file` must report healthy readiness. Degraded readiness returns nonzero.
+
+`doctor --env-file` uses the strict Python environment loader and restores process settings afterwards. Server files
+select the configured listener, independently of Client URLs used for SSH forwarding or stale caller defaults.
+Client-only files select their Client URL; an explicit diagnostic `--server-url` takes priority. Shell does not source
+the file or interpret its contents. Host setup receives `setup --env-file PATH HOST...` at the installed release tag.
+
+The installed release must provide the added CLI options; an older release reports the failed stage and leaves the
+Runtime installed. Distribution publication must coordinate the package containing these options with the website.
+After any file edit, including Scope IDs, or an upgrade, users rerun `service install --env-file` with the original file.
+Generated configuration output and next-step instructions recommend this flow, including reconciliation after Scope
+creation. A post-install failure retains packages, saved files, and committed registrations for explicit recovery.
+
+## Choose download sources
+
+`--region auto|cn|global` overrides `POWERCONTEXT_INSTALL_REGION`. Automatic selection checks local named timezone,
+then locale territory, then uses global sources. It does not call a network location service.
+
+| Component | Global source | Automatic China source | Explicit control |
+| --- | --- | --- | --- |
+| PowerContext and dependencies | PyPI | Tsinghua PyPI mirror | `--index-url`, uv index settings and configuration |
+| uv installer and binaries | Astral channels | USTC release mirror | `POWERCONTEXT_UV_INSTALLER_URL`, `UV_DOWNLOAD_URL`, uv installer mirror variables |
+| Python | uv default channels | NJU python-build-standalone mirror | `UV_PYTHON_INSTALL_MIRROR`, uv Python download configuration |
+
+Existing uv configuration suppresses automatic package mirror selection. An explicit `--index-url` changes only the
+default index; additional indexes keep uv's priority rules. uv owns authentication and index resolution. Script URL
+arguments must use HTTPS and omit credentials; credentials belong in uv configuration. pip index environment variables
+are not uv configuration. Python and uv download overrides are independent of package indexes.
+
+# Reference-level explanation
+
+## Installation responsibilities
+
+The entry points are `website/public/install.sh` and `website/public/install.ps1`, served as static website assets.
+Their supported options and failure behavior are equivalent. They own bootstrap and sequencing; uv owns environment
+creation and package resolution, while existing `powercontext setup` adapters own integration installation.
+
+The effective installation inputs are:
+
+```text
+Package requirement + Runtime profile + explicit Host selection = installation operation
+```
+
+The package manager resolves the requirement before replacing the tool environment. Once installed, the CLI's exact
+version is verified and used for all host setup. This keeps one release coordinate for the operation without adding a
+second package resolver or introducing a release manifest dependency.
+
+The scripts preserve `powercontext setup`, `config`, `doctor`, and service commands. They do not move domain behavior
+into shell, add a standalone Python installer engine, or change Runtime/public HTTP APIs.
+
+## Version and profile semantics
+
+- `latest` passes an unpinned profile requirement to `uv tool install --upgrade --reinstall-package powercontext --prerelease disallow`. It means the
+ newest stable version compatible with the selected interpreter and configured sources, not necessarily the version
+ most recently uploaded to another mirror. The PowerContext package is reinstalled so an existing prerelease cannot bypass stable selection. Resolution failures
+ are reported without substituting another requirement.
+- An exact `X.Y.Z`, optionally followed by `aN`, `bN`, or `rcN`, uses `==VERSION`. Explicit prereleases are supported;
+ source refs, ranges, and URLs are not `--version` values. Releases before 0.1.0 are excluded.
+- `local` uses `powercontext[cli,server]`; `client` uses `powercontext[cli]`, whose dependencies include the Client.
+ Rerunning with a different profile replaces that tool's dependency set. Independently added extras must be managed
+ through the documented manual installation path.
+- The executable in `uv tool dir --bin` must report a release version. Exact requests must match it. All profiles
+ must expose CLI help and `capabilities --help`; `local` additionally requires
+ `config init --help` and `server run --help`. The script prints success only after these commands succeed.
+ These checks establish imports and command availability, not Server readiness or Agent workflow correctness.
+
+uv is bootstrapped at an installer-controlled version; that version is independent of the PowerContext release.
+An existing uv is reused rather than upgraded silently. Python discovery excludes virtual environments so a project
+venv cannot become an accidental installation prerequisite. If no compatible Python 3.11+ is found, uv provisions
+Python 3.12. uv remains responsible for platform, architecture, and wheel compatibility errors.
+
+## Mirror precedence and recovery
+
+User-specified uv/Python sources are preserved without fallback. For automatic China selection, an unavailable uv
+mirror installer falls back to Astral; failed mirrored uv artifacts may use official sources. Python's exact build
+URL is obtained from uv, and its mirrored artifact is checked before installation. If the automatic Python mirror
+fails, installation retries with uv's default channels.
+
+Automatic China package selection makes a bounded availability request to the PowerContext index page. An
+unreachable mirror falls back to PyPI before tool installation. A reachable mirror remains selected even if it lacks
+the requested release; uv reports that resolution failure. Global and explicit sources go directly to uv. The scripts
+never interpret index HTML, wheel filenames, encoded URLs, package compatibility, or authentication. They do not
+switch indexes after a tool installation attempt, which may already have changed installed files.
+
+Shell environment changes are local to the installer process; PowerShell restores temporary source variables in
+`finally`. Existing configuration files are neither parsed by shell nor rewritten. Only installer-owned temporary
+downloads are cleaned. `UV_INSTALL_DIR`, `UV_TOOL_DIR`, and `UV_TOOL_BIN_DIR` retain their location controls.
+
+## Persistence and compatibility
+
+Ordinary package installation does not read or rewrite `.env`, credentials, data directories, or database schemas,
+and does not stop or restart a service. Explicit `--configure` asks the CLI to save only the selected file; explicit
+`--service` asks the native layer to reconcile its owned registration and restart when its existing contract requires it. Users follow the existing upgrade and migration instructions when starting an updated
+Server. An exact-version retry may reuse cached packages; `latest` intentionally allows upgrades.
+
+`UV_OFFLINE=1` permits cached reinstallation only when uv, a compatible Python, and all dependencies are present.
+It disables installer source probes as well as uv networking. It does not promise a complete offline distribution.
+Prerequisites remain available after later failures. A resolution failure for an unavailable exact version preserves
+the existing tool. Once uv accepts a package, verification can fail after replacing the old executable: report
+"package installed, verification failed" and allow an explicit version retry, without claiming rollback. A later host
+setup failure preserves the verified Runtime. There is no cross-component rollback or repair of unrelated host state.
+
+Windows retains its experimental product status. Native acceptance runs on Linux, macOS, and Windows; host-specific
+support still comes from each integration's capability contract.
+
+## Executable contract and acceptance
+
+`tests/fixtures/installation/*.json` defines shared public inputs and expected outcomes for both shell adapters.
+`tests/test_installation_contract.py` executes those cases with real uv and small offline fixture wheels: host opt-in,
+profiles, exact-version verification, unavailable commands, and partial host failure. Cases observe installed
+capabilities, selected hosts, release refs and exit results. They do not fix internal function boundaries or call order.
+The same catalog runs on each native operating system; fixture host/service execution is not native service or Agent
+acceptance. POSIX controlling-terminal cases cover a piped configuration request and cancellation. Native service CI
+adds installed-script acceptance for persistent memory, environment-file reconciliation, and a stopped-service upgrade.
+
+`tests/native/test_installation.py` runs the actual shell installer, uv, installed PowerContext CLI, and HTTP Server.
+A wheel built from the tested commit uses release-shaped metadata and a direct local file constraint. That constraint
+selects the tested artifact; it does not establish an independently enforced checksum. Qualification includes:
+
+- missing uv/Python under global and China source selection, existing tools/configuration, and spaces/Unicode paths;
+- real stable/prerelease wheel resolution through a controlled package index, default upgrade, exact selection, and
+ preserving the installed version when a requested release is unavailable;
+- Client-only installation and profile changes, with no local Server state created by installation;
+- `.env` generation and validation, readiness, Memory remember/search, cached offline reinstall, and restart readback;
+- explicit index failure, conflicting host choices, unattended and piped Bash installation, and the downloaded
+ PowerShell scriptblock entry point.
+
+A separate CI matrix executes the suite on all three operating systems. Release reference checks preserve script
+`latest` defaults while updating explicit version examples. Website validation confirms documentation links and the
+static build. Syntax checks alone do not establish Windows or macOS installation support, and these tests do not
+claim that a real Agent host completes its capture/recall workflow.
+
+[Reproducible first-principles and ablation studies](https://github.com/PsiACE/powercontext/tree/feat/installation-contract/experiments/installation) pin upstream
+sources, installer baselines, uv versions, controlled inputs and observed results. They support these decisions:
+removing implicit host selection preserves explicit host failure semantics; a filename-based preflight rejects valid
+encoded package URLs; prerelease-only indexes require explicit stable policy; and an accepted package can still have
+an unusable entry point. They remain separate from maintained product acceptance and carry their own execution limits.
+
+# Drawbacks
+
+Bash and PowerShell duplicate some sequencing and source-selection policy. Automatic mirrors add operational
+availability dependencies. `latest` deliberately changes over time and is unsuitable for reproducible deployment
+without an exact version. Matching tags coordinate releases but do not independently prove host compatibility.
+
+# Rationale and alternatives
+
+A uv-only command is retained for users who already manage Python and uv; it cannot bootstrap a clean machine.
+Source installation is useful for development but is not the default release channel. Pinning the public script to
+a PowerContext version would leave new users on stale releases until the website is republished.
+
+A Python engine still needs a bootstrap on machines without Python. Generating the shell adapters would still require
+native behavioral tests and would add generator/versioning machinery. Shared conformance cases provide executable
+parity without another runtime asset. Introduce an engine or generator only when a concrete policy needs it.
+
+Immutable manifests and a second installation ledger are useful when distributing independently versioned raw
+artifacts. Here uv owns wheel resolution, tool environments and its records. The installer observes the actual launcher
+and delegates repair to an explicit uv-backed retry. This boundary avoids two authorities for the same installation.
+
+# Prior art
+
+[RFC 1408](https://github.com/oceanbase/powercontext/pull/1408) defines the separation of installation, configuration,
+and diagnostics; independent Runtime profiles and host selection; component-level recovery; and explicit service
+registration. [RFC 1299](1299_local_server_availability_and_service_installation.md) defines personal service lifecycle.
+
+[Magpie's installer](https://github.com/yetone/magpie/blob/023f5aaad2ecd41ae04390166b9cac9a0b300d81/site/public/install.sh)
+separates an authoritative release feed from artifact mirrors and verifies a binary checksum before replacement.
+Its raw binary ownership justifies that verification; its manifest is not a replacement for Python package standards.
+
+[Lody's daemon installation contract](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/specs/daemon-upgrade-installation.md)
+resolves and executes the actual npm installation destination and checks replacement readiness. Its separate
+[managed Agent runtime](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/apps/cli/src/agent/managed-agent-runtime.ts)
+owns raw archives and therefore owns manifests and completion records. Apply the actual-executable check here;
+retain service readiness in the service boundary. The public tag-release workflow does not publish installers.
+
+[Bub](https://github.com/bubbuild/bub/tree/b4a61bf1326729a024161d22ba20019b8500f907/website/public) bootstraps uv before
+running its Python preset resolver. Its terminal and macOS Bash fixes motivate explicit host setup and native tests.
+[uv tools](https://docs.astral.sh/uv/concepts/tools/) and
+[configuration](https://docs.astral.sh/uv/concepts/configuration-files/) define the reused environment and source
+semantics; the [Python Simple API](https://packaging.python.org/en/latest/specifications/simple-repository-api/)
+defines package index interpretation. A shell regex cannot implement those contracts.
+
+# Unresolved questions
+
+No further cross-subsystem decision is required for this contract. Complete offline bundles, cryptographic installer
+manifests, independently packaged host artifacts, and changes to configuration storage need separate designs.
+The platform matrix must remain green before promotion; Windows product support remains experimental.
+
+# Future possibilities
+
+The scripts can bootstrap a distribution-owned installer engine if installation plans later need immutable component
+manifests, independent integration versions, or durable per-component repair records. An offline bundle can include
+all required artifacts explicitly. Neither extension should make Server startup or service registration implicit.
+
+The personal-service onboarding work tracks [Issue #1900](https://github.com/oceanbase/powercontext/issues/1900).
diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md
index bd5380fba4..75f797ab7a 100644
--- a/docs/zh/docs/get-started/install-and-run.md
+++ b/docs/zh/docs/get-started/install-and-run.md
@@ -20,6 +20,134 @@ description: 安装 PowerContext 1.4.0,并运行本地 Server。
Windows 的 CLI、Server 和个人服务支持为试验性;各 Agent Host 仍需满足自身的平台要求。
使用 Bash 语法的示例需要 Bash 环境,不可直接粘贴到 PowerShell。嵌入式 seekDB 不支持 Windows。
+## 使用推荐安装脚本
+
+脚本在独立的 uv tool 环境中安装 CLI 和本地 Server。已有兼容的 uv/Python 时会直接复用;缺少时在用户目录安装
+uv 和 Python 3.12,无需管理员权限。
+
+macOS 或 Linux(使用 Bash,需要 `curl` 或 `wget`):
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash
+```
+
+Windows(PowerShell 5.1 或更新版本):
+
+```powershell
+powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))"
+```
+
+先执行安装完成时打印的 PATH 命令,再在终端运行 `powercontext`。安装器不会修改 shell 启动文件或 Windows 的持久
+PATH。`UV_INSTALL_DIR` 指定缺少 uv 时的安装目录,`UV_TOOL_BIN_DIR` 指定 PowerContext 可执行文件目录。
+
+默认安装可以无人值守执行。使用 `--host codex` 安装选定的 Agent 集成;多个宿主可重复传入 `--host`。
+只有集成安装需要 Git 及对应宿主的前置条件。`--no-hosts` 可以显式表达默认行为,不能与 `--host` 同时使用。
+宿主支持范围见[能力矩阵](../integrations/capabilities.md)。
+
+连接已有远程 Server 时,加上 `--profile client`,只安装 CLI 和 Client 依赖。默认 `--profile local` 包含本地 Server。
+两种模式都不会启动 Server、注册服务或覆盖配置与数据。本地安装使用 `powercontext config init`,继续阅读
+[快速开始](quickstart.md);Client-only 安装按[远程连接指南](../operate/connect-remote-server.md)设置地址和认证。
+
+## 配置并安装个人服务
+
+个人 macOS/Linux 推荐使用原生当前用户服务。安装器可以显式串联配置向导和服务注册:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --configure --service --env-file "$HOME/.config/powercontext/powercontext.env"
+```
+
+`--configure` 通过控制终端打开现有向导,需要显式 `--env-file`。保存后才能继续;取消会中止后续服务和宿主安装。
+个人服务选择 loopback 绑定;Linux 需要可用的 `systemd --user`。无人值守环境使用已有受保护文件:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --service --env-file /path/to/powercontext.env
+```
+
+安装器验证配置,执行 `service install`、`service status`,再通过 `doctor --env-file` 检查同一文件中的 Server。
+显式 `--host` 也会使用该文件及实际安装版本对应的 ref。Client-only 模式在安装前拒绝 `--configure` 和 `--service`;
+单独使用 `--env-file` 可以为显式宿主安装提供连接配置,而不安装本地服务。
+
+Windows 的相同选项仍为试验性支持;`--service` 显式启用登录自启动。`--configure` 需要交互控制台。
+已有文件的 ACL 保护方式见[部署 Server](../operate/deploy-server.md)。
+
+这些选项要求所选发行版提供 `config init --require-write` 和 `doctor --env-file`。缺少时,安装器报告失败阶段并保留
+Runtime;使用兼容发行版,或分开执行配置与服务命令。Runtime 安装、配置保存、服务注册和 Server 就绪分别报告。
+`degraded` 诊断返回非零状态;后续失败不会回滚包文件、删除配置或移除已提交的服务注册。
+
+任何文件修改,包括写入返回的 Scope ID 后,都要使用原文件重新注册:
+
+```bash
+powercontext service install --env-file /path/to/powercontext.env
+powercontext service status
+powercontext doctor --env-file /path/to/powercontext.env
+```
+
+`doctor --env-file` 以文件为准,覆盖调用者 shell 的默认配置。Server 文件选择其监听地址,Client-only 文件选择 Client URL;
+显式 `--server-url` 可以覆盖诊断目标。生成的后续步骤仍负责引导 Scope 创建和 Agent 验收;服务运行不代表 Agent 工作流已完成。
+
+## 选择脚本安装版本
+
+默认 `--version latest` 安装或升级到所选包源中与当前 Python 兼容的最新稳定版,排除预发布版本。
+`--version` 也支持指定准确版本,包括 `1.3.0rc1` 这样的显式预发布版本;源中没有指定版本时会报错,不会换成其他版本。
+
+例如,安装 PowerContext 1.4.0 及其 Codex 集成:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.4.0 --host codex
+```
+
+Python 包版本为 `1.4.0`,对应 Git tag 为 `powercontext-v1.4.0`。使用 `latest` 时,安装器读取实际安装的 CLI 版本,
+并使用对应的 tag 安装集成,不会使用会移动的 `master` 分支。稍后添加集成时可以执行:
+
+```bash
+powercontext setup codex --ref "powercontext-v$(powercontext --version)"
+```
+
+需要交互选择宿主时,运行 `powercontext setup --ref "powercontext-v$(powercontext --version)"`。
+
+PowerShell 也支持这里的双引号表达式。集成安装失败时,已安装的 Runtime 会保留,脚本返回错误并给出重试提示。
+安装成功不代表 Server 就绪或宿主工作流可用;仍需执行下文检查及集成文档中的验证。
+
+安装器在报告成功前验证版本和所选模式的命令。包安装成功后仍可能无法通过命令验证,此时旧可执行文件可能已被替换。
+请查看错误中指出的命令,再指定已知可用的 `--version` 重试。安装器不回滚包文件。
+
+## 使用镜像重试依赖下载
+
+包索引、uv 二进制和 Python 发行版是三类独立下载。更换包索引不会改变 uv 或 Python 的下载位置。
+
+| 下载内容 | 显式配置 | 中国区域自动源 | 全球默认源 |
+| --- | --- | --- | --- |
+| PowerContext 和 Python 包 | `--index-url URL`、uv 索引环境变量或 `uv.toml` | 清华 PyPI 镜像 | PyPI |
+| uv 安装器 | `POWERCONTEXT_UV_INSTALLER_URL` | USTC uv release 镜像 | Astral 安装器 |
+| uv 二进制 | `UV_DOWNLOAD_URL` 或 `UV_INSTALLER_GITHUB_BASE_URL` | USTC uv release 镜像 | Astral release 渠道 |
+| Python 发行版 | `UV_PYTHON_INSTALL_MIRROR` | NJU python-build-standalone 镜像 | uv 默认渠道 |
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --region cn
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --index-url https://pypi.org/simple
+```
+
+`--region auto|cn|global` 优先于 `POWERCONTEXT_INSTALL_REGION`。自动选择依次参考本地命名时区、locale 地区,最后采用
+全球源,不请求网络定位服务。自动镜像不可用时可回退官方源;中国区域包镜像可达时,即使缺少请求版本,也保持选中。
+解析或文件下载失败由 uv 报告,不会再换源重试安装。需要换源时使用 `--region global` 或显式指定来源。
+显式配置的源不会自动回退。镜像可能有同步延迟:`latest` 指所选源中的最新兼容稳定版。
+
+已有的 uv 索引环境变量和配置文件优先于自动包镜像。`--index-url` 只覆盖默认索引,额外 uv 索引仍保留其优先级。
+私有源凭据应使用 uv 认证配置,不放在脚本参数里。uv 不读取 `PIP_INDEX_URL` 或 `PIP_EXTRA_INDEX_URL`。
+已有 Python 镜像设置和 `uv.toml` 同样会禁用自动 Python 镜像选择。`UV_ASTRAL_MIRROR_URL` 会透传给支持它的 uv 版本。
+这些设置只作用于本次安装,不改写持久包管理配置;已有 uv 会直接复用,不会自动升级。
+
+需要检查脚本内容,或在 PowerShell 传入多个选项时,可以先保存脚本:
+
+```powershell
+irm https://powercontext.oceanbase.io/install.ps1 -OutFile install.ps1
+powershell -ExecutionPolicy Bypass -File .\install.ps1 --region cn --version 1.4.0
+```
+
+已有 uv、兼容 Python 和完整依赖缓存时,可以使用 `UV_OFFLINE=1` 重装。这不等于离线发行包;缺少下载内容时会明确报错。
+
## 选择版本
本页使用 PowerContext 1.4.0。包与 Agent 集成保持版本一致:
diff --git a/docs/zh/rfcs/1892-script-installation.md b/docs/zh/rfcs/1892-script-installation.md
new file mode 100644
index 0000000000..45717fa5ca
--- /dev/null
+++ b/docs/zh/rfcs/1892-script-installation.md
@@ -0,0 +1,263 @@
+---
+title: 脚本安装
+description: 通过 Bash 和 PowerShell 安装最新或指定版本,独立管理包与运行时镜像,并显式选择 Agent。
+---
+
+- Proposal Name: `script_installation`
+- Start Date: 2026-10-09
+- RFC PR: [oceanbase/powercontext#1892](https://github.com/oceanbase/powercontext/pull/1892)
+- Related RFCs: [RFC 1733](1733-usability-and-agent-workflows.md)、[RFC 1299](1299_local_server_availability_and_service_installation.md)
+
+# Summary
+
+提供由发行版维护的 Bash 和 PowerShell 入口,通过 uv 安装 PowerContext。默认版本是所选包索引中的最新稳定版,
+`--version` 可以指定准确版本。脚本补齐缺少的 uv 和 Python,安装所选 Runtime profile,并按需调用现有 Agent 集成
+适配器,使用与实际安装的 Runtime 对应的 tag。
+
+安装、配置、诊断和服务运行各自承担独立职责。默认安装软件不会配置推理服务、启动 Server 或注册持久服务。显式 `--configure` 和 `--service` 可以通过
+现有 CLI 串联这些操作,并使用同一份 `--env-file`。
+README 和快速开始将脚本及其安装指南作为首选路径。
+
+# Motivation
+
+正常安装不应要求用户自行协调 Python 发现、uv 安装、包 extras、包索引及集成 Git ref。Python 包、uv 二进制和
+Python 发行版也来自不同渠道:只更换 PyPI 索引无法解决 Python 下载不可达的问题。
+
+安装负责放置版本化组件并注册显式选择的集成;配置管理 Server URL、Scope、采集策略等可变值;诊断观察安装结果。
+分清这些职责,安装体验就可以独立于配置存储、统一 Hooks、运维工具或服务生命周期改进。
+
+预期结果是每种 shell 一个入口、复用已有依赖、明确的版本选择、显式的宿主选择、可恢复的部分失败,
+以及通过实际安装产物进行验证。
+
+# Guide-level explanation
+
+## 安装 Runtime profile
+
+macOS 或 Linux:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash
+```
+
+Windows,使用 PowerShell 5.1 或更新版本:
+
+```powershell
+powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))"
+```
+
+默认 `local` profile 安装 CLI、Client 和本地 Server 依赖。`--profile client` 只安装连接已有 Server 所需的 CLI 和
+Client 依赖。Profile 与宿主选择相互独立:选择数据库角色不隐含选择 Agent,选择 Codex 也不会更改 Runtime profile。
+
+安装器复用已有 uv,以及兼容的系统 Python 或 uv 管理的 Python;否则在用户目录补齐 uv 和 Python 3.12。
+完成后打印实际 Runtime 版本、profile、可执行文件目录、PATH 命令及下一步配置命令,不修改持久 PATH 设置。
+继续操作前,先执行打印的 PATH 命令。
+
+## 选择发行版和集成
+
+默认值为 `--version latest`,重复执行会检查已配置索引上的更新稳定版。需要可重复的包版本选择时,指定准确版本:
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.2.0 --host codex
+```
+
+默认安装无需 Git 或宿主选择界面,可以无人值守执行。`--host` 可以重复,用于显式安装集成;这时需要 Git 和
+各宿主自己的前置条件。`--no-hosts` 显式表达默认行为,不能与 `--host` 同时使用。安装后需要交互选择宿主时,运行
+`powercontext setup --ref "powercontext-v$(powercontext --version)"`。
+
+已安装的 CLI 提供准确版本,形成 `powercontext-vVERSION`。宿主安装不再次解析 `latest`,也不默认使用 `master`。
+宿主安装失败时,保留已安装的 Runtime,脚本返回非零状态,并提示按该 tag 重试 setup;各适配器提供其已有的宿主结果。
+
+个人 macOS/Linux 配置受保护文件后,运行 `powercontext service install --env-file .env`、`powercontext service status`
+和 `powercontext doctor --env-file .env`,由原生用户服务管理器负责生命周期。开发、调试、临时使用或缺少 manager 时,
+保留前台 `server run`。Client-only 模式按远程连接指南设置地址和认证。持久服务仍由用户显式注册。
+
+## 显式配置与个人服务安装
+
+安装器提供三个可以组合的选项:
+
+| 选项 | 契约 |
+| --- | --- |
+| `--configure` | 通过控制终端运行 `config init --require-write --output PATH` |
+| `--service` | 验证所选文件,安装原生用户服务,检查状态与就绪 |
+| `--env-file PATH` | 为配置、服务、诊断和宿主安装选择同一份显式文件 |
+
+```bash
+curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \
+ --configure --service --env-file "$HOME/.config/powercontext/powercontext.env"
+```
+
+`--configure` 和 `--service` 要求 local profile 及显式文件。无人值守安装省略 `--configure`,提供已有文件。
+无效组合、缺少已有文件或交互终端时,在 Runtime 安装前失败。Bash 将 `/dev/tty` 单独交给向导,避免与下载脚本的
+stdin 混用;PowerShell 要求交互控制台。普通安装继续支持无人值守。
+
+配置 CLI 增加 `--require-write`,保留独立使用时的取消行为。指定该选项时,取消保存返回 130,即使已有有效旧文件,
+也不会继续修改服务或宿主。两个入口分别报告配置保存、验证、服务验证以及后续失败。
+
+服务安装复用现有服务层的文件保护、loopback、manager 支持、注册归属、reconcile 和启动规则。
+Windows 仍为试验性;入口的显式 `--service` 同时同意登录自启动。完成注册后,还必须通过 `service status` 和
+`doctor --env-file` 的就绪诊断;`degraded` 返回非零状态。
+
+`doctor --env-file` 复用严格的 Python 环境加载器,结束后恢复进程设置。Server 文件选择其监听地址,不受 SSH 转发
+所用 Client URL 或调用者旧配置影响;Client-only 文件选择 Client URL,显式诊断 `--server-url` 优先。
+Shell 不执行文件内容。宿主安装使用 `setup --env-file PATH HOST...`,并绑定实际安装版本对应的 tag。
+
+所选发行版必须提供新增 CLI 选项;旧版缺少时,报告失败阶段并保留 Runtime。发布需要协调包含这些选项的包与网站。
+任何文件修改(包括 Scope ID)或升级后,使用原文件重新执行 `service install --env-file`。
+配置输出和后续步骤统一推荐这一路径,并在 Scope 创建后提示重新注册。后续失败保留包、已保存文件和已提交的服务注册,
+供用户显式恢复。
+
+## 选择下载来源
+
+`--region auto|cn|global` 优先于 `POWERCONTEXT_INSTALL_REGION`。自动模式依次参考本地命名时区、locale 地区,
+最后采用全球源,不调用网络定位服务。
+
+| 组件 | 全球默认源 | 中国区域自动源 | 显式控制 |
+| --- | --- | --- | --- |
+| PowerContext 及依赖 | PyPI | 清华 PyPI 镜像 | `--index-url`、uv 索引设置及配置文件 |
+| uv 安装器及二进制 | Astral 渠道 | USTC release 镜像 | `POWERCONTEXT_UV_INSTALLER_URL`、`UV_DOWNLOAD_URL`、uv 安装器镜像变量 |
+| Python | uv 默认渠道 | NJU python-build-standalone 镜像 | `UV_PYTHON_INSTALL_MIRROR`、uv Python 下载配置 |
+
+已有 uv 配置时不自动选择包镜像。显式 `--index-url` 只更改默认索引,额外索引沿用 uv 的优先级。
+认证和索引解析由 uv 负责。脚本 URL 参数必须使用 HTTPS,且不包含凭据;凭据通过 uv 配置管理。
+pip 的索引环境变量不属于 uv 配置。Python 和 uv 下载覆盖项与包索引彼此独立。
+
+# Reference-level explanation
+
+## 安装职责
+
+入口为 `website/public/install.sh` 和 `website/public/install.ps1`,作为网站静态资源发布。
+两者支持相同的选项和失败语义。脚本负责引导与编排;uv 负责环境创建和包解析;现有 `powercontext setup` 适配器
+负责集成安装。
+
+安装操作由以下输入确定:
+
+```text
+包 requirement + Runtime profile + 显式宿主选择 = 安装操作
+```
+
+包管理器在替换工具环境前解析 requirement。安装完成后验证 CLI 的准确版本,再用于全部宿主安装。
+这样一次操作具有统一的发行版坐标,无需新增包解析器,也不依赖另一个发行清单系统。
+
+保留 `powercontext setup`、`config`、`doctor` 和服务命令。不把领域行为搬到 shell,不引入独立 Python 安装引擎,
+也不修改 Runtime 或公共 HTTP API。
+
+## 版本和 profile 语义
+
+- `latest` 向 `uv tool install --upgrade --reinstall-package powercontext --prerelease disallow` 传入未固定版本的 profile requirement。
+ 它表示所选解释器及已配置源兼容的最新稳定版,不一定是另一镜像上最近上传的版本。重新安装 PowerContext 包,避免已有预发布版本绕过稳定版选择。解析失败直接报告,不改换 requirement。
+- 准确版本使用 `==VERSION`,格式为 `X.Y.Z`,可追加 `aN`、`bN` 或 `rcN`。支持显式预发布版本;
+ `--version` 不接受源码 ref、版本范围或 URL,也不支持 0.1.0 之前的版本。
+- `local` 使用 `powercontext[cli,server]`;`client` 使用 `powercontext[cli]`,后者已经包含 Client 依赖。
+ 使用不同 profile 重跑会替换该工具的依赖集合;自行添加的 extras 应通过文档中的手动安装路径维护。
+- `uv tool dir --bin` 中的可执行文件必须报告发行版本;显式请求必须与其一致。所有 profile 都需要通过 CLI help、
+ `capabilities --help`;`local` 还需要通过 `config init --help` 和 `server run --help`。
+ 全部通过后才打印安装成功。这些检查验证导入和命令可用性,不代表 Server 就绪或 Agent 工作流可用。
+
+uv 使用安装器维护的固定引导版本,与 PowerContext 发行版本无关。已有 uv 直接复用,不悄悄升级。
+Python 发现排除虚拟环境,避免项目 venv 意外成为安装前提。没有兼容的 Python 3.11+ 时,通过 uv 安装 Python 3.12。
+平台、架构和 wheel 兼容性错误由 uv 报告。
+
+## 镜像优先级与恢复
+
+保留用户显式指定的 uv/Python 来源,不做自动回退。中国区域自动选择时,uv 镜像安装器不可用则回退 Astral;
+uv 镜像文件下载失败时可以尝试官方源。Python 的准确构建 URL 由 uv 提供,安装前检查对应镜像文件;
+自动 Python 镜像安装失败后,使用 uv 默认渠道重试。
+
+中国区域自动包源选择对 PowerContext 索引页发起有超时限制的可用性请求;镜像不可达时,在工具安装前回退 PyPI。
+可达镜像即使缺少请求版本也保持选中,由 uv 报告解析失败。全球源和显式来源直接交给 uv。脚本不解释索引 HTML、
+wheel 文件名、编码 URL、包兼容性或认证,也不在一次可能已经改变安装文件的工具安装尝试后换源重试。
+
+Shell 环境变化局限于安装器进程;PowerShell 在 `finally` 恢复临时来源变量。不用 shell 解析或改写已有配置文件,
+只清理由安装器创建的临时下载目录。保留 `UV_INSTALL_DIR`、`UV_TOOL_DIR` 和 `UV_TOOL_BIN_DIR` 的位置控制。
+
+## 持久化与兼容性
+
+普通包安装不读取或改写 `.env`、凭据、数据目录或数据库 schema,也不停止或重启服务。显式 `--configure`
+由 CLI 保存所选文件;显式 `--service` 由原生层 reconcile 自有注册,并按既有契约在需要时重启。
+启动升级后的 Server 时,用户按现有升级和迁移指南操作。准确版本重试可以使用缓存;`latest` 则明确允许升级。
+
+`UV_OFFLINE=1` 只支持已有 uv、兼容 Python 和全部依赖时的缓存重装,同时禁用安装器源探测和 uv 网络访问,
+不承诺完整离线发行包。后续步骤失败会保留已安装的前置依赖。准确版本不存在导致的解析失败会保留原工具;
+但 uv 接受新包后,验证失败可能发生在旧可执行文件已被替换之后。此时明确报告“包已安装,验证失败”,允许通过准确
+版本重试,不声称已经回滚。后续宿主安装失败则保留已验证的 Runtime。不提供跨组件回滚,也不修复无关宿主状态。
+
+Windows 保持产品的试验性支持状态。原生验收在 Linux、macOS 和 Windows 执行;具体宿主支持范围仍由各集成的能力契约决定。
+
+## 可执行契约与验收
+
+`tests/fixtures/installation/*.json` 定义两个 shell 适配器共享的公开输入与预期结果。
+`tests/test_installation_contract.py` 使用真实 uv 和小型离线 fixture wheel 执行宿主显式选择、profile、准确版本
+校验、命令缺失和宿主部分失败用例。用例观察安装后的能力、所选宿主、发行 ref 和退出结果,不固定内部函数边界或
+调用顺序。同一组用例在各操作系统原生执行;模拟宿主或服务执行不等于真实原生服务或 Agent 验收。POSIX 控制终端用例覆盖管道安装中的配置请求与取消;
+原生服务 CI 通过安装后的脚本验证记忆持久化、文件修改后的重新注册和停止服务后的升级。
+
+`tests/native/test_installation.py` 执行真实 shell 安装器、uv、安装后的 PowerContext CLI 和 HTTP Server。
+从被测提交构建 wheel,使用发行版形式的元数据和直接本地文件约束。该约束选定被测产物,不构成独立执行的校验和验证。
+验收包括:
+
+- 全球源及中国区域源下缺少 uv/Python 的安装、已有工具与配置、包含空格和中文的路径;
+- 通过受控包索引解析真实稳定版和预发布版 wheel、默认升级、指定版本,以及版本不存在时保留原安装;
+- Client-only 安装及 profile 切换,安装过程不创建本地 Server 状态;
+- `.env` 生成与验证、就绪检查、Memory 保存与搜索、离线缓存重装及重启后的回读;
+- 显式索引失败、宿主选项冲突、无人值守与 Bash 管道安装,以及 PowerShell 下载脚本后执行 scriptblock 的入口。
+
+独立 CI 矩阵在三个操作系统执行上述套件。发布引用检查保持脚本的 `latest` 默认值,同时更新显式版本示例。
+网站验证检查文档链接和静态构建。语法检查本身不证明 Windows 或 macOS 安装可用;这些测试也不声称
+真实 Agent 宿主已完成采集与召回工作流。
+
+[可复现的第一性原理和消融研究](https://github.com/PsiACE/powercontext/tree/feat/installation-contract/experiments/installation)
+固定上游源码、安装器基线、uv 版本、受控输入和
+观测结果。它们支持以下决策:删除隐式宿主选择仍可保留显式宿主失败语义;基于文件名的预检查会拒绝合法编码包 URL;
+只有预发布版本的索引需要显式稳定版策略;包安装成功后入口仍可能不可用。研究与持续维护的产品验收分开,分别说明
+实际执行范围和限制。
+
+# Drawbacks
+
+Bash 和 PowerShell 会重复部分编排和来源选择策略。自动镜像增加外部可用性依赖。
+`latest` 会随时间变化,可重复部署需要指定版本。匹配 tag 能协调发行版,但不能单独证明宿主兼容性。
+
+# Rationale and alternatives
+
+保留直接运行 uv 命令的路径,供已有 Python 和 uv 的用户使用;它无法引导干净机器。
+源码安装适合开发,不作为默认发行渠道。把公共脚本固定在某个 PowerContext 版本,会使新用户在网站重新发布前
+一直安装过期版本。
+
+没有 Python 的机器仍需要为 Python 引擎提供引导。生成 shell 适配器仍需原生行为测试,还会增加生成器和版本管理。
+共享契约用例可以提供可执行的一致性约束,无需分发新的运行时资源。仅当具体策略确有需要时再引入引擎或生成器。
+
+分发独立版本的原始产物时,不可变清单和额外安装账本有其用途。这里由 uv 管理 wheel 解析、工具环境及自身记录;
+安装器观察实际入口,通过显式的 uv 安装重试修复组件,避免同一安装状态出现两个权威来源。
+
+# Prior art
+
+[RFC 1408](https://github.com/oceanbase/powercontext/pull/1408) 定义了安装、配置和诊断的职责分离,
+独立的 Runtime profile 与宿主选择、组件级恢复和显式服务注册。
+[RFC 1299](1299_local_server_availability_and_service_installation.md) 定义个人服务生命周期。
+
+[Magpie 安装器](https://github.com/yetone/magpie/blob/023f5aaad2ecd41ae04390166b9cac9a0b300d81/site/public/install.sh)
+将权威发行源与产物镜像分开,在替换二进制前检查校验和。它直接拥有原始二进制的分发职责,因此需要这层验证;
+其清单不能替代 Python 包标准。
+
+[Lody daemon 安装契约](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/specs/daemon-upgrade-installation.md)
+解析并执行实际 npm 安装位置,检查替换后的就绪状态。它另有直接管理原始压缩包的
+[Agent runtime](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/apps/cli/src/agent/managed-agent-runtime.ts),
+因此拥有清单和完成记录。本方案采用实际可执行文件检查,服务就绪仍归服务边界负责。其公开 tag-release 工作流不发布安装器。
+
+[Bub](https://github.com/bubbuild/bub/tree/b4a61bf1326729a024161d22ba20019b8500f907/website/public) 先引导 uv,再运行
+Python preset 解析器。它对终端和 macOS Bash 的修复支持显式宿主安装与原生测试的必要性。
+[uv 工具环境](https://docs.astral.sh/uv/concepts/tools/)及
+[配置规则](https://docs.astral.sh/uv/concepts/configuration-files/)定义复用的环境与来源语义;
+[Python Simple API](https://packaging.python.org/en/latest/specifications/simple-repository-api/)定义包索引解释规则。
+Shell 正则无法实现这些契约。
+
+# Unresolved questions
+
+接受本契约无需额外的跨子系统决策。完整离线包、带密码学校验的安装清单、独立打包的宿主产物以及配置存储变化,
+需要各自设计。推广前必须保持平台矩阵通过,Windows 产品支持仍为试验性。
+
+# Future possibilities
+
+当安装计划需要不可变组件清单、独立集成版本或持久组件修复记录时,脚本可以引导发行版维护的独立安装引擎。
+离线包也可以显式携带全部所需产物。这些扩展继续保持 Server 启动与服务注册的显式同意。
+
+个人服务引导工作关联 [Issue #1900](https://github.com/oceanbase/powercontext/issues/1900)。
diff --git a/scripts/release_version.py b/scripts/release_version.py
index 57bf563e31..fc505dcddf 100644
--- a/scripts/release_version.py
+++ b/scripts/release_version.py
@@ -45,12 +45,14 @@
*INSTALL_REFERENCES,
r"PowerContext ",
r"package `",
+ r"--version ",
r"^To upgrade to ",
),
"docs/zh/docs/get-started/install-and-run.md": (
*INSTALL_REFERENCES,
r"PowerContext ",
r"Python 包版本为 `",
+ r"--version ",
r"^升级到 ",
),
"integrations/dsh/plugins/powercontext/README.md": INSTALL_REFERENCES,
diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py
index f18386607b..75e93aba16 100644
--- a/src/powercontext/cli/config.py
+++ b/src/powercontext/cli/config.py
@@ -28,7 +28,6 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
-from importlib.metadata import version
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Never
from urllib.parse import urlsplit
@@ -139,35 +138,15 @@ class ApiProtocol:
_PROTOCOL_BY_ID = {protocol.identifier: protocol for protocol in API_PROTOCOLS}
-AGENTS: dict[str, tuple[str, str, str]] = {
- "codex": ("Codex", "powercontext setup codex --source oceanbase/powercontext --ref master", "codex"),
- "claude-code": (
- "Claude Code",
- "powercontext setup claude-code --source oceanbase/powercontext --ref master",
- "claude",
- ),
- "dsh": ("DeepSeek Harness", "powercontext setup dsh --source oceanbase/powercontext --ref master", "dsh web"),
- "opencode": (
- "OpenCode",
- "powercontext setup opencode --source oceanbase/powercontext --ref master",
- "opencode",
- ),
- "pi": ("Pi", "powercontext setup pi --source oceanbase/powercontext --ref master", "pi"),
- "openclaw": (
- "OpenClaw",
- "powercontext setup openclaw --source oceanbase/powercontext --ref master",
- "openclaw",
- ),
- "hermes": (
- "Hermes",
- "powercontext setup hermes --source oceanbase/powercontext --ref master",
- "hermes",
- ),
- "workbuddy": (
- "WorkBuddy",
- "powercontext setup workbuddy --source oceanbase/powercontext --ref master",
- "重启 WorkBuddy",
- ),
+AGENTS: dict[str, tuple[str, str]] = {
+ "codex": ("Codex", "codex"),
+ "claude-code": ("Claude Code", "claude"),
+ "dsh": ("DeepSeek Harness", "dsh web"),
+ "opencode": ("OpenCode", "opencode"),
+ "pi": ("Pi", "pi"),
+ "openclaw": ("OpenClaw", "openclaw"),
+ "hermes": ("Hermes", "hermes"),
+ "workbuddy": ("WorkBuddy", "重启 WorkBuddy"),
}
# Input hints only, not a provider allowlist. Unknown prefixes can attach arbitrary variables.
@@ -358,13 +337,18 @@ def init_command(
template: Annotated[
bool, typer.Option("--template", help="Use the basic model-free template instead of the guided setup.")
] = False,
+ require_write: Annotated[
+ bool, typer.Option(help="Exit with status 130 if configuration is cancelled without saving.")
+ ] = False,
) -> None:
"""Create a working configuration through a short guided setup."""
if not template:
from powercontext.cli.config_wizard import run_wizard
- run_wizard(output, language=None if language is None else language.value, advanced=advanced)
+ written = run_wizard(output, language=None if language is None else language.value, advanced=advanced)
+ if require_write and not written:
+ raise typer.Exit(130)
return
if output.exists() and not force:
@@ -377,6 +361,8 @@ def init_command(
content = update_environment_document(existing, configuration)
if not _confirm_environment_write(output, existing=existing, updated=content):
typer.echo("No changes written.")
+ if require_write:
+ raise typer.Exit(130)
return
backup = write_environment(output, content, backup=output.exists())
except (ConfigError, EnvironmentFileError, OSError, UnicodeError, ValidationError) as error:
@@ -1095,7 +1081,11 @@ def _removes_inference_configuration(existing: str, updated: str) -> bool:
def _print_next_steps(path: Path) -> None:
+ from powercontext.cli.config_startup import server_startup_commands
+ from powercontext.cli.config_wizard_installation import installation_source
+
quoted = shlex.quote(str(path.resolve()))
+ source = installation_source(Path(__file__))
typer.secho("\nEnvironment file", bold=True, fg=typer.colors.CYAN)
typer.echo(f" Path {path.resolve()} (mode 0600)")
typer.echo(" Authentication disabled by default.")
@@ -1103,17 +1093,22 @@ def _print_next_steps(path: Path) -> None:
" If Bearer authentication is enabled, read POWERCONTEXT_SERVER_AUTH_TOKEN from this file;"
" the value is never printed."
)
- typer.echo(f"\nStart Server:\n powercontext server run --env-file {quoted}")
+ typer.echo("\nStart Server:")
+ for command in server_startup_commands(
+ path.resolve(),
+ host=parse_environment(path.read_text(encoding="utf-8")).get("POWERCONTEXT_SERVER_HTTP_HOST", "127.0.0.1"),
+ ):
+ typer.echo(f" {command}")
write_inference_capability_notice(generation_model=None, embedding_model=None)
typer.secho("\nSupported Coding Agents (choose one):", bold=True, fg=typer.colors.CYAN)
- for host, (name, setup, launch) in AGENTS.items():
- if host == "dsh":
- installed = version("powercontext")
- setup = (
- f"powercontext setup dsh --source oceanbase/powercontext --ref powercontext-v{installed}"
- if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?", installed)
- else "powercontext setup dsh --source /path/to/matching-powercontext-checkout"
- )
+ for host, (name, launch) in AGENTS.items():
+ setup = f"powercontext setup --env-file {quoted} {host}"
+ if source is None:
+ setup += " --help"
+ else:
+ setup += f" --source {shlex.quote(source.source)}"
+ if source.ref:
+ setup += f" --ref {shlex.quote(source.ref)}"
typer.echo(f"\n{name}:\n {setup}")
if launch.startswith("重启"):
typer.echo(f" {launch}")
diff --git a/src/powercontext/cli/config_startup.py b/src/powercontext/cli/config_startup.py
new file mode 100644
index 0000000000..f596a8739c
--- /dev/null
+++ b/src/powercontext/cli/config_startup.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Startup guidance shared by configuration output and saved instructions."""
+
+import shlex
+import sys
+from pathlib import Path
+
+from powercontext.transport import is_loopback_host
+
+
+def personal_service_recommended(host: str) -> bool:
+ return sys.platform in {"darwin", "linux"} and is_loopback_host(host)
+
+
+def server_startup_commands(path: Path, *, host: str = "127.0.0.1") -> list[str]:
+ quoted = shlex.quote(str(path))
+ commands = [f"powercontext config validate --env-file {quoted}"]
+ if personal_service_recommended(host):
+ commands.extend((
+ f"powercontext service install --env-file {quoted}",
+ "powercontext service status",
+ f"powercontext doctor --env-file {quoted}",
+ ))
+ else:
+ commands.append(f"powercontext server run --env-file {quoted}")
+ return commands
diff --git a/src/powercontext/cli/config_wizard.py b/src/powercontext/cli/config_wizard.py
index 3222300cb5..79bd442ea7 100644
--- a/src/powercontext/cli/config_wizard.py
+++ b/src/powercontext/cli/config_wizard.py
@@ -111,7 +111,7 @@ def note(self, en: str, zh: str) -> None:
self.ui.say(en, zh)
-def run_wizard(output: Path, *, language: str | None = None, advanced: bool = False) -> None:
+def run_wizard(output: Path, *, language: str | None = None, advanced: bool = False) -> bool:
"""Collect answers and write files only after explicit final confirmation."""
from powercontext.cli.config import ConfigError
@@ -162,7 +162,7 @@ def run_wizard(output: Path, *, language: str | None = None, advanced: bool = Fa
_agents(state)
elif mode == "edit":
_edit_modules(state)
- _finish(state, output, content)
+ return _finish(state, output, content)
except (ConfigError, EnvironmentFileError, OSError, ValueError) as error:
if isinstance(error, (WizardInputError, EnvironmentFileError, OSError)):
message = str(error)
@@ -1103,7 +1103,7 @@ def _validate_files(state: Wizard, values: dict[str, str]) -> None:
raise WizardInputError(message) from None
-def _finish(state: Wizard, output: Path, original_content: str) -> None:
+def _finish(state: Wizard, output: Path, original_content: str) -> bool:
ui = state.ui
ui.section("8. Review files", "8. 检查配置文件")
@@ -1153,10 +1153,11 @@ def _finish(state: Wizard, output: Path, original_content: str) -> None:
typer.echo(f" {path}")
if not ui.confirm("Save these files?", "保存这些文件?"):
ui.say("No changes written.", "未写入任何文件。")
- return
+ return False
_save_bundle(state, bundle, snapshots)
_finish_seekdb_install(state)
_show_connection_details(state, output, values, notes_file)
+ return True
def _save_bundle(state: Wizard, bundle: dict[Path, str], snapshots: dict[Path, str]) -> None:
@@ -1215,8 +1216,10 @@ def _show_connection_details(state: Wizard, output: Path, values: dict[str, str]
ui = state.ui
ui.section("Connection details", "连接与下一步")
if not state.client_only:
- command = f"powercontext server run --env-file {shlex.quote(str(output))}"
- ui.say(f"Start Server: {command}", f"启动 Server:{command}")
+ from powercontext.cli.config_startup import server_startup_commands
+
+ for command in server_startup_commands(output, host=values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")):
+ ui.say(f"Start Server: {command}", f"启动 Server:{command}")
if state.ssh_tunnel_command:
ui.say(
f"Run on the client computer: {state.ssh_tunnel_command}",
@@ -1257,8 +1260,50 @@ def _connection_base_url(state: Wizard, values: dict[str, str]) -> str:
return f"http://{host}:{port}"
+def _server_start_steps(state: Wizard, output: Path) -> list[str]:
+ from powercontext.cli.config_startup import personal_service_recommended, server_startup_commands
+
+ if state.client_only:
+ return []
+ ui = state.ui
+ personal_service = personal_service_recommended(state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1"))
+ lines: list[str] = []
+ lines += [
+ "```bash",
+ *server_startup_commands(output, host=state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")),
+ "```",
+ "",
+ ]
+ if personal_service:
+ lines += [
+ ui.text(
+ "The native user-service manager owns the Server lifecycle. After upgrades or any edit to "
+ "this environment file, rerun these commands with the same file. Use `powercontext server run` "
+ "for development or temporary foreground use after stopping the personal service.",
+ "原生用户服务管理器负责 Server 生命周期。升级或修改这份环境文件后,使用同一文件重新执行上述命令。"
+ "开发或临时前台运行可先停止个人服务,再使用 `powercontext server run`。",
+ ),
+ "",
+ ]
+ if state.values.get(f"{SERVER}AUTH_TOKEN"):
+ lines += [
+ ui.text(
+ f"The Server token is stored as `POWERCONTEXT_SERVER_AUTH_TOKEN` in `{output}`. "
+ "Do not paste it into logs or issues.",
+ f"Server Token 保存在 `{output}` 的 `POWERCONTEXT_SERVER_AUTH_TOKEN` 中;请勿将其粘贴到日志或 Issue。",
+ ),
+ "",
+ ]
+ return lines
+
+
def _next_steps(state: Wizard, output: Path, client_file: Path) -> str:
+ from powercontext.cli.config_startup import personal_service_recommended, server_startup_commands
+
ui = state.ui
+ personal_service = not state.client_only and personal_service_recommended(
+ state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")
+ )
lines = [
"# " + ui.text("PowerContext next steps", "PowerContext 后续步骤"),
"",
@@ -1268,24 +1313,7 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str:
),
"",
]
- if not state.client_only:
- lines += [
- "```bash",
- f"powercontext config validate --env-file {shlex.quote(str(output))}",
- f"powercontext server run --env-file {shlex.quote(str(output))}",
- "```",
- "",
- ]
- if state.values.get(f"{SERVER}AUTH_TOKEN"):
- lines += [
- ui.text(
- f"The Server token is stored as `POWERCONTEXT_SERVER_AUTH_TOKEN` in `{output}`. "
- "Do not paste it into logs or issues.",
- f"Server Token 保存在 `{output}` 的 `POWERCONTEXT_SERVER_AUTH_TOKEN` 中;"
- "请勿将其粘贴到日志或 Issue。",
- ),
- "",
- ]
+ lines += _server_start_steps(state, output)
if state.agents or state.client_only:
if state.scenario != "local":
lines += [
@@ -1328,6 +1356,18 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str:
lines += _alternate_client_check_steps(state)
if state.planned_scopes:
lines += _scope_creation_steps(state)
+ if personal_service:
+ lines += [
+ ui.text(
+ "After saving the returned Scope IDs, reconcile the service's environment-file identity:",
+ "保存返回的 Scope ID 后,重新注册服务以更新环境文件身份:",
+ ),
+ "",
+ "```bash",
+ *server_startup_commands(output, host=state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")),
+ "```",
+ "",
+ ]
lines += [
ui.text(
"After saving the returned Scope IDs in the environment file, reload it in each Agent's "
@@ -1344,7 +1384,7 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str:
"```",
"",
]
- lines += _agent_installation_steps(state)
+ lines += _agent_installation_steps(state, env_file=client_file)
if "profile" in state.features:
lines += _profile_policy_steps(state)
lines += ["## " + ui.text("Checks and pending steps", "验收与待完成项"), ""]
@@ -1498,7 +1538,7 @@ def _profile_policy_script(address: str, scope_name: str, authorization_name: st
""").splitlines()
-def _agent_installation_steps(state: Wizard) -> list[str]:
+def _agent_installation_steps(state: Wizard, *, env_file: Path | None = None) -> list[str]:
if not state.agents:
return []
source = installation_source(Path(__file__))
@@ -1522,7 +1562,10 @@ def _agent_installation_steps(state: Wizard) -> list[str]:
if source is None:
lines.append(f"powercontext setup {agent} --help")
continue
- command = f"powercontext setup {agent} --source {shlex.quote(source.source)}"
+ command = "powercontext setup"
+ if env_file is not None:
+ command += f" --env-file {shlex.quote(str(env_file))}"
+ command += f" {agent} --source {shlex.quote(source.source)}"
if source.ref:
command += f" --ref {shlex.quote(source.ref)}"
if spec.setup_server_url:
diff --git a/src/powercontext/cli/config_wizard_installation.py b/src/powercontext/cli/config_wizard_installation.py
index 3bd832fe5b..5a9602a84e 100644
--- a/src/powercontext/cli/config_wizard_installation.py
+++ b/src/powercontext/cli/config_wizard_installation.py
@@ -27,17 +27,18 @@
@dataclass(frozen=True)
class InstallationSource:
- """A verified checkout or the Git source and requested ref of an installation."""
+ """A verified checkout, preserved Git origin, or tagged package-index release."""
source: str
ref: str | None = None
def installation_source(module_path: Path) -> InstallationSource | None:
- """Return a usable plugin source without treating site-packages as a checkout.
+ """Return plugin coordinates without treating site-packages as a checkout.
Wheels exclude the integration marketplaces. PEP 610 metadata preserves the
- origin of Git installs, including forks and requested branches or tags.
+ origin of Git installs, including forks and requested branches or tags. Package
+ index releases use the distribution's version at the official release tag.
"""
checkout = module_path.resolve().parents[3]
if (
@@ -49,7 +50,16 @@ def installation_source(module_path: Path) -> InstallationSource | None:
return InstallationSource(str(checkout))
try:
- direct_url_text = importlib.metadata.distribution("powercontext").read_text("direct_url.json")
+ distribution = importlib.metadata.distribution("powercontext")
+ direct_url_text = distribution.read_text("direct_url.json")
+ if direct_url_text is None:
+ metadata = distribution.metadata
+ # PackageMetadata's protocol does not expose get().
+ installed_version = metadata["Version"] if "Version" in metadata else None # noqa: SIM401
+ if isinstance(installed_version, str) and re.fullmatch(
+ r"[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?", installed_version
+ ):
+ return InstallationSource("oceanbase/powercontext", f"powercontext-v{installed_version}")
direct_url = json.loads(direct_url_text) if direct_url_text else None
except (importlib.metadata.PackageNotFoundError, OSError, UnicodeError, ValueError):
return None
diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py
index 970d5fd072..7a225c1acb 100644
--- a/src/powercontext/cli/system.py
+++ b/src/powercontext/cli/system.py
@@ -40,6 +40,7 @@
from pydantic import ValidationError
from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target
+from powercontext.cli.env_file import EnvironmentFileError, environment_context, read_environment_file
from powercontext.cli.hosts import stdin_is_tty
from powercontext.cli.installation import SetupError, setup
from powercontext.cli.transport import is_remote_http
@@ -266,8 +267,20 @@ def doctor(
session_id: Annotated[str | None, typer.Option(help="Exact Kimi session.")] = None,
runtime_data_dir: Annotated[Path | None, typer.Option(help="Actual ZCode plugin data directory.")] = None,
prepare: Annotated[bool, typer.Option(help="Probe ZCode readonly context preparation.")] = False,
+ env_file: Annotated[
+ Path | None, typer.Option(help="Diagnose the Server defined in this file, independently of shell defaults.")
+ ] = None,
) -> None:
"""Check selected integrations, or saved integrations and the environment by default."""
+ if env_file is not None:
+ if agents:
+ message = "Run doctor without an Agent selection when using --env-file."
+ raise typer.BadParameter(message, param_hint="--env-file")
+ _doctor_environment_file(
+ env_file, server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output
+ )
+ return
+
from powercontext.cli.hosts import (
diagnostic_result,
doctor_checks,
@@ -352,6 +365,50 @@ def _codex_endpoint_updates(marketplace: str, plugin_version: str, server_url: s
) from error
+def _doctor_environment_file(
+ path: Path, *, server_url: str | None, allow_insecure_http: bool | None, json_output: bool
+) -> None:
+ try:
+ checks = run_environment_diagnostics(path, server_url=server_url, allow_insecure_http=allow_insecure_http)
+ except (EnvironmentFileError, OSError, ValueError) as error:
+ typer.echo("Cannot diagnose the environment file; check its path, assignments, and Server settings.", err=True)
+ raise typer.Exit(2) from error
+ _write_diagnostics(checks, json_output=json_output)
+ if not _diagnostics_ok(checks):
+ raise typer.Exit(code=1)
+
+
+def run_environment_diagnostics(
+ path: Path, *, server_url: str | None = None, allow_insecure_http: bool | None = None
+) -> dict[str, Diagnostic]:
+ """Observe the same Server configuration used by a personal service."""
+
+ loaded = read_environment_file(path.expanduser())
+ cleared = {
+ name
+ for name in os.environ
+ if name.casefold().startswith(("powercontext_client_", "powercontext_server_")) or name == "POWERCONTEXT_HOME"
+ }
+ with environment_context(loaded, override=True, clear=cleared):
+ if server_url is None and any(name.casefold().startswith("powercontext_server_") for name in loaded):
+ from powercontext.server.configuration import server_settings_context
+
+ with server_settings_context(environment=loaded) as settings:
+ host = settings.http.host
+ if host == "0.0.0.0": # noqa: S104 - diagnose wildcard binds via loopback
+ host = "127.0.0.1"
+ elif host in {"::", "[::]"}:
+ host = "::1"
+ if ":" in host and not host.startswith("["):
+ host = f"[{host}]"
+ server_url = f"http://{host}:{settings.http.port}"
+ else:
+ server_url = server_url or next(
+ (value for name, value in loaded.items() if name.casefold() == "powercontext_client_server_url"), None
+ )
+ return run_diagnostics(server_url=server_url, allow_insecure_http=allow_insecure_http)
+
+
def run_diagnostics(*, server_url: str | None = None, allow_insecure_http: bool | None = None) -> dict[str, Diagnostic]:
"""Collect installed-environment diagnostics without changing state."""
diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py
index dabcce1280..47976b19d9 100644
--- a/src/powercontext/service/controller.py
+++ b/src/powercontext/service/controller.py
@@ -677,6 +677,14 @@ def _build_definition(self, env_file: Path | None, *, start_on_login: bool) -> S
raise ServiceError( # noqa: TRY003
f"invalid personal service configuration: {error}", exit_code=2
) from error
+ if loaded_env is not None and (
+ any(name.casefold().startswith("powercontext_client_") for name in loaded_env.values)
+ and not any(name.casefold().startswith("powercontext_server_") for name in loaded_env.values)
+ ):
+ raise ServiceError( # noqa: TRY003
+ "the selected environment file contains only Client settings; configure a local Server before installing a service",
+ exit_code=2,
+ )
if loaded_env is None:
inherited = sorted(
name
diff --git a/tests/fixtures/installation/README.md b/tests/fixtures/installation/README.md
new file mode 100644
index 0000000000..5ada199593
--- /dev/null
+++ b/tests/fixtures/installation/README.md
@@ -0,0 +1,48 @@
+# Installer behavior contract
+
+The JSON catalogs describe public installer inputs and observable outcomes. Both native shell entry points use
+the same cases through `tests/test_installation_contract.py`. The catalogs are test inputs, not bootstrap downloads
+or an additional installation manifest. RFC 1892 defines the overall installation and recovery boundary.
+
+Run the offline contract with an existing Python and uv:
+
+```bash
+uv run pytest tests/test_installation_contract.py -q
+```
+
+Each file has `schema_version: 1` and a `cases` array. Case IDs must be unique across catalogs.
+
+| Field | Meaning |
+| --- | --- |
+| `id` | Stable scenario name shown in test results |
+| `args` | Arguments passed to the native installer |
+| `git` | Whether Git is discoverable; defaults to false |
+| `setup_exit` | Exit status of the fixture host operation; defaults to success |
+| `reported_version` | Fixture CLI version; defaults to the wheel's `1.2.0` |
+| `environment_file` | Create a protected existing configuration file for the case |
+| `terminal` | Run a piped Bash installer through a POSIX controlling terminal |
+| `config_exit`, `validate_exit`, `service_exit`, `doctor_exit` | Exit status of the fixture operation |
+| `missing_commands` | Public command help paths omitted by the fixture |
+| `expected.exit_code` | Installer completion or failure |
+| `expected.runtime_installed` | Whether uv installed the tool launcher, including verification failures |
+| `expected.server_extra` | Whether the selected profile contains the fixture Server dependency |
+| `expected.selected_hosts` | Hosts actually submitted to setup; defaults to none |
+| `expected.setup_ref` | Release tag actually submitted to setup |
+| `expected.configuration_saved` | Whether interactive configuration wrote its selected file |
+| `expected.service`, `expected.doctor` | Whether registration and same-file diagnostics produced their fixture results |
+| `expected.setup_env_file` | Whether host setup received the selected configuration file |
+| `expected.verification_failed` | Whether success must remain unannounced |
+| `expected.error_contains` | Required diagnostic fragment |
+
+The harness builds small standards-shaped wheels, installs them through real uv in private directories without
+network access, and executes the resulting native launcher. Profile cases inspect an optional installed dependency.
+Host cases observe the setup request and result through the fixture CLI. They do not start a real Agent, execute Git,
+or establish Server readiness. No case requires a particular shell function name, private call order or subprocess count.
+
+The installation CI matrix runs these cases on Linux, macOS and Windows. The same workflow also builds the actual
+PowerContext wheel and runs `tests/native/test_installation.py` for bootstrap, configuration, HTTP readiness,
+Memory persistence, profile replacement and version selection. `tests/native/test_install_sources.py` adds Linux
+loopback index fault tests using the real downloader and uv, with only distribution endpoints redirected to fixtures.
+Those source tests do not qualify public mirror availability or native Windows source handling. Native service CI
+also runs `tests/native/test_service_installation.py` on disposable Linux/macOS runners with the built wheel, a real
+user-service manager, configuration reconciliation, an offline stopped-service upgrade, and persistent Memory readback.
diff --git a/tests/fixtures/installation/capabilities.json b/tests/fixtures/installation/capabilities.json
new file mode 100644
index 0000000000..9c9fe5dca2
--- /dev/null
+++ b/tests/fixtures/installation/capabilities.json
@@ -0,0 +1,116 @@
+{
+ "schema_version": 1,
+ "cases": [
+ {
+ "id": "missing-root-help",
+ "args": [
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ ""
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "server_extra": true,
+ "verification_failed": true,
+ "error_contains": "Package installed, but"
+ }
+ },
+ {
+ "id": "missing-capabilities",
+ "args": [
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ "capabilities"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "server_extra": true,
+ "verification_failed": true,
+ "error_contains": "Package installed, but"
+ }
+ },
+ {
+ "id": "runtime-without-optional-setup",
+ "args": [
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ "setup"
+ ],
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "server_extra": true
+ }
+ },
+ {
+ "id": "missing-config-init",
+ "args": [
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ "config init"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "server_extra": true,
+ "verification_failed": true,
+ "error_contains": "Package installed, but"
+ }
+ },
+ {
+ "id": "missing-server-run",
+ "args": [
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ "server run"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "server_extra": true,
+ "verification_failed": true,
+ "error_contains": "Package installed, but"
+ }
+ },
+ {
+ "id": "client-does-not-require-local-commands",
+ "args": [
+ "--profile",
+ "client",
+ "--no-hosts"
+ ],
+ "missing_commands": [
+ "config init",
+ "server run"
+ ],
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "server_extra": false
+ }
+ },
+ {
+ "id": "exact-version-report-mismatch",
+ "args": [
+ "--version",
+ "1.2.0",
+ "--no-hosts"
+ ],
+ "reported_version": "9.9.9",
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "server_extra": true,
+ "verification_failed": true,
+ "error_contains": "differs from the requested release"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/installation/hosts.json b/tests/fixtures/installation/hosts.json
new file mode 100644
index 0000000000..cfd3007aa5
--- /dev/null
+++ b/tests/fixtures/installation/hosts.json
@@ -0,0 +1,111 @@
+{
+ "schema_version": 1,
+ "cases": [
+ {
+ "id": "unattended-default",
+ "args": [],
+ "git": false,
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "selected_hosts": [],
+ "server_extra": true
+ }
+ },
+ {
+ "id": "compatibility-no-hosts",
+ "args": [
+ "--no-hosts"
+ ],
+ "git": false,
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "selected_hosts": [],
+ "server_extra": true
+ }
+ },
+ {
+ "id": "client-profile",
+ "args": [
+ "--profile",
+ "client"
+ ],
+ "git": false,
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "selected_hosts": [],
+ "server_extra": false
+ }
+ },
+ {
+ "id": "explicit-hosts",
+ "args": [
+ "--host",
+ "codex",
+ "--host",
+ "claude"
+ ],
+ "git": true,
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "selected_hosts": [
+ "codex",
+ "claude"
+ ],
+ "setup_ref": "powercontext-v1.2.0",
+ "server_extra": true
+ }
+ },
+ {
+ "id": "host-partial-failure",
+ "args": [
+ "--host",
+ "codex"
+ ],
+ "git": true,
+ "setup_exit": 17,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "selected_hosts": [
+ "codex"
+ ],
+ "setup_ref": "powercontext-v1.2.0",
+ "server_extra": true,
+ "error_contains": "Runtime installed, but integration setup did not complete"
+ }
+ },
+ {
+ "id": "host-choice-conflict",
+ "args": [
+ "--host",
+ "codex",
+ "--no-hosts"
+ ],
+ "git": true,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "selected_hosts": [],
+ "error_contains": "--host and --no-hosts cannot be combined"
+ }
+ },
+ {
+ "id": "explicit-host-needs-git",
+ "args": [
+ "--host",
+ "codex"
+ ],
+ "git": false,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "selected_hosts": [],
+ "error_contains": "requires Git"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/installation/services.json b/tests/fixtures/installation/services.json
new file mode 100644
index 0000000000..8ed88773ba
--- /dev/null
+++ b/tests/fixtures/installation/services.json
@@ -0,0 +1,200 @@
+{
+ "schema_version": 1,
+ "cases": [
+ {
+ "id": "service-existing-file",
+ "environment_file": true,
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "service": true,
+ "doctor": true
+ }
+ },
+ {
+ "id": "service-registration-failure",
+ "environment_file": true,
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "service_exit": 1,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "service": true,
+ "error_contains": "personal service setup did not complete"
+ }
+ },
+ {
+ "id": "service-readiness-failure",
+ "environment_file": true,
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "doctor_exit": 1,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "service": true,
+ "doctor": true,
+ "error_contains": "Server diagnostics did not pass"
+ }
+ },
+ {
+ "id": "invalid-service-configuration",
+ "environment_file": true,
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "validate_exit": 2,
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "error_contains": "configuration validation failed"
+ }
+ },
+ {
+ "id": "service-and-host-use-same-file",
+ "environment_file": true,
+ "git": true,
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}",
+ "--host",
+ "codex"
+ ],
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "service": true,
+ "doctor": true,
+ "selected_hosts": [
+ "codex"
+ ],
+ "setup_ref": "powercontext-v1.2.0",
+ "setup_env_file": true
+ }
+ },
+ {
+ "id": "client-rejects-local-service",
+ "environment_file": true,
+ "args": [
+ "--profile",
+ "client",
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "error_contains": "require --profile local"
+ }
+ },
+ {
+ "id": "service-requires-explicit-file",
+ "args": [
+ "--service"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "error_contains": "require --env-file"
+ }
+ },
+ {
+ "id": "service-requires-existing-file",
+ "args": [
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "error_contains": "existing file"
+ }
+ },
+ {
+ "id": "configuration-requires-terminal",
+ "args": [
+ "--configure",
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": false,
+ "error_contains": "requires a terminal"
+ }
+ },
+ {
+ "id": "piped-configuration-and-service",
+ "terminal": true,
+ "args": [
+ "--configure",
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 0,
+ "runtime_installed": true,
+ "configuration_saved": true,
+ "service": true,
+ "doctor": true
+ }
+ },
+ {
+ "id": "cancelled-configuration-stops-service",
+ "terminal": true,
+ "config_exit": 130,
+ "args": [
+ "--configure",
+ "--service",
+ "--env-file",
+ "{env_file}"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "configuration_saved": false,
+ "error_contains": "configuration did not complete"
+ }
+ },
+ {
+ "id": "existing-configuration-cancellation-stops-hosts",
+ "terminal": true,
+ "environment_file": true,
+ "git": true,
+ "config_exit": 130,
+ "args": [
+ "--configure",
+ "--service",
+ "--env-file",
+ "{env_file}",
+ "--host",
+ "codex"
+ ],
+ "expected": {
+ "exit_code": 1,
+ "runtime_installed": true,
+ "selected_hosts": [],
+ "error_contains": "configuration did not complete"
+ }
+ }
+ ]
+}
diff --git a/tests/native/test_install_sources.py b/tests/native/test_install_sources.py
new file mode 100644
index 0000000000..041c4c0832
--- /dev/null
+++ b/tests/native/test_install_sources.py
@@ -0,0 +1,228 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Linux installer source acceptance with real uv and a loopback package index."""
+
+from __future__ import annotations
+
+import hashlib
+import http.server
+import io
+import os
+import shutil
+import subprocess
+import sys
+import urllib.parse
+import zipfile
+from pathlib import Path
+from threading import Thread
+from typing import ClassVar
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+BASH = shutil.which("bash")
+UV = os.environ.get("POWERCONTEXT_INSTALL_UV") or shutil.which("uv")
+pytestmark = pytest.mark.skipif(sys.platform != "linux" or not UV, reason="requires Linux and real uv")
+
+
+def build_wheel(version: str) -> bytes:
+ data = io.BytesIO()
+ metadata = f"powercontext-{version}.dist-info"
+ with zipfile.ZipFile(data, "w") as archive:
+ archive.writestr(
+ "fixture_cli.py",
+ "from importlib.metadata import version\ndef main():\n print(version('powercontext'))\n",
+ )
+ archive.writestr(
+ f"{metadata}/METADATA",
+ f"Metadata-Version: 2.1\nName: powercontext\nVersion: {version}\nProvides-Extra: cli\nProvides-Extra: server\n",
+ )
+ archive.writestr(f"{metadata}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n")
+ archive.writestr(f"{metadata}/entry_points.txt", "[console_scripts]\npowercontext = fixture_cli:main\n")
+ archive.writestr(f"{metadata}/RECORD", "")
+ return data.getvalue()
+
+
+@pytest.fixture
+def installation(tmp_path): # noqa: C901 - one fixture owns the server and subprocess environment
+ assert BASH
+ bash_command: str = BASH
+ wheels = {version: build_wheel(version) for version in ("1.0.0", "1.1.0", "1.2.0rc1")}
+
+ class Index(http.server.BaseHTTPRequestHandler):
+ mirror_status = 200
+ mirror_versions: ClassVar[list[str]] = ["1.0.0", "1.1.0", "1.2.0rc1"]
+ requests: ClassVar[list[str]] = []
+
+ # Keep this controlled fixture compatible with standalone Python 3.11.
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
+ pass
+
+ def do_HEAD(self):
+ self.do_GET()
+
+ def do_GET(self):
+ self.requests.append(self.path)
+ path = urllib.parse.unquote(self.path)
+ if path.startswith("/cn/") and self.mirror_status != 200:
+ self.send_error(self.mirror_status)
+ return
+ if path.endswith("/powercontext/"):
+ versions = self.mirror_versions if path.startswith("/cn/") else list(wheels)
+ source = path.split("/")[1]
+ links = []
+ for version in versions:
+ name = f"powercontext-{version}-py3-none-any.whl"
+ # Valid URL encoding and opaque anchor text reproduce the filename-probe defect.
+ href = f"/{source}/files/{name}".replace(".", "%2E")
+ digest = hashlib.sha256(wheels[version]).hexdigest()
+ links.append(f'download')
+ payload = ("" + "\n".join(links)).encode()
+ content_type = "text/html"
+ elif path.endswith(".whl"):
+ payload = wheels[path.rsplit("/", 1)[-1].split("-")[1]]
+ content_type = "application/octet-stream"
+ else:
+ self.send_error(404)
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ if self.command != "HEAD":
+ self.wfile.write(payload)
+
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Index)
+ worker = Thread(target=server.serve_forever, daemon=True)
+ worker.start()
+ base = f"http://127.0.0.1:{server.server_port}"
+ utilities = tmp_path / "utilities"
+ utilities.mkdir()
+ # Translate only distribution endpoints to fixtures; run the real downloader and uv.
+ mappings = {
+ "https://pypi.tuna.tsinghua.edu.cn/simple": base + "/cn/simple",
+ "https://pypi.org/simple": base + "/global/simple",
+ "https://explicit.invalid/simple": base + "/cn/simple",
+ }
+ for name, executable in (("uv", UV), ("curl", shutil.which("curl"))):
+ assert executable
+ wrapper = utilities / name
+ wrapper.write_text(
+ f"#!{sys.executable}\nimport os, sys\nmapping={mappings!r}\n"
+ "args=[mapping.get(value, value) for value in sys.argv[1:]]\n"
+ "args=[next((target + value[len(source):] for source,target in mapping.items() "
+ "if value.startswith(source)), value) for value in args]\n"
+ "for key in ('UV_DEFAULT_INDEX','UV_INDEX','UV_INDEX_URL'):\n"
+ " if key in os.environ: os.environ[key]=mapping.get(os.environ[key],os.environ[key])\n"
+ f"os.execv({executable!r}, [{executable!r}, *args])\n",
+ encoding="utf-8",
+ )
+ wrapper.chmod(0o755)
+ env = {
+ "PATH": str(utilities) + os.pathsep + os.environ["PATH"],
+ "HOME": str(tmp_path / "home"),
+ "TMPDIR": str(tmp_path),
+ "LANG": "C.UTF-8",
+ "XDG_CONFIG_HOME": str(tmp_path / "config"),
+ "XDG_CONFIG_DIRS": str(tmp_path / "system-config"),
+ "UV_TOOL_DIR": str(tmp_path / "tools"),
+ "UV_TOOL_BIN_DIR": str(tmp_path / "bin"),
+ "UV_CACHE_DIR": str(tmp_path / "cache"),
+ "UV_PYTHON_INSTALL_DIR": str(tmp_path / "python"),
+ "UV_HTTP_RETRIES": "0",
+ "NO_PROXY": "127.0.0.1",
+ }
+ Path(env["HOME"]).mkdir()
+
+ def install(*arguments, success=True, extra=None):
+ completed = subprocess.run(
+ [bash_command, str(ROOT / "website/public/install.sh"), "--no-hosts", *arguments],
+ cwd=tmp_path,
+ env=env | (extra or {}),
+ text=True,
+ capture_output=True,
+ timeout=30,
+ )
+ assert (completed.returncode == 0) == success, completed.stdout + completed.stderr
+ return completed.stdout + completed.stderr
+
+ def version():
+ return subprocess.check_output([str(tmp_path / "bin/powercontext"), "--version"], env=env, text=True).strip()
+
+ try:
+ yield install, version, Index, env
+ finally:
+ server.shutdown()
+ server.server_close()
+ worker.join()
+
+
+@pytest.mark.parametrize("region", ["global", "cn"])
+def test_encoded_release_installs_through_public_script(installation, region):
+ install, version, _index, _env = installation
+ output = install("--region", region, "--version", "1.0.0")
+ assert version() == "1.0.0"
+ assert "Runtime installed: 1.0.0" in output
+
+
+def test_latest_stable_and_explicit_prerelease(installation):
+ install, version, _index, _env = installation
+ install("--region", "cn")
+ assert version() == "1.1.0"
+ install("--region", "cn", "--version", "1.2.0rc1")
+ assert version() == "1.2.0rc1"
+ install("--region", "cn")
+ assert version() == "1.1.0"
+
+
+def test_unavailable_automatic_mirror_falls_back_before_tool_install(installation):
+ install, version, index, _env = installation
+ index.mirror_status = 503
+ output = install("--region", "cn", "--version", "1.0.0")
+ assert version() == "1.0.0"
+ assert "Automatic package mirror unavailable; using PyPI." in output
+ assert any(path.startswith("/global/") for path in index.requests)
+
+
+def test_reachable_stale_mirror_preserves_request_and_installed_runtime(installation):
+ install, version, index, _env = installation
+ install("--region", "global", "--version", "1.1.0")
+ index.requests.clear()
+ index.mirror_versions = ["1.0.0"]
+ output = install("--region", "cn", "--version", "1.2.0rc1", success=False)
+ assert "Installation failed" in output
+ assert version() == "1.1.0"
+ assert not any(path.startswith("/global/") for path in index.requests)
+
+
+def test_explicit_failed_index_has_no_automatic_fallback(installation):
+ install, _version, index, _env = installation
+ index.mirror_status = 403
+ install("--region", "cn", "--index-url", "https://explicit.invalid/simple", "--version", "1.0.0", success=False)
+ assert not any(path.startswith("/global/") for path in index.requests)
+
+
+def test_unrelated_config_preserved_and_region_does_not_override_index(installation):
+ install, version, index, env = installation
+ config = Path(env["XDG_CONFIG_HOME"]) / "uv/uv.toml"
+ config.parent.mkdir(parents=True)
+ config.write_text("compile-bytecode = false\n", encoding="utf-8")
+ original = config.read_bytes()
+ # Existing uv settings provide the local source while conservative suppression preserves config.
+ output = install("--region", "cn", "--version", "1.0.0", extra={"UV_DEFAULT_INDEX": "https://pypi.org/simple"})
+ assert version() == "1.0.0"
+ assert "Using existing uv configuration" in output
+ assert config.read_bytes() == original
+ assert not any(path.startswith("/cn/") for path in index.requests)
diff --git a/tests/native/test_installation.py b/tests/native/test_installation.py
new file mode 100644
index 0000000000..5b9c20d39b
--- /dev/null
+++ b/tests/native/test_installation.py
@@ -0,0 +1,382 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Acceptance through the installed CLI and a real HTTP Server, without model services."""
+
+from __future__ import annotations
+
+import email
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import time
+import zipfile
+from collections.abc import Iterator
+from contextlib import contextmanager
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+import httpx
+import pytest
+from dotenv import dotenv_values
+
+ROOT = Path(__file__).parents[2]
+WHEEL = os.environ.get("POWERCONTEXT_INSTALL_WHEEL")
+pytestmark = pytest.mark.skipif(not WHEEL, reason="set POWERCONTEXT_INSTALL_WHEEL to run installation acceptance")
+
+
+def run(
+ command: list[str], root: Path, env: dict[str, str], name: str, *, stdin: str = "", success: bool = True
+) -> str:
+ with (root / f"{name}.log").open("w", encoding="utf-8") as log:
+ result = subprocess.run(
+ command,
+ cwd=root,
+ env=env,
+ input=stdin,
+ text=True,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ timeout=300,
+ check=False,
+ )
+ output = (root / f"{name}.log").read_text(encoding="utf-8", errors="replace")
+ assert (result.returncode == 0) == success, output
+ return output
+
+
+def request(url: str, token: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
+ with httpx.Client(trust_env=False, timeout=5, headers=headers) as client:
+ response = client.get(url) if body is None else client.post(url, json=body)
+ response.raise_for_status()
+ return response.json()
+
+
+@contextmanager
+def running_server(cli: str, root: Path, env: dict[str, str], token: str, name: str) -> Iterator[str]:
+ with socket.socket() as listener:
+ listener.bind(("127.0.0.1", 0))
+ port = listener.getsockname()[1]
+ url = f"http://127.0.0.1:{port}"
+ log_path = root / f"{name}.log"
+ with log_path.open("w", encoding="utf-8") as log:
+ process = subprocess.Popen(
+ [cli, "server", "run", "--env-file", str(root / ".env"), "--host", "127.0.0.1", "--port", str(port)],
+ cwd=root,
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ )
+ try:
+ deadline = time.monotonic() + 90
+ while time.monotonic() < deadline and process.poll() is None:
+ try:
+ request(url + "/health/ready", token)
+ break
+ except (httpx.TransportError, httpx.HTTPStatusError):
+ time.sleep(0.2)
+ else:
+ pytest.fail(log_path.read_text(encoding="utf-8", errors="replace"))
+ yield url
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+
+
+def reinstall_offline(
+ installer: list[str], root: Path, env: dict[str, str], version: str, *, interactive: bool
+) -> None:
+ if not interactive:
+ run(installer, root, env, "reinstall-offline")
+ return
+ # A piped default install succeeds without implicitly opening Agent selection.
+ controller = """import errno, os, pty, sys
+pid, terminal = pty.fork()
+if pid == 0:
+ os.execv('/bin/bash', ['/bin/bash', '-o', 'pipefail', '-c',
+ 'cat "$1" | /bin/bash -s -- --version "$2"', 'installer-pipeline', *sys.argv[1:]])
+try:
+ os.write(terminal, b'\\n')
+ while True:
+ try:
+ data = os.read(terminal, 4096)
+ except OSError as error:
+ if error.errno == errno.EIO:
+ break
+ raise
+ if not data:
+ break
+ os.write(1, data)
+finally:
+ os.close(terminal)
+_, status = os.waitpid(pid, 0)
+sys.exit(os.waitstatus_to_exitcode(status))
+"""
+ output = run(
+ [sys.executable, "-c", controller, str(ROOT / "website/public/install.sh"), str(version)],
+ root,
+ env,
+ "reinstall-offline",
+ )
+ assert "Runtime installed:" in output
+ assert "Select hosts" not in output
+
+
+def utilities_without_python_or_uv(root: Path, system_path: str) -> Path:
+ """Keep OS utilities available while removing Python and uv from PATH discovery."""
+ utilities = root / "utilities"
+ utilities.mkdir()
+ for directory in system_path.split(os.pathsep):
+ for executable in Path(directory).iterdir():
+ if executable.name.startswith(("python", "pypy", "uv")) or not executable.is_file():
+ continue
+ target = utilities / executable.name
+ if not target.exists():
+ target.symlink_to(executable)
+ return utilities
+
+
+@pytest.mark.parametrize("environment", ["bootstrap-global", "bootstrap-cn", "existing"])
+def test_install_configure_remember_and_reinstall(tmp_path: Path, environment: str) -> None:
+ assert WHEEL is not None
+ wheel = Path(WHEEL).resolve()
+ with zipfile.ZipFile(wheel) as archive:
+ metadata = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
+ version = email.message_from_bytes(archive.read(metadata))["Version"]
+ # The direct URL selects this build; a file URL hash fragment is not an integrity check.
+ constraints = tmp_path / "constraints.txt"
+ constraints.write_text(f"powercontext @ {wheel.as_uri()}\n", encoding="utf-8")
+ home_dir = tmp_path / "home with spaces 测试"
+ home_dir.mkdir()
+ env = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith(("UV_", "PIP_", "PYTHON", "POWERCONTEXT_")) and key != "VIRTUAL_ENV"
+ }
+ env.update(
+ HOME=str(home_dir),
+ USERPROFILE=str(home_dir),
+ # These subprocesses write UTF-8 log files, rather than a Windows console.
+ PYTHONIOENCODING="utf-8",
+ UV_TOOL_DIR=str(tmp_path / "tools"),
+ UV_TOOL_BIN_DIR=str(tmp_path / "bin"),
+ UV_CACHE_DIR=str(tmp_path / "cache"),
+ UV_PYTHON_INSTALL_DIR=str(tmp_path / "python"),
+ UV_NO_CONFIG="1",
+ UV_CONSTRAINT=str(constraints),
+ UV_NO_PROGRESS="1",
+ POWERCONTEXT_HOME=str(tmp_path / "state"),
+ )
+ if sys.platform == "win32":
+ # Python inherits PowerShell 7 module paths, which Windows PowerShell cannot load.
+ env.pop("PSMODULEPATH", None)
+ shell = shutil.which("powershell.exe")
+ assert shell is not None
+ installer = [
+ shell,
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ str(ROOT / "website/public/install.ps1"),
+ ]
+ if environment == "bootstrap-cn":
+ # Exercise the documented downloaded-scriptblock entry point as well as -File.
+ wrapper = tmp_path / "downloaded-installer.ps1"
+ script_path = str(ROOT / "website/public/install.ps1").replace("'", "''")
+ wrapper.write_text(
+ f"& ([scriptblock]::Create([IO.File]::ReadAllText('{script_path}'))) @args\n", encoding="utf-8"
+ )
+ installer[-1] = str(wrapper)
+ cli = str(tmp_path / "bin/powercontext.exe")
+ system_path = os.pathsep.join([str(Path(os.environ["SYSTEMROOT"]) / "System32"), os.environ["SYSTEMROOT"]])
+ else:
+ installer = ["/bin/bash", str(ROOT / "website/public/install.sh")]
+ cli = str(tmp_path / "bin/powercontext")
+ system_path = os.defpath
+ installer += ["--no-hosts", "--version", str(version)]
+ if environment.startswith("bootstrap"):
+ if sys.platform == "win32":
+ env["PATH"] = system_path
+ env["UV_PYTHON_NO_REGISTRY"] = "1"
+ else:
+ env["PATH"] = str(utilities_without_python_or_uv(tmp_path, system_path))
+ env.update(TZ="Asia/Shanghai", LC_ALL="C", LANG="en_US.UTF-8")
+ if environment == "bootstrap-global":
+ env["POWERCONTEXT_INSTALL_REGION"] = "cn"
+ installer += ["--region", "global"]
+ else:
+ env.pop("UV_NO_CONFIG")
+ uv_config = tmp_path / "uv.toml"
+ uv_config.write_text('[[index]]\nurl = "https://pypi.org/simple"\ndefault = true\n', encoding="utf-8")
+ env.update(
+ TZ="UTC",
+ LC_ALL="zh_CN.UTF-8",
+ UV_CONFIG_FILE=str(uv_config),
+ UV_PYTHON_DOWNLOADS="never",
+ UV_PYTHON_INSTALL_MIRROR="https://127.0.0.1:9/unavailable",
+ POWERCONTEXT_UV_INSTALLER_URL="https://127.0.0.1:9/unavailable",
+ )
+ run(installer, tmp_path, env, "install")
+ if environment == "existing":
+ # A fresh tool/cache forces resolution; exact reinstallation may legitimately reuse the existing tool.
+ failed_source_env = {
+ **env,
+ "UV_TOOL_DIR": str(tmp_path / "failed-source-tools"),
+ "UV_TOOL_BIN_DIR": str(tmp_path / "failed-source-bin"),
+ "UV_CACHE_DIR": str(tmp_path / "failed-source-cache"),
+ }
+ run(
+ [*installer, "--index-url", "https://127.0.0.1:9/simple"],
+ tmp_path,
+ failed_source_env,
+ "explicit-index",
+ success=False,
+ )
+ assert run([cli, "--version"], tmp_path, env, "version").strip() == version
+ run([cli, "config", "init", "--template", "--output", ".env"], tmp_path, env, "configure", stdin="\n")
+ config = (tmp_path / ".env").read_bytes()
+ run([cli, "config", "validate", "--env-file", ".env"], tmp_path, env, "validate")
+ token = dotenv_values(tmp_path / ".env").get("POWERCONTEXT_SERVER_AUTH_TOKEN") or ""
+ text = "Installation acceptance preserves the release decision."
+ with running_server(cli, tmp_path, env, token, "server") as url:
+ scope = request(url + "/v1/scopes/default", token)["scope_id"]
+ request(url + "/v1/memory/remember", token, {"scope_id": scope, "kind": "decision", "text": text})
+ query = {"scope_id": scope, "query": "release decision", "mode": "fts"}
+ found = request(url + "/v1/memory/search", token, query)
+ assert text in [hit["text"] for hit in found["hits"]]
+ env.update(
+ UV_OFFLINE="1", UV_PYTHON_DOWNLOADS="never", POWERCONTEXT_UV_INSTALLER_URL="https://127.0.0.1:9/unavailable"
+ )
+ reinstall_offline(
+ installer, tmp_path, env, str(version), interactive=environment == "existing" and sys.platform != "win32"
+ )
+ assert (tmp_path / ".env").read_bytes() == config
+ with running_server(cli, tmp_path, env, token, "server-restarted") as url:
+ found = request(url + "/v1/memory/search", token, query)
+ assert text in [hit["text"] for hit in found["hits"]]
+
+
+def test_latest_exact_versions_and_client_profile(tmp_path: Path) -> None:
+ """Resolve real wheels from a controlled index with stable and prerelease candidates."""
+ assert WHEEL is not None
+ uv = shutil.which("uv")
+ assert uv is not None
+ repository = tmp_path / "index"
+ packages = repository / "powercontext"
+ packages.mkdir(parents=True)
+ shutil.copyfile(WHEEL, packages / Path(WHEEL).name)
+ for version in ("1.2.1", "1.2.2rc1"):
+ run(
+ [uv, "build", "--project", str(ROOT), "--wheel", "--out-dir", str(packages)],
+ tmp_path,
+ {**os.environ, "SETUPTOOLS_SCM_PRETEND_VERSION": version},
+ f"build-{version}",
+ )
+ (packages / "index.html").write_text(
+ "\n".join(f'{wheel.name}' for wheel in sorted(packages.glob("*.whl"))),
+ encoding="utf-8",
+ )
+ env = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith(("UV_", "PIP_", "PYTHON", "POWERCONTEXT_")) and key != "VIRTUAL_ENV"
+ }
+ env.update(
+ PYTHONIOENCODING="utf-8",
+ UV_NO_CONFIG="1",
+ UV_TOOL_DIR=str(tmp_path / "tools"),
+ UV_TOOL_BIN_DIR=str(tmp_path / "bin with spaces"),
+ UV_CACHE_DIR=str(tmp_path / "cache"),
+ UV_NO_PROGRESS="1",
+ POWERCONTEXT_HOME=str(tmp_path / "state"),
+ )
+ if sys.platform == "win32":
+ shell = shutil.which("powershell.exe")
+ assert shell is not None
+ installer = [
+ shell,
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ str(ROOT / "website/public/install.ps1"),
+ ]
+ cli = str(tmp_path / "bin with spaces/powercontext.exe")
+ python = str(tmp_path / "tools/powercontext/Scripts/python.exe")
+ else:
+ installer = ["/bin/bash", str(ROOT / "website/public/install.sh")]
+ cli = str(tmp_path / "bin with spaces/powercontext")
+ python = str(tmp_path / "tools/powercontext/bin/python")
+ server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(repository)))
+ worker = Thread(target=server.serve_forever, daemon=True)
+ worker.start()
+ env["UV_INDEX"] = f"http://127.0.0.1:{server.server_port}"
+ try:
+ # Contradictory host intent must fail before any tool installation.
+ run([*installer, "--host", "codex", "--no-hosts"], tmp_path, env, "host-choice-conflict", success=False)
+ assert not Path(cli).exists()
+ client_install = [*installer, "--no-hosts", "--profile", "client"]
+ run([*client_install, "--version", "1.2.0"], tmp_path, env, "install-old")
+ assert run([cli, "--version"], tmp_path, env, "old-version").strip() == "1.2.0"
+ run(client_install, tmp_path, env, "install-latest")
+ assert run([cli, "--version"], tmp_path, env, "latest-version").strip() == "1.2.1"
+ if shutil.which("git"):
+ output = run(
+ [*installer, "--profile", "client", "--host", "unknown-host"],
+ tmp_path,
+ env,
+ "integration-failure",
+ success=False,
+ )
+ assert "Runtime installed" in output
+ assert "powercontext-v1.2.1" in output
+ assert run([cli, "--version"], tmp_path, env, "retained-runtime").strip() == "1.2.1"
+ run(
+ [python, "-c", "from importlib.util import find_spec; assert find_spec('fastapi') is None"],
+ tmp_path,
+ env,
+ "client-without-server",
+ )
+ assert not (tmp_path / "state").exists()
+ run([*client_install, "--version", "1.2.2rc1"], tmp_path, env, "install-prerelease")
+ assert run([cli, "--version"], tmp_path, env, "prerelease-version").strip() == "1.2.2rc1"
+ run([*client_install, "--version", "99.0.0"], tmp_path, env, "missing-version", success=False)
+ assert run([cli, "--version"], tmp_path, env, "preserved-version").strip() == "1.2.2rc1"
+ run(client_install, tmp_path, env, "stable-after-prerelease")
+ assert run([cli, "--version"], tmp_path, env, "restored-stable-version").strip() == "1.2.1"
+ run([*installer, "--no-hosts", "--version", "1.2.0"], tmp_path, env, "switch-to-local")
+ run([python, "-c", "import fastapi"], tmp_path, env, "local-includes-server")
+ run([*client_install, "--version", "1.2.0"], tmp_path, env, "switch-to-client")
+ run(
+ [python, "-c", "from importlib.util import find_spec; assert find_spec('fastapi') is None"],
+ tmp_path,
+ env,
+ "server-dependencies-removed",
+ )
+ finally:
+ server.shutdown()
+ worker.join(timeout=5)
+ server.server_close()
diff --git a/tests/native/test_service_installation.py b/tests/native/test_service_installation.py
new file mode 100644
index 0000000000..a2d7cc9135
--- /dev/null
+++ b/tests/native/test_service_installation.py
@@ -0,0 +1,145 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Installed-script acceptance on disposable native macOS/Linux user-service runners."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+from pathlib import Path
+
+import httpx
+import pytest
+
+from powercontext.cli.config import write_environment
+
+ROOT = Path(__file__).resolve().parents[2]
+WHEEL = os.environ.get("POWERCONTEXT_INSTALL_WHEEL")
+pytestmark = [
+ pytest.mark.native_service,
+ pytest.mark.skipif(
+ os.environ.get("POWERCONTEXT_RUN_NATIVE_SERVICE_TESTS") != "1"
+ or not WHEEL
+ or sys.platform not in {"linux", "darwin"},
+ reason="requires a release-shaped wheel and a disposable native user-service runner",
+ ),
+]
+
+
+def test_installer_service_reconciles_configuration_and_preserves_memory_after_upgrade(tmp_path: Path) -> None:
+ assert WHEEL is not None
+ uv = shutil.which("uv")
+ assert uv is not None
+ environment = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith(("POWERCONTEXT_", "UV_", "PIP_", "PYTHON")) and key != "VIRTUAL_ENV"
+ }
+ constraints = tmp_path / "constraints.txt"
+ constraints.write_text(f"powercontext @ {Path(WHEEL).resolve().as_uri()}\n")
+ environment.update(
+ UV_CACHE_DIR=os.environ.get("UV_CACHE_DIR", str(Path.home() / ".cache" / "uv")),
+ UV_NO_CONFIG="1",
+ UV_OFFLINE="1",
+ UV_PYTHON_DOWNLOADS="never",
+ UV_CONSTRAINT=str(constraints),
+ UV_TOOL_DIR=str(tmp_path / "tools"),
+ UV_TOOL_BIN_DIR=str(tmp_path / "bin"),
+ )
+ cli = str(tmp_path / "bin" / "powercontext")
+ configuration = tmp_path / "personal server.env"
+ with socket.socket() as listener:
+ listener.bind(("127.0.0.1", 0))
+ port = listener.getsockname()[1]
+ token = "native-installation-token" # noqa: S105
+ content = (
+ f"POWERCONTEXT_HOME='{tmp_path / 'data'}'\n"
+ "POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\n"
+ f"POWERCONTEXT_SERVER_HTTP_PORT={port}\n"
+ "POWERCONTEXT_SERVER_ACCESS_MODE=enforced\n"
+ f"POWERCONTEXT_SERVER_AUTH_TOKEN={token}\n"
+ )
+ write_environment(configuration, content, backup=False)
+ installer = ["/bin/bash", str(ROOT / "website/public/install.sh"), "--region", "global"]
+
+ def run(command: list[str], name: str) -> str:
+ result = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=300)
+ output = result.stdout + result.stderr
+ (tmp_path / f"{name}.log").write_text(output)
+ assert result.returncode == 0, output
+ return result.stdout
+
+ # This runner is disposable. Never replace an existing personal service.
+ before = subprocess.run(
+ [str(Path(sys.executable).with_name("powercontext")), "service", "status", "--json"],
+ env=environment,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert json.loads(before.stdout)["registration"] == "not_installed", before.stdout + before.stderr
+ try:
+ output = run([*installer, "--version", "1.2.0", "--service", "--env-file", str(configuration)], "install")
+ assert "Personal service verified with:" in output
+ status = json.loads(run([cli, "service", "status", "--json"], "status"))
+ assert status["manager"] == "active"
+ assert status["server_liveness"] == "live"
+ endpoint = f"http://127.0.0.1:{port}"
+ with httpx.Client(base_url=endpoint, headers={"Authorization": f"Bearer {token}"}, trust_env=False) as client:
+ scope = client.get("/v1/scopes/default").json()["scope_id"]
+ response = client.post(
+ "/v1/memory/remember",
+ json={"scope_id": scope, "kind": "decision", "text": "Keep native service memory."},
+ )
+ response.raise_for_status()
+
+ content += "POWERCONTEXT_CODEX_SCOPE_ID=scope_example\n"
+ write_environment(configuration, content, backup=False)
+ # Stale status intentionally exits nonzero; inspect its public JSON separately.
+ stale = subprocess.run(
+ [cli, "service", "status", "--json"], env=environment, capture_output=True, text=True, timeout=30
+ )
+ assert stale.returncode == 1
+ assert json.loads(stale.stdout)["definition"] == "stale"
+ run([*installer, "--version", "1.2.0", "--service", "--env-file", str(configuration)], "reconcile")
+
+ run([cli, "service", "uninstall"], "stop-before-upgrade")
+ upgraded = tmp_path / "upgraded"
+ build = subprocess.run(
+ [uv, "build", "--offline", "--wheel", "--out-dir", str(upgraded)],
+ cwd=ROOT,
+ env={**environment, "SETUPTOOLS_SCM_PRETEND_VERSION": "1.2.1"},
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+ assert build.returncode == 0, build.stdout + build.stderr
+ constraints.write_text(f"powercontext @ {next(upgraded.glob('*.whl')).as_uri()}\n")
+ run([*installer, "--version", "1.2.1", "--service", "--env-file", str(configuration)], "upgrade")
+ assert run([cli, "--version"], "upgraded-version").strip() == "1.2.1"
+ assert configuration.read_text() == content
+ with httpx.Client(base_url=endpoint, headers={"Authorization": f"Bearer {token}"}, trust_env=False) as client:
+ response = client.post(
+ "/v1/memory/search", json={"scope_id": scope, "query": "native service memory", "mode": "fts"}
+ )
+ response.raise_for_status()
+ assert any(hit["text"] == "Keep native service memory." for hit in response.json()["hits"])
+ finally:
+ if Path(cli).exists():
+ run([cli, "service", "uninstall"], "cleanup")
diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py
index d23f48f76b..008eb9a70e 100644
--- a/tests/test_config_cli.py
+++ b/tests/test_config_cli.py
@@ -48,10 +48,10 @@ def test_init_creates_a_model_free_deployment_and_explains_capability_limits(tmp
assert "the value is never printed" in result.output
assert "Configuration" in result.output
assert "Supported Coding Agents (choose one)" in result.output
- for host, (name, setup, launch) in config_cli.AGENTS.items():
+ for host, (name, launch) in config_cli.AGENTS.items():
assert name in result.output
- if host != "dsh":
- assert setup in result.output
+ assert f" {host} --source " in result.output
+ assert f"--env-file {environment}" in result.output
assert launch in result.output
values = config_cli.parse_environment(environment.read_text(encoding="utf-8"))
assert "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL" not in values
@@ -63,22 +63,6 @@ def test_init_creates_a_model_free_deployment_and_explains_capability_limits(tmp
assert shown.exit_code == 0
-@pytest.mark.parametrize("installed", ["0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0", "1.1.0", "0.2.1.dev1+g1234567"])
-def test_init_matches_dsh_setup_to_installed_server(installed: str, tmp_path: Path, monkeypatch) -> None:
- monkeypatch.setattr(config_cli, "version", lambda _name: installed)
- monkeypatch.setattr(config_cli, "collect_configuration", lambda **_kwargs: _configuration())
- result = CliRunner().invoke(
- config_cli.app, ["init", "--template", "--output", str(tmp_path / "server.env")], input="\n"
- )
- assert result.exit_code == 0
- command = next(line.strip() for line in result.output.splitlines() if "powercontext setup dsh" in line)
- if installed in {"0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0", "1.1.0"}:
- assert command.endswith(f"--ref powercontext-v{installed}")
- else:
- assert "--source /path/to/matching-powercontext-checkout" in command
- assert "--ref master" not in command
-
-
def test_arbitrary_model_providers_and_environment_variables_are_not_rejected() -> None:
configuration = _configuration(
generation=config_cli.ModelSelection(
@@ -404,18 +388,28 @@ def test_init_refuses_to_replace_an_existing_environment_without_force(
assert environment.read_text(encoding="utf-8") == "EXISTING=value\n"
-def test_init_force_defaults_to_preserving_existing_inference_configuration(tmp_path: Path) -> None:
+@pytest.mark.parametrize("require_write", [False, True])
+def test_init_force_defaults_to_preserving_existing_inference_configuration(
+ tmp_path: Path, require_write: bool
+) -> None:
environment = tmp_path / ".env"
original = config_cli.render_managed_block(_configuration())
environment.write_text(original, encoding="utf-8")
result = CliRunner().invoke(
config_cli.app,
- ["init", "--template", "--output", str(environment), "--force"],
+ [
+ "init",
+ "--template",
+ "--output",
+ str(environment),
+ "--force",
+ *(["--require-write"] if require_write else []),
+ ],
input="\n",
)
- assert result.exit_code == 0
+ assert result.exit_code == (130 if require_write else 0)
assert "will remove existing model, embedding, inference schedule, or provider credential settings" in result.output
assert "A mode-0600 backup will be created" in result.output
assert "Replace them with a model-free configuration? [y/N]" in result.output
diff --git a/tests/test_config_wizard.py b/tests/test_config_wizard.py
index fe8094d20d..0b453fb38f 100644
--- a/tests/test_config_wizard.py
+++ b/tests/test_config_wizard.py
@@ -269,7 +269,7 @@ def test_openclaw_next_steps_use_plugin_configuration_contract(tmp_path: Path) -
client = parse_environment(output.read_text())
assert not any(name.startswith("POWERCONTEXT_OPENCLAW_") for name in client)
steps = output.with_name("server.env.next-steps.md").read_text()
- assert "powercontext setup openclaw" in steps
+ assert f"powercontext setup --env-file {output} openclaw" in steps
assert "--server-url http://127.0.0.1:17429" in steps
assert "plugins.entries.memory-powercontext.config.endpoint" in steps
assert "plugins.entries.memory-powercontext.config.autoCapture true" in steps
@@ -298,19 +298,34 @@ def test_base_wizard_writes_private_environment_without_models(tmp_path: Path) -
assert CliRunner().invoke(app, ["validate", "--env-file", str(output)]).exit_code == 0
-def test_cancel_in_chinese_does_not_write_files(tmp_path: Path) -> None:
+@pytest.mark.parametrize("require_write", [False, True])
+def test_cancel_in_chinese_does_not_write_files(tmp_path: Path, require_write: bool) -> None:
output = tmp_path / "server.env"
result = CliRunner().invoke(
app,
- ["init", "--language", "zh", "--output", str(output)],
+ ["init", "--language", "zh", "--output", str(output), *(["--require-write"] if require_write else [])],
input=f"sqlite\n{tmp_path / 'context.db'}\nlocal\nbase\nn\n\nnone\ny\nn\n",
)
- assert result.exit_code == 0, result.output
+ assert result.exit_code == (130 if require_write else 0), result.output
assert "配置向导" in result.output
assert "未写入" in result.output
assert not list(tmp_path.iterdir())
+def test_required_write_cancellation_preserves_existing_configuration(tmp_path: Path) -> None:
+ output = tmp_path / "server.env"
+ original = "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_HTTP_PORT=18000\nCUSTOM_FLAG=keep\n"
+ output.write_text(original)
+ result = CliRunner().invoke(
+ app,
+ ["init", "--language", "en", "--output", str(output), "--require-write"],
+ input=f"sqlite\n{tmp_path / 'context.db'}\nreuse\nn\n",
+ )
+ assert result.exit_code == 130, result.output
+ assert output.read_text() == original
+ assert not output.with_name("server.env.next-steps.md").exists()
+
+
def test_existing_server_skips_server_models_and_storage(tmp_path: Path) -> None:
output = tmp_path / "client.env"
result = CliRunner().invoke(
diff --git a/tests/test_config_wizard_installation.py b/tests/test_config_wizard_installation.py
index 1412b38c05..0b427fc894 100644
--- a/tests/test_config_wizard_installation.py
+++ b/tests/test_config_wizard_installation.py
@@ -19,7 +19,9 @@
from pathlib import Path
import pytest
+from typer.testing import CliRunner
+import powercontext.cli.config as config_cli
import powercontext.cli.config_wizard as wizard
from powercontext.cli.config_wizard_ui import WizardUI
@@ -84,6 +86,39 @@ def test_checkout_instructions_keep_a_verified_local_source(tmp_path, monkeypatc
assert "--ref" not in instructions
+@pytest.mark.parametrize("installed", ["0.2.0", "1.0.0rc1", "1.0.0", "1.1.0"])
+def test_index_release_instructions_pin_every_agent_and_the_selected_file(tmp_path, monkeypatch, installed) -> None:
+ module = _installed_package(tmp_path, monkeypatch, None)
+ metadata = module.parents[2] / "powercontext-0.1.dist-info" / "METADATA"
+ metadata.write_text(f"Metadata-Version: 2.1\nName: powercontext\nVersion: {installed}\n")
+ monkeypatch.setattr(config_cli, "__file__", str(module))
+ environment = tmp_path / "server.env"
+
+ result = CliRunner().invoke(config_cli.app, ["init", "--template", "--output", str(environment)], input="\n")
+
+ assert result.exit_code == 0, result.output
+ for agent in config_cli.AGENTS:
+ assert (
+ f"powercontext setup --env-file {environment} {agent} --source oceanbase/powercontext "
+ f"--ref powercontext-v{installed}"
+ ) in result.output
+ instructions = _instructions()
+ for agent in ("codex", "claude-code"):
+ assert (
+ f"powercontext setup {agent} --source oceanbase/powercontext --ref powercontext-v{installed}"
+ in instructions
+ )
+ assert "--ref master" not in instructions
+
+
+def test_unreleased_index_package_requires_an_explicit_matching_source(tmp_path, monkeypatch) -> None:
+ module = _installed_package(tmp_path, monkeypatch, None)
+ metadata = module.parents[2] / "powercontext-0.1.dist-info" / "METADATA"
+ metadata.write_text("Metadata-Version: 2.1\nName: powercontext\nVersion: 0.2.1.dev1+g1234567\n")
+ assert "powercontext setup codex --help" in _instructions()
+ assert "--ref master" not in _instructions()
+
+
@pytest.mark.parametrize(
"direct_url",
[
diff --git a/tests/test_config_wizard_next_steps.py b/tests/test_config_wizard_next_steps.py
index fe8b9eb2e7..e4855aebfd 100644
--- a/tests/test_config_wizard_next_steps.py
+++ b/tests/test_config_wizard_next_steps.py
@@ -77,11 +77,39 @@ def test_scope_binding_reloads_client_environment_before_agent_installation(tmp_
steps = wizard._next_steps(state, tmp_path / ".env", client_file)
creation = steps.index("POST http://127.0.0.1:8000/v1/scopes")
- installation = steps.index("powercontext setup claude-code")
+ installation = steps.index(" claude-code --source")
assert f". {client_file}" in steps[creation:installation]
+ assert f"powercontext setup --env-file {client_file} claude-code" in steps
assert "Reload the edited client environment before starting a new Agent" in steps
+@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"])
+def test_startup_guidance_matches_personal_service_support(tmp_path, monkeypatch, platform) -> None:
+ import powercontext.cli.config_startup as startup
+
+ monkeypatch.setattr(startup.sys, "platform", platform)
+ state = _state()
+ environment = tmp_path / ".env"
+ steps = wizard._next_steps(state, environment, environment)
+ creation = steps.index("POST http://127.0.0.1:8000/v1/scopes")
+ installation = steps.index(" claude-code --source")
+ if platform == "win32":
+ assert "powercontext server run --env-file" in steps[:creation]
+ assert "powercontext service install" not in steps
+ else:
+ assert "powercontext service install --env-file" in steps[:creation]
+ assert "powercontext doctor --env-file" in steps[:creation]
+ assert "powercontext service install --env-file" in steps[creation:installation]
+
+
+def test_managed_bind_keeps_foreground_startup_guidance(tmp_path) -> None:
+ state = _state()
+ state.values["POWERCONTEXT_SERVER_HTTP_HOST"] = "0.0.0.0" # noqa: S104 - managed-bind guidance
+ steps = wizard._next_steps(state, tmp_path / ".env", tmp_path / ".env")
+ assert "powercontext server run --env-file" in steps
+ assert "powercontext service install" not in steps
+
+
def _profile_script() -> str:
state = _state()
state.features = {"profile"}
diff --git a/tests/test_doctor_environment.py b/tests/test_doctor_environment.py
new file mode 100644
index 0000000000..94b3bce673
--- /dev/null
+++ b/tests/test_doctor_environment.py
@@ -0,0 +1,118 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Explicit-file diagnostics reach the configured HTTP endpoint without shell evaluation."""
+
+import json
+import os
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from threading import Thread
+
+import pytest
+from typer.testing import CliRunner
+
+from powercontext.cli.app import create_cli
+from powercontext.cli.system import doctor_app
+
+
+@pytest.fixture
+def health_server():
+ class Handler(BaseHTTPRequestHandler):
+ readiness = "ready"
+
+ def log_message(self, format, *args): # noqa: A002
+ pass
+
+ def do_GET(self):
+ payload = (
+ {"status": "ok"}
+ if self.path == "/health/live"
+ else {"status": self.readiness, "checks": {"database": "ready"}}
+ )
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(payload).encode())
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ worker = Thread(target=server.serve_forever, daemon=True)
+ worker.start()
+ try:
+ yield server.server_port, Handler
+ finally:
+ server.shutdown()
+ server.server_close()
+ worker.join()
+
+
+@pytest.mark.parametrize("server_file", [True, False])
+@pytest.mark.parametrize("readiness", ["ready", "degraded"])
+@pytest.mark.parametrize("lowercase_names", [False, True])
+def test_doctor_uses_selected_file_and_preserves_shell(
+ tmp_path, monkeypatch, health_server, server_file, readiness, lowercase_names
+):
+ port, handler = health_server
+ handler.readiness = readiness
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "http://127.0.0.1:1")
+ monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_PORT", "1")
+ monkeypatch.setenv("POWERCONTEXT_CLIENT_API_TOKEN", "caller-secret")
+ marker = tmp_path / "executed"
+ environment = tmp_path / "selected.env"
+ endpoint = f"http://127.0.0.1:{port}"
+ content = (
+ f"POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\nPOWERCONTEXT_SERVER_HTTP_PORT={port}\n"
+ "POWERCONTEXT_CLIENT_SERVER_URL=http://127.0.0.1:1\n"
+ if server_file
+ else f"POWERCONTEXT_CLIENT_SERVER_URL={endpoint}\n"
+ )
+ if lowercase_names:
+ content = content.lower()
+ environment.write_text(content + f"POWERCONTEXT_CLIENT_API_TOKEN='file-secret'\nCUSTOM='$(touch {marker})'\n")
+ result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "--json"])
+
+ assert result.exit_code == (0 if readiness == "ready" else 1), result.output
+ response = json.loads(result.output)
+ assert response["checks"]["server_readiness"]["detail"] == f"{endpoint} status={readiness}"
+ assert "file-secret" not in result.output
+ assert not marker.exists()
+ assert os.environ["POWERCONTEXT_CLIENT_API_TOKEN"] == "caller-secret" # noqa: S105
+ assert os.environ["POWERCONTEXT_SERVER_HTTP_PORT"] == "1"
+
+
+def test_doctor_explicit_url_overrides_file(tmp_path, health_server):
+ port, _handler = health_server
+ environment = tmp_path / "selected.env"
+ environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=1\n")
+ endpoint = f"http://127.0.0.1:{port}"
+ result = CliRunner().invoke(
+ create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "--server-url", endpoint, "--json"]
+ )
+ assert result.exit_code == 0, result.output
+ assert endpoint in json.loads(result.output)["checks"]["server_readiness"]["detail"]
+
+
+def test_doctor_rejects_missing_environment_file(tmp_path):
+ result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(tmp_path / "missing.env")])
+ assert result.exit_code == 2
+ assert "Cannot diagnose the environment file" in result.output
+
+
+def test_doctor_rejects_file_option_for_integration_diagnostics(tmp_path):
+ environment = tmp_path / "selected.env"
+ environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=8000\n")
+ result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "codex"])
+ assert result.exit_code == 2
+ assert "without an Agent selection" in result.output
diff --git a/tests/test_installation_contract.py b/tests/test_installation_contract.py
new file mode 100644
index 0000000000..f5581aae70
--- /dev/null
+++ b/tests/test_installation_contract.py
@@ -0,0 +1,269 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Native installer conformance with real uv and offline synthetic distributions."""
+
+import csv
+import io
+import json
+import os
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+CASES = [
+ case
+ for path in sorted((ROOT / "tests/fixtures/installation").glob("*.json"))
+ for case in json.loads(path.read_text())["cases"]
+]
+CLI = """import importlib.util
+import json
+import os
+from pathlib import Path
+import sys
+
+def main():
+ args = sys.argv[1:]
+ if args == ["--version"]:
+ print(os.environ.get("CONTRACT_REPORTED_VERSION", "1.2.0"))
+ elif not args or args[-1] == "--help":
+ command = " ".join(args[:-1]) if args else ""
+ missing = json.loads(os.environ.get("CONTRACT_MISSING_COMMANDS", "[]"))
+ if command in missing:
+ return 2
+ if command == "server run":
+ import contract_server
+ print("Synthetic installation contract CLI")
+ elif args[:2] == ["config", "init"]:
+ if not sys.stdin.isatty():
+ return 2
+ status = int(os.environ.get("CONTRACT_CONFIG_EXIT", "0"))
+ if status == 0:
+ Path(args[args.index("--output") + 1]).write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\\n")
+ return status
+ elif args[:2] == ["config", "validate"]:
+ return int(os.environ.get("CONTRACT_VALIDATE_EXIT", "0"))
+ elif args[:2] == ["service", "install"]:
+ env_file = args[args.index("--env-file") + 1]
+ Path(os.environ["CONTRACT_SERVICE_RESULT"]).write_text(json.dumps({"env_file": env_file}))
+ return int(os.environ.get("CONTRACT_SERVICE_EXIT", "0"))
+ elif args[:2] == ["service", "status"]:
+ return 0
+ elif args and args[0] == "doctor":
+ env_file = args[args.index("--env-file") + 1]
+ Path(os.environ["CONTRACT_DOCTOR_RESULT"]).write_text(json.dumps({"env_file": env_file}))
+ return int(os.environ.get("CONTRACT_DOCTOR_EXIT", "0"))
+ elif args and args[0] == "setup":
+ hosts = []
+ index = 1
+ while index < len(args):
+ if args[index].startswith("--"):
+ index += 2
+ else:
+ hosts.append(args[index])
+ index += 1
+ ref = args[args.index("--ref") + 1]
+ selected = {"hosts": hosts, "ref": ref}
+ if "--env-file" in args:
+ selected["env_file"] = args[args.index("--env-file") + 1]
+ Path(os.environ["CONTRACT_SETUP_RESULT"]).write_text(json.dumps(selected))
+ return int(os.environ.get("CONTRACT_SETUP_EXIT", "0"))
+ elif args == ["contract-capabilities"]:
+ print(json.dumps({"server_extra": importlib.util.find_spec("contract_server") is not None}))
+ else:
+ print("Synthetic installation contract CLI")
+ return 0
+"""
+
+
+def wheel(directory: Path, name: str, files: dict[str, str], extra_metadata: str = "") -> None:
+ """Build a tiny standards-shaped wheel using only stdlib ZIP/CSV."""
+ info = f"{name}-1.2.0.dist-info"
+ entries = files | {
+ f"{info}/METADATA": f"Metadata-Version: 2.3\nName: {name}\nVersion: 1.2.0\n{extra_metadata}\n",
+ f"{info}/WHEEL": "Wheel-Version: 1.0\nGenerator: installation-contract\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
+ }
+ record = io.StringIO()
+ writer = csv.writer(record)
+ writer.writerows((path, "", "") for path in entries)
+ writer.writerow((f"{info}/RECORD", "", ""))
+ entries[f"{info}/RECORD"] = record.getvalue()
+ with zipfile.ZipFile(directory / f"{name}-1.2.0-py3-none-any.whl", "w") as archive:
+ for path, content in entries.items():
+ archive.writestr(path, content)
+
+
+def executable_path(directory: Path, uv: str, windows: bool, include_git: bool) -> str:
+ """Expose real bootstrap prerequisites, optionally excluding Git."""
+ tools = directory / "tools"
+ tools.mkdir()
+ # Real executables only; uv creates the platform-native console launcher.
+ executables = {"uv": uv} if windows else {"uv": uv, "python": sys.executable}
+ for name, executable in executables.items():
+ destination = tools / (name + ".exe" if windows else name)
+ if windows:
+ shutil.copy2(executable, destination)
+ else:
+ destination.symlink_to(executable)
+ git = shutil.which("git")
+ if include_git and not git:
+ pytest.skip("explicit-host contract requires Git discovery")
+ if not windows:
+ for name in ("curl", "wget", "uname", "mktemp", "rm", "dirname", "cat", "sh", "readlink"):
+ executable = shutil.which(name)
+ if executable:
+ (tools / name).symlink_to(executable)
+ if include_git and git:
+ (tools / "git").symlink_to(git)
+ elif include_git and git:
+ # Presence check only: synthetic host setup never executes Git.
+ shutil.copy2(git, tools / "git.exe")
+ if windows:
+ return os.pathsep.join((
+ str(tools),
+ str(Path(sys.executable).parent),
+ str(Path(os.environ["SYSTEMROOT"]) / "System32"),
+ ))
+ return str(tools)
+
+
+@pytest.mark.parametrize("case", CASES, ids=lambda case: case["id"])
+def test_installation_contract(case: dict[str, Any], tmp_path: Path) -> None:
+ uv = shutil.which("uv")
+ if not uv:
+ pytest.skip("native installation contract requires real uv")
+ windows = sys.platform == "win32"
+ if case.get("terminal") and windows:
+ pytest.skip("controlling-terminal pipe regression requires POSIX")
+ shell = shutil.which("powershell") or shutil.which("pwsh") if windows else shutil.which("bash")
+ if not shell:
+ pytest.skip("native installer shell is unavailable")
+ path = executable_path(tmp_path, uv, windows, case.get("git", False))
+ packages = tmp_path / "packages"
+ packages.mkdir()
+ wheel(packages, "contract_server", {"contract_server.py": ""})
+ wheel(
+ packages,
+ "powercontext",
+ {
+ "powercontext_contract.py": CLI,
+ "powercontext-1.2.0.dist-info/entry_points.txt": "[console_scripts]\npowercontext = powercontext_contract:main\n",
+ },
+ "Provides-Extra: cli\nProvides-Extra: server\nRequires-Dist: contract-server==1.2.0; extra == 'server'\n",
+ )
+ environment = {
+ key: value
+ for key, value in os.environ.items()
+ if not key.startswith(("UV_", "POWERCONTEXT_", "PIP_", "PYTHON", "CONTRACT_")) and key != "VIRTUAL_ENV"
+ }
+ environment.update({
+ "PYTHONIOENCODING": "utf-8",
+ "HOME": str(tmp_path),
+ "USERPROFILE": str(tmp_path),
+ "TMPDIR": str(tmp_path),
+ "PATH": path,
+ "UV_TOOL_DIR": str(tmp_path / "tool"),
+ "UV_TOOL_BIN_DIR": str(tmp_path / "bin"),
+ "UV_CACHE_DIR": str(tmp_path / "cache"),
+ "UV_NO_CONFIG": "1",
+ "UV_NO_INDEX": "1",
+ "UV_FIND_LINKS": str(packages),
+ "UV_PYTHON_DOWNLOADS": "never",
+ "UV_OFFLINE": "1",
+ "CONTRACT_SETUP_RESULT": str(tmp_path / "setup.json"),
+ "CONTRACT_SETUP_EXIT": str(case.get("setup_exit", 0)),
+ "CONTRACT_REPORTED_VERSION": case.get("reported_version", "1.2.0"),
+ "CONTRACT_MISSING_COMMANDS": json.dumps(case.get("missing_commands", [])),
+ "CONTRACT_VALIDATE_EXIT": str(case.get("validate_exit", 0)),
+ "CONTRACT_CONFIG_EXIT": str(case.get("config_exit", 0)),
+ "CONTRACT_SERVICE_EXIT": str(case.get("service_exit", 0)),
+ "CONTRACT_DOCTOR_EXIT": str(case.get("doctor_exit", 0)),
+ "CONTRACT_SERVICE_RESULT": str(tmp_path / "service.json"),
+ "CONTRACT_DOCTOR_RESULT": str(tmp_path / "doctor.json"),
+ })
+ env_file = tmp_path / "existing configuration.env"
+ if case.get("environment_file"):
+ env_file.write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n", encoding="utf-8")
+ env_file.chmod(0o600)
+ arguments = [str(env_file) if value == "{env_file}" else value for value in case["args"]]
+ script = ROOT / "website/public" / ("install.ps1" if windows else "install.sh")
+ command = (
+ [shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script)] if windows else [shell, str(script)]
+ )
+ if case.get("terminal"):
+ controller = """import errno, os, pty, sys
+pid, terminal = pty.fork()
+if pid == 0:
+ shell, script, *args = sys.argv[1:]
+ os.execv(shell, [shell, '-o', 'pipefail', '-c',
+ 'cat "$1" | "$2" -s -- "${@:3}"', 'installation', script, shell, *args])
+try:
+ while True:
+ try:
+ data = os.read(terminal, 4096)
+ except OSError as error:
+ if error.errno == errno.EIO:
+ break
+ raise
+ if not data:
+ break
+ os.write(1, data)
+finally:
+ os.close(terminal)
+_, status = os.waitpid(pid, 0)
+sys.exit(os.waitstatus_to_exitcode(status))
+"""
+ command = [sys.executable, "-c", controller, *command]
+ result = subprocess.run(
+ [*command, "--region", "global", *arguments], env=environment, capture_output=True, text=True, timeout=60
+ )
+ assert_installation_outcome(case, tmp_path, result, environment)
+
+
+def assert_installation_outcome(case, tmp_path, result, environment) -> None:
+ expected = case["expected"]
+ windows = sys.platform == "win32"
+ env_file = tmp_path / "existing configuration.env"
+ if "configuration_saved" in expected:
+ assert env_file.exists() == expected["configuration_saved"]
+ assert result.returncode == expected["exit_code"], result.stdout + result.stderr
+ executable = tmp_path / "bin" / ("powercontext.exe" if windows else "powercontext")
+ assert executable.exists() == expected["runtime_installed"]
+ setup = tmp_path / "setup.json"
+ observed = json.loads(setup.read_text()) if setup.exists() else {"hosts": []}
+ assert observed["hosts"] == expected.get("selected_hosts", [])
+ if "setup_ref" in expected:
+ assert observed["ref"] == expected["setup_ref"]
+ if expected.get("setup_env_file"):
+ assert observed["env_file"] == str(env_file)
+ for action in ("service", "doctor"):
+ observed_file = tmp_path / f"{action}.json"
+ assert observed_file.exists() == expected.get(action, False)
+ if observed_file.exists():
+ assert json.loads(observed_file.read_text())["env_file"] == str(env_file)
+ if case.get("environment_file"):
+ assert env_file.read_text() == "POWERCONTEXT_SERVER_HTTP_PORT=18000\n"
+ if expected.get("verification_failed"):
+ assert "Runtime installed:" not in result.stdout
+ if "error_contains" in expected:
+ assert expected["error_contains"] in result.stdout + result.stderr
+ if expected["runtime_installed"] and "server_extra" in expected:
+ capabilities = subprocess.check_output([str(executable), "contract-capabilities"], env=environment, text=True)
+ assert json.loads(capabilities)["server_extra"] == expected["server_extra"]
diff --git a/tests/test_release_version.py b/tests/test_release_version.py
index 9629fd7c07..4c7cb1a73c 100644
--- a/tests/test_release_version.py
+++ b/tests/test_release_version.py
@@ -86,11 +86,10 @@ def test_release_updates_installation_references_and_preserves_history(release_r
for relative in INSTALL_PAGES:
content = (release_repo / relative).read_text(encoding="utf-8")
assert set(re.findall(r"powercontext\[[^\]\n]+\]==([^\s\"`]+)", content)) == {version}, relative
- assert set(re.findall(r"powercontext-v([^\s\"`]+)", content)) == {version}, relative
+ assert set(re.findall(r"powercontext-v([0-9][^\s\"`]+)", content)) == {version}, relative
if relative.startswith(("README", "docs/")) and not relative.endswith("/manual-start.md"):
assert f"PowerContext {version}" in content, relative
if re.search(r"(?:a|b|rc)[0-9]+$", version):
- assert "stable release" not in content, relative
assert f"stable PowerContext {version}" not in content, relative
assert "正式版本" not in content, relative
assert "正式リリース" not in content, relative
@@ -98,8 +97,10 @@ def test_release_updates_installation_references_and_preserves_history(release_r
english = (release_repo / "docs/en/docs/get-started/install-and-run.md").read_text(encoding="utf-8")
chinese = (release_repo / "docs/zh/docs/get-started/install-and-run.md").read_text(encoding="utf-8")
assert f"package `{version}`" in english
+ assert f"--version {version}" in english
assert f"To upgrade to {version}:" in english
assert f"Python 包版本为 `{version}`" in chinese
+ assert f"--version {version}" in chinese
assert f"升级到 {version}" in chinese
assert "Version 1.1.0 upgrades legacy" in english
assert "1.0.0 also need" in english
diff --git a/tests/test_service.py b/tests/test_service.py
index ece742aaf1..52f9de6bf7 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -385,6 +385,21 @@ def test_service_controller_installs_and_starts_one_native_registration(tmp_path
assert adapter.events == ["write", "reload", "enable", "start:True"]
+def test_service_install_rejects_client_only_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ environment = tmp_path / "client.env"
+ environment.write_text("POWERCONTEXT_CLIENT_SERVER_URL=https://memory.example.com\n")
+ environment.chmod(0o600)
+ _secure_windows_file(environment)
+ controller = ServiceController(FakeAdapter(tmp_path))
+ monkeypatch.setattr(service_cli, "_controller", lambda: controller)
+
+ result = CliRunner().invoke(service_app, ["install", "--env-file", str(environment), "--start-on-login"])
+
+ assert result.exit_code == 2, result.output
+ assert "contains only Client settings" in result.output
+ assert controller.registration_status().registration is RegistrationState.NOT_INSTALLED
+
+
def test_service_controller_allows_slow_native_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
adapter = FakeAdapter(tmp_path)
clock = 0.0
diff --git a/website/public/install.ps1 b/website/public/install.ps1
new file mode 100644
index 0000000000..8c51287048
--- /dev/null
+++ b/website/public/install.ps1
@@ -0,0 +1,404 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$Version = 'latest'
+$UvVersion = '0.12.23'
+$RuntimeProfile = 'local'
+$Region = 'auto'
+if ($env:POWERCONTEXT_INSTALL_REGION) { $Region = $env:POWERCONTEXT_INSTALL_REGION }
+$IndexUrl = ''
+$Hosts = @()
+$NoHosts = $false
+$Configure = $false
+$InstallService = $false
+$EnvFile = ''
+$Uv = ''
+$TempDir = $null
+
+function Show-Help {
+ Write-Host @'
+Install PowerContext on Windows. Python and uv need not be installed.
+
+Usage: powershell -ExecutionPolicy Bypass -File install.ps1 [options]
+
+ --version VERSION Exact release version, or latest (default: latest stable).
+ --profile PROFILE local (CLI and Server, default) or client (CLI and Client).
+ --region REGION auto, cn, or global (default: POWERCONTEXT_INSTALL_REGION or auto).
+ --index-url URL HTTPS default package index for this installation.
+ --host HOST Install an Agent integration; repeat for multiple hosts.
+ --no-hosts Compatibility option; integration setup is skipped by default.
+ --configure Open the configuration wizard in a terminal; requires --env-file.
+ --service Install and verify the personal service using --env-file.
+ --env-file PATH Explicit configuration file for configuration, service, and hosts.
+ -h, --help Show this help.
+
+Agent integration setup runs only for explicitly selected --host values.
+After installation, run powercontext setup --ref "powercontext-v$(powercontext --version)".
+Unattended service setup requires an existing protected environment file.
+Windows service setup is experimental; --service enables login auto-start explicitly.
+
+Region priority: --region, POWERCONTEXT_INSTALL_REGION, named timezone, locale
+territory, then global. No network location service is queried.
+CN defaults: Tsinghua for PyPI, USTC for uv, NJU for Python. Global defaults use
+PyPI and Astral's download channels. Unavailable automatic mirrors fall back to
+official sources; explicit download settings are preserved without fallback.
+
+Download configuration (independent of the package index):
+ POWERCONTEXT_UV_INSTALLER_URL HTTPS uv PowerShell installer URL.
+ UV_DOWNLOAD_URL uv release artifact directory.
+ UV_INSTALLER_GITHUB_BASE_URL GitHub mirror base URL for uv binaries.
+ UV_PYTHON_INSTALL_MIRROR Mirror of Python distribution downloads.
+ UV_ASTRAL_MIRROR_URL Astral mirror for uv versions supporting it.
+Existing additional uv indexes still take precedence over the default index.
+'@
+}
+
+function Assert-HttpsUrl([string]$Value, [string]$Option) {
+ $Uri = $null
+ if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Uri) -or
+ $Uri.Scheme -ne 'https' -or -not $Uri.Host -or $Value -match '[@?#\s]') {
+ throw "$Option must be an HTTPS URL without credentials, query parameters, or fragments."
+ }
+}
+
+function Save-Download([string]$Url, [string]$Path, [int]$Timeout = 30) {
+ Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $Timeout -OutFile $Path
+}
+
+function Select-Region {
+ $Source = 'explicit'
+ if ($Region -eq 'auto') {
+ $Source = 'timezone'
+ $Timezone = [TimeZoneInfo]::Local.Id
+ if ($env:TZ) { $Timezone = $env:TZ.TrimStart(':') }
+ if ($Timezone -in @('Asia/Shanghai', 'Asia/Chongqing', 'Asia/Chungking', 'Asia/Harbin', 'Asia/Urumqi', 'PRC', 'China Standard Time')) {
+ $Region = 'cn'
+ }
+ elseif ($Timezone -match '^(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/' -or
+ ($Timezone -ne 'UTC' -and -not $env:TZ -and $Timezone -match 'Standard Time$')) {
+ $Region = 'global'
+ }
+ else {
+ $Source = 'locale'
+ $LocaleName = ''
+ foreach ($Name in @('LC_ALL', 'LC_MESSAGES', 'LANG')) {
+ $LocaleName = [Environment]::GetEnvironmentVariable($Name)
+ if ($LocaleName) { break }
+ }
+ if (-not $LocaleName -or $LocaleName -match '^(C($|\.)|POSIX$)') { $LocaleName = [Globalization.CultureInfo]::CurrentCulture.Name }
+ $Region = 'global'
+ if ($LocaleName -match '[_-]CN($|[.@])') { $Region = 'cn' }
+ }
+ }
+ Write-Host "Download region: $Region ($Source)."
+ return $Region
+}
+
+function Test-UvConfigFile {
+ if ($env:UV_CONFIG_FILE) { return $true }
+ if ($env:UV_NO_CONFIG -in @('1', 'true')) { return $false }
+ foreach ($Directory in @($env:APPDATA, $env:PROGRAMDATA)) {
+ if ($Directory -and (Test-Path -LiteralPath (Join-Path $Directory 'uv\uv.toml'))) { return $true }
+ }
+ return $false
+}
+
+function Test-UvConfiguration {
+ foreach ($Name in @('UV_DEFAULT_INDEX', 'UV_INDEX', 'UV_INDEX_URL', 'UV_EXTRA_INDEX_URL',
+ 'UV_OFFLINE', 'UV_NO_INDEX', 'UV_FIND_LINKS')) {
+ if ([Environment]::GetEnvironmentVariable($Name)) { return $true }
+ }
+ return (Test-UvConfigFile)
+}
+
+function Test-DownloadUrl([string]$Url) {
+ try {
+ Invoke-WebRequest -Uri $Url -UseBasicParsing -Method Head -TimeoutSec 10 | Out-Null
+ return $true
+ }
+ catch { return $false }
+}
+
+function Test-IndexAvailable([string]$Url) {
+ $IndexFile = Join-Path $TempDir 'index.html'
+ # Probe transport availability only; uv interprets the Simple API and versions.
+ try { Save-Download ($Url.TrimEnd('/') + '/powercontext/') $IndexFile }
+ catch {
+ Write-Host "Package mirror is unreachable: $Url"
+ return $false
+ }
+ return $true
+}
+
+function Select-Index {
+ if (-not $IndexUrl -and (Test-UvConfiguration)) {
+ Write-Host 'Using existing uv configuration; uv will check the configured indexes.'
+ return
+ }
+ if ($env:PIP_INDEX_URL -or $env:PIP_EXTRA_INDEX_URL) {
+ Write-Host 'uv does not read pip index settings. Use --index-url or uv configuration.'
+ }
+ if (-not $IndexUrl) {
+ $IndexUrl = 'https://pypi.org/simple'
+ if ($Region -eq 'cn') {
+ $Mirror = 'https://pypi.tuna.tsinghua.edu.cn/simple'
+ Write-Host "Checking package mirror availability: $Mirror"
+ if (Test-IndexAvailable $Mirror) { $IndexUrl = $Mirror }
+ else { Write-Host 'Automatic package mirror unavailable; using PyPI.' }
+ }
+ }
+ # A reachable mirror may be stale or incompatible. Let uv report that failure
+ # without retrying a tool installation or changing the requested requirement.
+ Write-Host "Package index: $IndexUrl"
+ $env:UV_DEFAULT_INDEX = $IndexUrl
+ return $IndexUrl
+}
+
+function Install-UvIfMissing {
+ $Command = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
+ $UserUv = Join-Path $HOME '.local\bin\uv.exe'
+ if ($env:UV_INSTALL_DIR) { $UserUv = Join-Path $env:UV_INSTALL_DIR 'uv.exe' }
+ if ($Command) { $Uv = $Command.Source }
+ elseif (Test-Path -LiteralPath $UserUv -PathType Leaf) { $Uv = $UserUv }
+ else {
+ if ($env:UV_OFFLINE -in @('1', 'true')) { throw 'uv is not installed and offline mode disables downloads.' }
+ Write-Host "Installing uv $UvVersion in the user executable directory."
+ $Url = "https://astral.sh/uv/$UvVersion/install.ps1"
+ if ($env:POWERCONTEXT_UV_INSTALLER_URL) { $Url = $env:POWERCONTEXT_UV_INSTALLER_URL }
+ $Installer = Join-Path $TempDir 'uv-install.ps1'
+ $CustomDownload = $env:POWERCONTEXT_UV_INSTALLER_URL -or $env:UV_DOWNLOAD_URL -or $env:INSTALLER_DOWNLOAD_URL -or
+ $env:UV_INSTALLER_GITHUB_BASE_URL -or $env:UV_INSTALLER_GHE_BASE_URL -or $env:UV_ASTRAL_MIRROR_URL
+ $Downloaded = $false
+ if ($Region -eq 'cn' -and -not $CustomDownload) {
+ $Mirror = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/$UvVersion"
+ try {
+ Write-Host "uv mirror: $Mirror"
+ Save-Download "$Mirror/uv-installer.ps1" $Installer
+ $Downloaded = $true
+ $env:UV_DOWNLOAD_URL = $Mirror
+ }
+ catch { Write-Host 'uv mirror installer unavailable; using the official installer.' }
+ }
+ if (-not $Downloaded) {
+ try { Save-Download $Url $Installer }
+ catch { throw 'Could not download uv. Check POWERCONTEXT_UV_INSTALLER_URL.' }
+ }
+ $env:UV_INSTALL_DIR = Split-Path -Parent $UserUv
+ $env:UV_NO_MODIFY_PATH = '1'
+ $PowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
+ & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $Installer | Out-Host
+ if ($LASTEXITCODE -ne 0 -and $Downloaded) {
+ # The official PowerShell installer accepts only one UV_DOWNLOAD_URL.
+ Write-Host 'uv mirror installation failed; retrying uv with official sources.'
+ Remove-Item Env:UV_DOWNLOAD_URL
+ & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $Installer | Out-Host
+ }
+ if ($LASTEXITCODE -ne 0) { throw 'uv installation failed. Check the installer output and configured download source.' }
+ $Uv = $UserUv
+ }
+ if (-not (Test-Path -LiteralPath $Uv -PathType Leaf)) { throw 'uv executable was not found after installation.' }
+ Write-Host "Using uv: $Uv"
+ $env:PATH = "$(Split-Path -Parent $Uv);$env:PATH"
+ return $Uv
+}
+
+$SavedEnvironment = @{}
+foreach ($Name in @('UV_DEFAULT_INDEX', 'UV_INSTALL_DIR', 'UV_NO_MODIFY_PATH', 'UV_DOWNLOAD_URL')) {
+ $SavedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name)
+}
+try {
+ for ($Index = 0; $Index -lt $args.Count; $Index++) {
+ $Option = $args[$Index]
+ switch ($Option) {
+ { $_ -in '--version', '--profile', '--region', '--index-url', '--host', '--env-file' } {
+ $Index++
+ if ($Index -ge $args.Count -or -not $args[$Index] -or $args[$Index].StartsWith('--')) {
+ throw "$Option requires a value."
+ }
+ switch ($Option) {
+ '--version' { $Version = $args[$Index] }
+ '--profile' { $RuntimeProfile = $args[$Index] }
+ '--region' { $Region = $args[$Index] }
+ '--index-url' { $IndexUrl = $args[$Index] }
+ '--host' { $Hosts += @($args[$Index]) }
+ '--env-file' { $EnvFile = $args[$Index] }
+ }
+ }
+ '--no-hosts' { $NoHosts = $true }
+ '--configure' { $Configure = $true }
+ '--service' { $InstallService = $true }
+ { $_ -in '-h', '--help' } { Show-Help; exit 0 }
+ default { throw 'Unknown option. Run install.ps1 --help.' }
+ }
+ }
+ if ($Region -cnotin @('auto', 'cn', 'global')) { throw 'Use --region auto, cn, or global.' }
+ if ($RuntimeProfile -cnotin @('local', 'client')) { throw 'Use --profile local or client.' }
+ if (($Version -cne 'latest' -and $Version -notmatch '^\d+\.\d+\.\d+((a|b|rc)\d+)?$') -or $Version.StartsWith('0.0.')) {
+ throw 'Use latest or an exact package version starting at 0.1.0.'
+ }
+ if ($IndexUrl) { Assert-HttpsUrl $IndexUrl '--index-url' }
+ if ($env:POWERCONTEXT_UV_INSTALLER_URL) { Assert-HttpsUrl $env:POWERCONTEXT_UV_INSTALLER_URL 'POWERCONTEXT_UV_INSTALLER_URL' }
+ if ($NoHosts -and $Hosts.Count) { throw '--host and --no-hosts cannot be combined.' }
+ if ($Configure -or $InstallService) {
+ if ($RuntimeProfile -ne 'local') { throw '--configure and --service require --profile local.' }
+ if (-not $EnvFile) { throw '--configure and --service require --env-file PATH.' }
+ }
+ if ($EnvFile -and -not $Configure -and -not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
+ throw '--env-file must name an existing file unless --configure is selected.'
+ }
+ if ($Configure -and ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected)) {
+ throw '--configure requires a terminal. For unattended setup, use --service with an existing --env-file.'
+ }
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Use install.sh on macOS and Linux.' }
+ if ($Hosts.Count -and -not (Get-Command git -CommandType Application -ErrorAction SilentlyContinue)) {
+ throw 'Agent integration setup requires Git. Install Git or use --no-hosts.'
+ }
+ $TempDir = Join-Path ([IO.Path]::GetTempPath()) ('powercontext-install-' + [Guid]::NewGuid())
+ [IO.Directory]::CreateDirectory($TempDir) | Out-Null
+ $Region = Select-Region
+ $Uv = Install-UvIfMissing
+
+ $Python = $null
+ $FoundPython = $false
+ try {
+ $Python = & $Uv python find --system --no-project --no-python-downloads '>=3.11,<4' 2>$null
+ $FoundPython = $LASTEXITCODE -eq 0 -and $Python
+ }
+ catch { $FoundPython = $false }
+ if ($FoundPython) {
+ Write-Host "Using local Python: $Python"
+ }
+ else {
+ if ($env:UV_PYTHON_DOWNLOADS -in @('never', 'false', '0')) { throw 'No compatible local Python; UV_PYTHON_DOWNLOADS disables downloads.' }
+ if ($env:UV_OFFLINE -in @('1', 'true')) { throw 'No compatible local Python in offline mode.' }
+ $PythonArgs = @('python', 'install', '3.12')
+ $AutomaticMirror = $false
+ if ($Region -eq 'cn' -and -not ($env:UV_PYTHON_INSTALL_MIRROR -or $env:UV_ASTRAL_MIRROR_URL -or
+ $env:UV_PYTHON_DOWNLOADS_JSON_URL -or (Test-UvConfigFile))) {
+ $Downloads = & $Uv python list 'cpython@3.12' --only-downloads --show-urls --color never
+ if ($LASTEXITCODE -ne 0) { throw 'Could not resolve a Python download.' }
+ $Url = (@($Downloads)[0] -split '\s+')[-1]
+ if ($Url -match '^https://[^/]+/(?:github/|astral-sh/)?python-build-standalone/releases/download/(.+)$') {
+ $Mirror = 'https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone'
+ if (Test-DownloadUrl "$Mirror/$($Matches[1])") {
+ Write-Host "Python mirror: $Mirror"
+ $PythonArgs += @('--mirror', $Mirror)
+ $AutomaticMirror = $true
+ }
+ else { Write-Host 'Python mirror does not provide the requested build; using uv default sources.' }
+ }
+ }
+ Write-Host 'No compatible local Python found. Installing Python 3.12.'
+ & $Uv @PythonArgs
+ if ($LASTEXITCODE -ne 0 -and $AutomaticMirror) {
+ Write-Host 'Python mirror installation failed; retrying with uv default sources.'
+ & $Uv python install 3.12
+ }
+ if ($LASTEXITCODE -ne 0) { throw 'Python installation failed. Check uv output and UV_PYTHON_INSTALL_MIRROR.' }
+ $Python = & $Uv python find --system --no-project --no-python-downloads 3.12
+ if ($LASTEXITCODE -ne 0 -or -not $Python) { throw 'Installed Python was not found.' }
+ }
+ $IndexUrl = Select-Index
+ $Requirement = 'powercontext[cli,server]'
+ if ($RuntimeProfile -eq 'client') { $Requirement = 'powercontext[cli]' }
+ $InstallArgs = @('tool', 'install', '--python', $Python.Trim(), '--no-python-downloads')
+ # An installed prerelease otherwise remains eligible even with prereleases disallowed.
+ if ($Version -eq 'latest') { $InstallArgs += @('--upgrade', '--reinstall-package', 'powercontext', '--prerelease', 'disallow') }
+ else { $Requirement += "==$Version" }
+ $InstallArgs += $Requirement
+ if ($IndexUrl) { $InstallArgs += @('--default-index', $IndexUrl) }
+ & $Uv @InstallArgs
+ if ($LASTEXITCODE -ne 0) { throw 'Installation failed. Check uv indexes for packages and UV_PYTHON_INSTALL_MIRROR for Python.' }
+ $ToolBin = & $Uv tool dir --bin
+ if ($LASTEXITCODE -ne 0) { throw 'Could not locate the installed CLI.' }
+ $Cli = Join-Path $ToolBin.Trim() 'powercontext.exe'
+ $env:PATH = "$($ToolBin.Trim());$env:PATH"
+ $InstalledVersion = & $Cli --version
+ if ($LASTEXITCODE -ne 0) { throw 'Package installed, but CLI version verification failed. Check uv output and retry installation.' }
+ if ($InstalledVersion -notmatch '^\d+\.\d+\.\d+((a|b|rc)\d+)?$') { throw 'Package installed, but CLI release version verification failed. Check uv output and retry installation.' }
+ if ($Version -ne 'latest' -and $Version -ne $InstalledVersion) { throw 'Package installed, but the CLI version differs from the requested release. Retry installation with the intended version.' }
+ # Help checks import and command availability without configuring or starting a service.
+ & $Cli --help | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw 'Package installed, but CLI help verification failed. Retry installation after reviewing the command error.' }
+ & $Cli capabilities --help | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw 'Package installed, but Client command verification failed (capabilities --help). Retry installation after reviewing the command error.' }
+ if ($RuntimeProfile -eq 'local') {
+ & $Cli config init --help | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw 'Package installed, but configuration command verification failed (config init --help). Retry installation after reviewing the command error.' }
+ & $Cli server run --help | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw 'Package installed, but Server command verification failed (server run --help). Retry installation after reviewing the command error.' }
+ }
+ $Version = $InstalledVersion.Trim()
+ Write-Host "Runtime installed: $Version ($RuntimeProfile)"
+ $PathPrefix = ("$($ToolBin.Trim());$(Split-Path -Parent $Uv)").Replace("'", "''")
+ Write-Host ('For a new terminal: $env:Path = ''' + $PathPrefix + ';'' + $env:Path')
+ if ($Configure) {
+ & $Cli config init --output $EnvFile --require-write
+ if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but configuration did not complete. Review the command error or cancellation; retry config init before service setup.' }
+ Write-Host "Configuration saved: $EnvFile"
+ }
+ if ($EnvFile -and $RuntimeProfile -eq 'local') {
+ & $Cli config validate --env-file $EnvFile
+ if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but configuration validation failed. Correct the selected environment file before service setup.' }
+ Write-Host "Configuration validated: $EnvFile"
+ }
+ if ($InstallService) {
+ & $Cli service install --env-file $EnvFile --start-on-login
+ if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but personal service setup did not complete. Inspect service status and retry service install with the same --env-file.' }
+ & $Cli service status
+ if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but personal service verification failed. Inspect the native service logs.' }
+ & $Cli doctor --env-file $EnvFile
+ if ($LASTEXITCODE -ne 0) { throw 'Personal service installed, but Server diagnostics did not pass. Review readiness and retry doctor with the same --env-file.' }
+ Write-Host "Personal service verified with: $EnvFile"
+ }
+ $SetupStatus = 0
+ if ($Hosts.Count) {
+ $SetupArgs = @('setup')
+ if ($EnvFile) { $SetupArgs += @('--env-file', $EnvFile) }
+ & $Cli @SetupArgs --source oceanbase/powercontext --ref "powercontext-v$Version" @Hosts
+ $SetupStatus = $LASTEXITCODE
+ }
+ if ($RuntimeProfile -eq 'local') {
+ if ($InstallService) {
+ Write-Host 'After upgrades or any environment-file edit, rerun service install with the same --env-file.'
+ }
+ else {
+ $NextEnv = '.env'
+ if ($EnvFile) { $NextEnv = $EnvFile }
+ $QuotedEnv = "'" + $NextEnv.Replace("'", "''") + "'"
+ if (-not $EnvFile) { Write-Host "`nConfigure: powercontext config init --output $QuotedEnv" }
+ Write-Host "Then run: powercontext service install --env-file $QuotedEnv"
+ Write-Host ' powercontext service status'
+ Write-Host " powercontext doctor --env-file $QuotedEnv"
+ Write-Host 'Windows personal services are experimental. For development or temporary use, run powercontext server run.'
+ }
+ Write-Host ' https://powercontext.oceanbase.io/en/docs/get-started/quickstart/'
+ }
+ else {
+ Write-Host "`nConnect to your existing Server:"
+ Write-Host ' https://powercontext.oceanbase.io/en/docs/operate/connect-remote-server/'
+ }
+ if ($SetupStatus -ne 0) { throw "Runtime installed, but integration setup did not complete. Retry setup with powercontext-v$Version." }
+}
+catch {
+ [Console]::Error.WriteLine("error: $($_.Exception.Message)")
+ exit 1
+}
+finally {
+ foreach ($Name in $SavedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($Name, $SavedEnvironment[$Name]) }
+ if ($TempDir) { Remove-Item -LiteralPath $TempDir -Recurse -Force -ErrorAction SilentlyContinue }
+}
diff --git a/website/public/install.sh b/website/public/install.sh
new file mode 100644
index 0000000000..fec764240a
--- /dev/null
+++ b/website/public/install.sh
@@ -0,0 +1,422 @@
+#!/usr/bin/env bash
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -euo pipefail
+
+VERSION=latest
+UV_VERSION=0.12.23
+PROFILE=local
+REGION=${POWERCONTEXT_INSTALL_REGION:-auto}
+PYTHON_BIN=""
+INDEX_URL=""
+HOSTS=()
+NO_HOSTS=false
+CONFIGURE=false
+INSTALL_SERVICE=false
+ENV_FILE=""
+TEMP_DIR=""
+UV_BIN=""
+GUIDE=https://powercontext.oceanbase.io/en/docs/get-started/quickstart/
+
+fail() {
+ printf 'error: %s\n' "$*" >&2
+ exit 1
+}
+
+cleanup() {
+ if [[ -n "$TEMP_DIR" ]]; then
+ rm -rf -- "$TEMP_DIR"
+ fi
+}
+
+usage() {
+ cat <<'EOF'
+Install PowerContext on macOS or Linux. Python and uv need not be installed.
+
+Usage: bash install.sh [--version VERSION] [--index-url URL] [--host HOST]... [--no-hosts]
+
+ --version VERSION Exact package version, or latest (default: latest stable).
+ Integrations use the matching powercontext-vVERSION tag.
+ --profile PROFILE local (CLI and Server, default) or client (CLI and Client).
+ --region REGION auto, cn, or global (default: POWERCONTEXT_INSTALL_REGION or auto).
+ --index-url URL HTTPS default package index for this installation.
+ Existing additional uv indexes still take precedence.
+ --host HOST Install this Agent integration; repeat for multiple hosts.
+ --no-hosts Compatibility option; integration setup is skipped by default.
+ --configure Open the configuration wizard in a terminal; requires --env-file.
+ --service Install and verify the personal service using --env-file.
+ --env-file PATH Explicit configuration file for configuration, service, and hosts.
+ -h, --help Show this help.
+
+Agent integration setup runs only for explicitly selected --host values.
+After installation, run powercontext setup --ref "powercontext-v$(powercontext --version)".
+Personal macOS/Linux use: --configure --service --env-file /path/to/powercontext.env.
+For unattended service setup, omit --configure and provide an existing protected file.
+
+Region priority: --region, POWERCONTEXT_INSTALL_REGION, named timezone, locale
+territory, then global. No network location service is queried.
+CN defaults: Tsinghua for PyPI, USTC for uv, NJU for Python. Global defaults use
+PyPI and Astral's download channels. Unavailable automatic mirrors fall back to
+official sources; explicit download settings are preserved without fallback.
+
+Download configuration (independent of the package index):
+ POWERCONTEXT_UV_INSTALLER_URL HTTPS uv installer script URL.
+ UV_DOWNLOAD_URL uv release artifact directory.
+ UV_INSTALLER_GITHUB_BASE_URL GitHub mirror base URL for uv binaries.
+ UV_PYTHON_INSTALL_MIRROR Mirror of python-build-standalone downloads.
+ UV_ASTRAL_MIRROR_URL Astral mirror for uv versions supporting it.
+These settings are passed to the official installer and uv without modification.
+Existing uv and compatible Python installations are reused.
+EOF
+}
+
+parse_args() {
+ while (($#)); do
+ case "$1" in
+ --version|--profile|--region|--index-url|--host|--env-file)
+ (($# >= 2)) && [[ -n "$2" && "$2" != --* ]] || fail "$1 requires a value"
+ case "$1" in
+ --version) VERSION=$2 ;;
+ --profile) PROFILE=$2 ;;
+ --region) REGION=$2 ;;
+ --index-url) INDEX_URL=$2 ;;
+ --host) HOSTS+=("$2") ;;
+ --env-file) ENV_FILE=$2 ;;
+ esac
+ shift 2
+ ;;
+ --no-hosts) NO_HOSTS=true; shift ;;
+ --configure) CONFIGURE=true; shift ;;
+ --service) INSTALL_SERVICE=true; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) fail "Unknown option. Run bash install.sh --help." ;;
+ esac
+ done
+ case "$REGION" in auto|cn|global) ;; *) fail "Use --region auto, cn, or global." ;; esac
+ case "$PROFILE" in local|client) ;; *) fail "Use --profile local or client." ;; esac
+ [[ "$VERSION" == latest || "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]] || fail "Use latest or an exact package version."
+ [[ "$VERSION" != 0.0.* ]] || fail "This installer supports releases starting at 0.1.0."
+ if [[ -n "$INDEX_URL" ]]; then
+ [[ "$INDEX_URL" == https://?* && "$INDEX_URL" != *[@\?#[:space:]]* ]] ||
+ fail "Use an HTTPS index URL without credentials, query parameters, or fragments."
+ fi
+ if [[ -n "${POWERCONTEXT_UV_INSTALLER_URL:-}" ]]; then
+ [[ "$POWERCONTEXT_UV_INSTALLER_URL" == https://?* && "$POWERCONTEXT_UV_INSTALLER_URL" != *[@\?#[:space:]]* ]] ||
+ fail "POWERCONTEXT_UV_INSTALLER_URL must be an HTTPS URL without credentials, query parameters, or fragments."
+ fi
+ if [[ "$NO_HOSTS" == true && ${#HOSTS[@]} -gt 0 ]]; then
+ fail "--host and --no-hosts cannot be combined."
+ fi
+ if [[ "$CONFIGURE" == true || "$INSTALL_SERVICE" == true ]]; then
+ [[ "$PROFILE" == local ]] || fail "--configure and --service require --profile local."
+ [[ -n "$ENV_FILE" ]] || fail "--configure and --service require --env-file PATH."
+ fi
+ if [[ -n "$ENV_FILE" && "$CONFIGURE" == false ]]; then
+ [[ -f "$ENV_FILE" ]] || fail "--env-file must name an existing file unless --configure is selected."
+ fi
+}
+
+download() {
+ local timeout=${3:-30}
+ if command -v curl >/dev/null 2>&1; then
+ curl --fail --location --silent --show-error --connect-timeout 5 --max-time "$timeout" "$1" -o "$2"
+ else
+ timeout=${3:-15}
+ wget --quiet --timeout="$timeout" --tries=1 "$1" -O "$2"
+ fi
+}
+
+detect_region() {
+ local timezone=${TZ:-} locale_name source=explicit
+ if [[ "$REGION" == auto ]]; then
+ source=timezone
+ if [[ -z "$timezone" ]]; then
+ timezone=$(readlink /etc/localtime 2>/dev/null || true)
+ if [[ "$timezone" != */zoneinfo/* && -f /etc/timezone ]]; then
+ IFS= read -r timezone /dev/null || true)
+ fi
+ fi
+ case "$locale_name" in
+ *_CN|*_CN.*|*_CN@*|*-CN|*-CN.*|*-CN@*) REGION=cn ;;
+ *) REGION=global ;;
+ esac
+ ;;
+ esac
+ fi
+ printf 'Download region: %s (%s).\n' "$REGION" "$source"
+}
+
+has_uv_config_file() {
+ [[ -n "${UV_CONFIG_FILE:-}" ]] && return 0
+ case "${UV_NO_CONFIG:-}" in 1|true) return 1 ;; esac
+ [[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/uv/uv.toml" || -f /etc/uv/uv.toml ]] && return 0
+ local directory
+ local config_dirs=()
+ IFS=: read -r -a config_dirs <<< "${XDG_CONFIG_DIRS:-/etc/xdg}"
+ for directory in "${config_dirs[@]}"; do
+ [[ -f "$directory/uv/uv.toml" ]] && return 0
+ done
+ return 1
+}
+
+has_uv_configuration() {
+ # Let uv resolve its configuration and authentication; do not parse TOML in shell.
+ [[ -n "${UV_DEFAULT_INDEX:-}${UV_INDEX:-}${UV_INDEX_URL:-}${UV_EXTRA_INDEX_URL:-}${UV_OFFLINE:-}${UV_NO_INDEX:-}${UV_FIND_LINKS:-}" ]] && return 0
+ has_uv_config_file
+}
+
+url_available() {
+ if command -v curl >/dev/null 2>&1; then
+ curl --fail --location --silent --head --connect-timeout 5 --max-time 10 "$1" >/dev/null
+ else
+ wget --quiet --spider --timeout=10 --tries=1 "$1"
+ fi
+}
+
+index_available() {
+ # Probe transport availability only; uv interprets the Simple API and versions.
+ if ! download "${1%/}/powercontext/" "$TEMP_DIR/index.html"; then
+ printf 'Package mirror is unreachable: %s\n' "$1" >&2
+ return 1
+ fi
+}
+
+check_index() {
+ if [[ -z "$INDEX_URL" ]] && has_uv_configuration; then
+ printf '%s\n' 'Using existing uv configuration; uv will check the configured indexes.'
+ return
+ fi
+ if [[ -n "${PIP_INDEX_URL:-}${PIP_EXTRA_INDEX_URL:-}" ]]; then
+ printf '%s\n' 'uv does not read PIP_INDEX_URL or PIP_EXTRA_INDEX_URL. Use --index-url or uv configuration.'
+ fi
+ if [[ -n "$INDEX_URL" ]]; then
+ # uv owns index authentication and package resolution, including private indexes.
+ printf 'Package index: %s\n' "$INDEX_URL"
+ return
+ fi
+
+ INDEX_URL=https://pypi.org/simple
+ if [[ "$REGION" == cn ]]; then
+ local mirror=https://pypi.tuna.tsinghua.edu.cn/simple
+ printf 'Checking package mirror availability: %s\n' "$mirror"
+ if index_available "$mirror"; then
+ INDEX_URL=$mirror
+ else
+ printf '%s\n' 'Automatic package mirror unavailable; using PyPI.'
+ fi
+ fi
+ # A reachable mirror may be stale or incompatible. Let uv report that failure
+ # without retrying a tool installation or changing the requested requirement.
+ printf 'Package index: %s\n' "$INDEX_URL"
+}
+
+ensure_uv() {
+ local uv_install_dir=${UV_INSTALL_DIR:-$HOME/.local/bin}
+ if command -v uv >/dev/null 2>&1; then
+ UV_BIN=$(command -v uv)
+ elif [[ -x "$uv_install_dir/uv" ]]; then
+ UV_BIN="$uv_install_dir/uv"
+ else
+ case "${UV_OFFLINE:-}" in 1|true) fail "uv is not installed and offline mode disables downloads." ;; esac
+ printf 'Installing uv %s in the user executable directory.\n' "$UV_VERSION"
+ local installer_url=${POWERCONTEXT_UV_INSTALLER_URL:-https://astral.sh/uv/$UV_VERSION/install.sh}
+ local mirror=https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/$UV_VERSION
+ local official=https://releases.astral.sh/github/uv/releases/download/$UV_VERSION
+ # A custom installer may install another version; never pair it with our pinned mirror.
+ if [[ "$REGION" == cn && -z "${POWERCONTEXT_UV_INSTALLER_URL:-}${UV_DOWNLOAD_URL:-}${INSTALLER_DOWNLOAD_URL:-}${UV_INSTALLER_GITHUB_BASE_URL:-}${UV_INSTALLER_GHE_BASE_URL:-}${UV_ASTRAL_MIRROR_URL:-}" ]]; then
+ printf 'uv mirror: %s\n' "$mirror"
+ if download "$mirror/uv-installer.sh" "$TEMP_DIR/uv-install.sh"; then
+ # The official shell installer supports an ordered list of artifact sources.
+ UV_DOWNLOAD_URL="$mirror $official https://github.com/astral-sh/uv/releases/download/$UV_VERSION" \
+ UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed."
+ else
+ printf '%s\n' 'uv mirror unavailable; using the official installer.'
+ download "$installer_url" "$TEMP_DIR/uv-install.sh" || fail "Could not download the uv installer."
+ UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed."
+ fi
+ else
+ download "$installer_url" "$TEMP_DIR/uv-install.sh" || fail "Could not download the uv installer. Check POWERCONTEXT_UV_INSTALLER_URL."
+ UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed. Check the configured download source."
+ fi
+ UV_BIN="$uv_install_dir/uv"
+ fi
+ [[ -x "$UV_BIN" ]] || fail "uv executable was not found after installation."
+ printf 'Using uv: %s\n' "$UV_BIN"
+ PATH="$(dirname "$UV_BIN"):$PATH"
+ export PATH
+}
+
+ensure_python() {
+ if PYTHON_BIN=$("$UV_BIN" python find --system --no-project --no-python-downloads '>=3.11,<4' 2>/dev/null); then
+ printf 'Using local Python: %s\n' "$PYTHON_BIN"
+ return
+ fi
+ case "${UV_PYTHON_DOWNLOADS:-}" in never|false|0) fail "No compatible local Python; UV_PYTHON_DOWNLOADS disables downloads." ;; esac
+ case "${UV_OFFLINE:-}" in 1|true) fail "No compatible local Python in offline mode." ;; esac
+ local mirror=https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone
+ local key url suffix automatic_mirror=false
+ local python_args=(python install 3.12)
+ if [[ "$REGION" == cn && -z "${UV_PYTHON_INSTALL_MIRROR:-}${UV_ASTRAL_MIRROR_URL:-}${UV_PYTHON_DOWNLOADS_JSON_URL:-}" ]] && ! has_uv_config_file; then
+ # Ask uv for the exact build URL; platform and archive selection stay with uv.
+ "$UV_BIN" python list 'cpython@3.12' --only-downloads --show-urls --color never >"$TEMP_DIR/python-downloads.txt" || fail "Could not resolve a Python download."
+ read -r key url <"$TEMP_DIR/python-downloads.txt" || true
+ case "$url" in
+ https://*/python-build-standalone/releases/download/*)
+ suffix=${url#*/python-build-standalone/releases/download/}
+ if url_available "$mirror/$suffix"; then
+ printf 'Python mirror: %s\n' "$mirror"
+ python_args+=(--mirror "$mirror")
+ automatic_mirror=true
+ else
+ printf '%s\n' 'Python mirror does not provide the requested build; using uv default sources.'
+ fi
+ ;;
+ esac
+ fi
+ printf '%s\n' 'No compatible local Python found. Installing Python 3.12.'
+ if ! "$UV_BIN" "${python_args[@]}"; then
+ if [[ "$automatic_mirror" == true ]]; then
+ printf '%s\n' 'Python mirror installation failed; retrying with uv default sources.'
+ "$UV_BIN" python install 3.12 || fail "Python installation failed. Check UV_PYTHON_INSTALL_MIRROR."
+ else
+ fail "Python installation failed. Check uv output and UV_PYTHON_INSTALL_MIRROR."
+ fi
+ fi
+ PYTHON_BIN=$("$UV_BIN" python find --system --no-project --no-python-downloads 3.12) || fail "Installed Python was not found."
+}
+
+main() {
+ parse_args "$@"
+ case "$(uname -s)" in
+ Darwin|Linux) ;;
+ *) fail "Use the Windows instructions in the installation guide. This script supports macOS and Linux." ;;
+ esac
+ [[ -n "${HOME:-}" ]] || fail "HOME is not set."
+ command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || fail "Install curl or wget first."
+ if [[ "$CONFIGURE" == true ]]; then
+ # The downloaded script owns stdin; give only the opt-in wizard the controlling terminal.
+ [[ -t 1 ]] && { exec 3/dev/null ||
+ fail "--configure requires a terminal. For unattended setup, use --service with an existing --env-file."
+ fi
+ if [[ ${#HOSTS[@]} -gt 0 ]]; then
+ command -v git >/dev/null 2>&1 || fail "Agent integration setup requires Git. Install Git or use --no-hosts."
+ fi
+ TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/powercontext-install.XXXXXX")
+ trap cleanup EXIT
+ detect_region
+ ensure_uv
+ ensure_python
+ check_index
+ [[ -z "$INDEX_URL" ]] || export UV_DEFAULT_INDEX="$INDEX_URL"
+
+ local requirement='powercontext[cli,server]'
+ [[ "$PROFILE" != client ]] || requirement='powercontext[cli]'
+ local install_args=(tool install --python "$PYTHON_BIN" --no-python-downloads)
+ if [[ "$VERSION" == latest ]]; then
+ # An installed prerelease otherwise remains eligible even with prereleases disallowed.
+ install_args+=(--upgrade --reinstall-package powercontext --prerelease disallow)
+ else
+ requirement="$requirement==$VERSION"
+ fi
+ install_args+=("$requirement")
+ [[ -z "$INDEX_URL" ]] || install_args+=(--default-index "$INDEX_URL")
+ printf 'Installing PowerContext %s.\n' "$VERSION"
+ "$UV_BIN" "${install_args[@]}" ||
+ fail "Installation failed. Check the uv error: Python downloads use UV_PYTHON_INSTALL_MIRROR or UV_ASTRAL_MIRROR_URL; Python packages use uv indexes."
+
+ local tool_bin
+ tool_bin=$("$UV_BIN" tool dir --bin)
+ export PATH="$tool_bin:$PATH"
+ local installed_version
+ installed_version=$("$tool_bin/powercontext" --version) || fail "Package installed, but CLI version verification failed. Check uv output and retry installation."
+ [[ "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]] || fail "Package installed, but CLI release version verification failed. Check uv output and retry installation."
+ [[ "$VERSION" == latest || "$VERSION" == "$installed_version" ]] || fail "Package installed, but the CLI version differs from the requested release. Retry installation with the intended version."
+ # Help checks import and command availability without configuring or starting a service.
+ "$tool_bin/powercontext" --help >/dev/null ||
+ fail "Package installed, but CLI help verification failed. Retry installation after reviewing the command error."
+ "$tool_bin/powercontext" capabilities --help >/dev/null ||
+ fail "Package installed, but Client command verification failed (capabilities --help). Retry installation after reviewing the command error."
+ if [[ "$PROFILE" == local ]]; then
+ "$tool_bin/powercontext" config init --help >/dev/null ||
+ fail "Package installed, but configuration command verification failed (config init --help). Retry installation after reviewing the command error."
+ "$tool_bin/powercontext" server run --help >/dev/null ||
+ fail "Package installed, but Server command verification failed (server run --help). Retry installation after reviewing the command error."
+ fi
+ VERSION=$installed_version
+ printf 'Runtime installed: %s (%s)\n' "$VERSION" "$PROFILE"
+ printf 'For a new terminal, add these directories to PATH if needed:\n'
+ printf ' export PATH=%q:%q:"%s"\n' "$(dirname "$UV_BIN")" "$tool_bin" "\$PATH"
+
+ if [[ "$CONFIGURE" == true ]]; then
+ "$tool_bin/powercontext" config init --output "$ENV_FILE" --require-write <&3 ||
+ fail "Runtime installed, but configuration did not complete. Review the command error or cancellation; retry config init before service setup."
+ printf 'Configuration saved: %s\n' "$ENV_FILE"
+ fi
+ if [[ -n "$ENV_FILE" && "$PROFILE" == local ]]; then
+ "$tool_bin/powercontext" config validate --env-file "$ENV_FILE" ||
+ fail "Runtime installed, but configuration validation failed. Correct the selected environment file before service setup."
+ printf 'Configuration validated: %s\n' "$ENV_FILE"
+ fi
+ if [[ "$INSTALL_SERVICE" == true ]]; then
+ "$tool_bin/powercontext" service install --env-file "$ENV_FILE" ||
+ fail "Runtime installed, but personal service setup did not complete. Inspect service status and retry service install with the same --env-file."
+ "$tool_bin/powercontext" service status ||
+ fail "Runtime installed, but personal service verification failed. Inspect the native service logs."
+ "$tool_bin/powercontext" doctor --env-file "$ENV_FILE" ||
+ fail "Personal service installed, but Server diagnostics did not pass. Review readiness and retry doctor with the same --env-file."
+ printf 'Personal service verified with: %s\n' "$ENV_FILE"
+ fi
+
+ local setup_status=0
+ if [[ ${#HOSTS[@]} -gt 0 ]]; then
+ local setup_args=(setup)
+ [[ -z "$ENV_FILE" ]] || setup_args+=(--env-file "$ENV_FILE")
+ "$tool_bin/powercontext" "${setup_args[@]}" --source oceanbase/powercontext \
+ --ref "powercontext-v$VERSION" ${HOSTS[@]+"${HOSTS[@]}"} || setup_status=$?
+ fi
+ if [[ "$PROFILE" == local ]]; then
+ if [[ "$INSTALL_SERVICE" == true ]]; then
+ printf '%s\n' 'After upgrades or any environment-file edit, rerun service install with the same --env-file.'
+ else
+ local next_env=${ENV_FILE:-.env}
+ [[ -n "$ENV_FILE" ]] || printf '\nConfigure: powercontext config init --output %q\n' "$next_env"
+ printf 'Then run: powercontext service install --env-file %q\n' "$next_env"
+ printf ' powercontext service status\n powercontext doctor --env-file %q\n' "$next_env"
+ printf '%s\n' 'A native user-service manager and loopback Server bind are required. For development or temporary use, run powercontext server run.'
+ fi
+ printf ' %s\n' "$GUIDE"
+ else
+ printf '\nConnect to your existing Server:\n %s\n' \
+ 'https://powercontext.oceanbase.io/en/docs/operate/connect-remote-server/'
+ fi
+ if ((setup_status != 0)); then
+ fail "Runtime installed, but integration setup did not complete. Review its results and retry setup with powercontext-v$VERSION."
+ fi
+}
+
+main "$@"