diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 2e772ece..cc5d25a6 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -3,7 +3,7 @@ name: Update Homebrew tap on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index d6a62b44..cd89c47b 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Same scheme as release-pythinker-cli.yml and windows-installer.yml. - - "v[0-9]+.[0-9]+.[0-9]+" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index b2f72904..8f0f333f 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -16,7 +16,7 @@ name: Promote release on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: tag: diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index 4dfe7b22..83655e68 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -3,7 +3,7 @@ name: Release (pythinker-code) on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v+([0-9]).+([0-9]).+([0-9])" permissions: contents: write diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 1a407f96..d67b5497 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Match the existing PyPI release workflow's tag scheme: v... - - "v[0-9]+.[0-9]+.[0-9]+" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/.gitignore b/.gitignore index 2fe9b009..568bb108 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ static/ .memo/ .entire .claude +.pythinker/ .worktrees/ blackbox/ diff --git a/CHANGELOG.md b/CHANGELOG.md index eb241a46..f9346141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. +- **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. - **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. ## 0.27.0 (2026-05-31) diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..1cc6c479 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +Use a product-engineering register: concise, practical, and confident. Write for developers who value clear terminal workflows; prefer precise verbs, concrete outcomes, and short examples over marketing slogans. + +## Users + +Developers using Pythinker inside terminal workflows, including PowerShell, Windows Terminal, macOS Terminal, iTerm2, GNOME Terminal, CI logs, SSH sessions, and low-color or no-color shells. They are reviewing code, diagnosing failures, running commands, approving changes, and switching between AI and shell work. + +## Product Purpose + +Pythinker Code is a review-first AI engineering CLI. The interface should help users understand what the agent is doing, trust approvals and results, move quickly through repeated terminal actions, and keep context visible without leaving the shell. + +## Brand Personality + +Disciplined, terminal-native, precise. + +## Anti-references + +Do not make the TUI feel like a neon dashboard, glassmorphism mockup, emoji-heavy chat toy, or novelty terminal skin. Avoid fragile color-only semantics, excessive borders, box-heavy layouts, decorative motion, AI-purple gradients, and UI effects that slow repeated keyboard workflows. + +## Design Principles + +1. Standardize first: core structure, labels, states, and colors must behave consistently across shells and terminal capabilities. +2. Text is the UI: layout, copy, hierarchy, and semantic labels should remain usable in no-color and narrow-width environments. +3. Speed over spectacle: feedback should be immediate, short, and interruptible; repeated keyboard actions should not animate. +4. State must be explicit: approvals, background work, errors, warnings, and tool execution need clear shape and wording, not just color. +5. Compatibility is craft: PowerShell, Windows Terminal, Unix terminals, SSH, CI, and screen readers should all get a coherent experience. + +## Accessibility & Inclusion + +Target WCAG-minded contrast within ANSI limitations, colorblind-safe state semantics, reduced-motion behavior, keyboard-first operation, graceful no-color fallback, and responsive rendering from narrow split panes to wide terminals. diff --git a/README.md b/README.md index 1f1b5cae..c422953e 100644 --- a/README.md +++ b/README.md @@ -801,4 +801,8 @@ Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com [🧩 ACP](https://github.com/agentclientprotocol/agent-client-protocol)  ·  [🔌 MCP](https://modelcontextprotocol.io/) +

+ Thanks for visiting ✨ Pythinker! Viewing this README does not trigger telemetry. +

+ diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 47ec674e..cef00ff4 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. +- **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. +- **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. + ## 0.27.0 (2026-05-31) ### What changed in this release diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md new file mode 100644 index 00000000..916d7cbd --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md @@ -0,0 +1,1162 @@ +# P0 — Quick Wins (Release Orchestration) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Migrate the cross-repo release auth to an org-owned GitHub App, make `promote-release.yml` fail loud (block the prerelease→latest flip until every pinned sub-package resolves on PyPI; drop Homebrew from the gate behind a new drift-reconcile backstop), and fix the site sync's 0.24.0-class Mode-B bugs (stale source literal, missing served-version signal, dead install-script mirrors), plus harden both native installers. + +**Architecture:** Three pull-requests across two repos. **PR-code-1** carries the non-sequencing-entangled code-repo edits (dispatch-file App migration + fail-loud, retire the dead pdoc gh-pages step, installer backoff/pagination). **PR-code-2** carries `release-readiness-reconcile.yml` (NEW) *and* all `promote-release.yml` edits in the same change set, so the hard rule "reconcile lands before/with Homebrew-gate removal" (§5) is satisfied atomically. **PR-home-3** carries the pythinker-home site edits (receiver source-repo gate, `public/version.json` emit + drift, the line-366 literal fix, ref-pinned raw fetch, `git rm` of 3 dead mirrors, deploy retirement). The shipped agent gains zero runtime deps (C3); every change is branch→PR→CodeRabbit-success→merge (C1/C2). + +**Tech Stack:** GitHub Actions (bash + `gh api` + `jq` + `curl`), `actions/create-github-app-token@v2.2.2` (the reference pattern at `homebrew-tap.yml:79-86`), TypeScript on Bun 1.3.x with the built-in `bun:test` runner (zero new JS deps), `actionlint` (workflow lint), `shellcheck` (bash lint), PowerShell `PSScriptAnalyzer` (manual). + +--- + +## Prerequisites (manual / operator) + +These touch admin/secrets/outward-facing services. They are **operator actions**, not code steps. Do them in this order; PR-code-1, PR-code-2 dispatch verification, and the PAT deletions depend on them. + +- [ ] **OP-1 — Create the org-owned GitHub App `pythinker-release-bot`.** In the GitHub UI: `https://github.com/organizations/TechMatrix-labs/settings/apps/new`. Name `pythinker-release-bot`. Homepage URL `https://github.com/TechMatrix-labs/pythinker-code`. Uncheck "Webhook → Active". Repository permissions: **Contents: Read and write**, **Metadata: Read-only** (Metadata auto-selects). "Where can this GitHub App be installed?" → **Only on this account**. Create. On the App's page, **Generate a private key** (downloads a `.pem`) and note the numeric **App ID**. + - *Why an App and not the PAT:* `homebrew-pythinker` is public, `pythinker-home` is private; a dedicated org App contains a leak to one trust domain, survives member/org changes, and mints ~1h tokens per run (§4). +- [ ] **OP-2 — Install the App on `pythinker-home` ONLY.** App page → **Install App** → TechMatrix-labs → **Only select repositories** → `pythinker-home` → Install. Verify it is NOT installed on any other repo. +- [ ] **OP-3 — Set the org secrets** (run from a shell where `gh auth status` shows an org-admin token): + + ```bash + gh secret set PYTHINKER_RELEASE_BOT_APP_ID --org TechMatrix-labs --visibility selected --repos pythinker-code --body "" + gh secret set PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY --org TechMatrix-labs --visibility selected --repos pythinker-code < /path/to/pythinker-release-bot.private-key.pem + ``` + + Expected: `✓ Set Organization secret PYTHINKER_RELEASE_BOT_APP_ID` (and `_PRIVATE_KEY`). Then `rm /path/to/pythinker-release-bot.private-key.pem` (the key lives only in the secret now). +- [ ] **OP-4 — Confirm the live site host runs Dokploy build-from-source, not the Docker-Compose/Watchtower stack.** Required before Task 14 (deploy retirement). Check the Dokploy dashboard / server: the site is built from source via nixpacks (`bun run server.ts`), and there is no running `watchtower`/`traefik` compose stack for it. If you cannot confirm, **skip Task 14** and log it under "Out of scope / deferred" — it is reversible (`git rm`) and not on the release path. +- [ ] **OP-5 — (DEFERRED, post-verification) Delete the retired PATs.** Do these only after the gated green cycle in "Phase verification": + - `PYTHINKER_HOME_REPO_DISPATCH_TOKEN` — delete after **both** dispatch files are migrated (PR-code-1 + PR-code-2) **and** one green release cycle dispatches via the App. `gh secret delete PYTHINKER_HOME_REPO_DISPATCH_TOKEN --repo TechMatrix-labs/pythinker-code` (it is a repo secret today, per `promote-release.yml:168`). + - `PYTHINKER_CORE_PAGES_TOKEN` — delete after PR-code-1 merges (the only consumer, `release-pythinker-core.yml:101`, is removed there). `gh secret delete PYTHINKER_CORE_PAGES_TOKEN --repo TechMatrix-labs/pythinker-code`. + +**Local tooling the executor needs** (install once; none are repo deps): +```bash +go install github.com/rhysd/actionlint/cmd/actionlint@latest # -> ~/go/bin/actionlint +sudo dnf install -y ShellCheck # shellcheck on Fedora 44 +# bun 1.3.13 already present (site TDD); PSScriptAnalyzer is Windows/manual only. +``` +Expected: `actionlint --version` prints a version; `shellcheck --version` prints `version: 0.x`. + +--- + +## File Structure + +**pythinker-code repo** (`/home/ai/Projects/pythinker-code-main`): + +| File | Change | Responsibility | +|---|---|---| +| `.github/workflows/dispatch-pythinker-home-sync.yml` | Modify | Mint `pythinker-release-bot` token (replace PAT); fail loud on empty token | +| `.github/workflows/release-pythinker-core.yml` | Modify | Remove the dead `docs` job's pdoc→gh-pages step (404 target) | +| `scripts/install-native.sh` | Modify | Exponential backoff (4→120s, ~6m cap) on the asset-wait loop | +| `scripts/install.ps1` | Modify | `/releases/latest`-first; paginated scan only as fallback (fix `per_page=20` cliff); add backoff | +| `.github/workflows/promote-release.yml` | Modify | Sub-package PyPI-existence blocking check; remove Homebrew gate; per-channel Slack detail; `issues:write` + release-readiness issue (rows ticked as channels go ready); separate App-authed `needs: promote` dispatch job; fail-loud token; asset URLs from API `tag_name` | +| `.github/workflows/release-readiness-reconcile.yml` | **Create** | Daily/dispatch drift detector + idempotent re-dispatch + persistent-drift Slack + stale-issue auto-close (the Homebrew backstop) | + +**pythinker-home repo** (`/home/ai/Projects/pythinker-site/site`): + +| File | Change | Responsibility | +|---|---|---| +| `scripts/sync-upstream-products.ts` | Modify | Export functions + guard entrypoint; emit `public/version.json`; fix the line-366 literal to derive from per-product config; pin raw fetch to dispatched ref; drop 3 dead mirror targets; add the foreign-owner lockstep assertion | +| `scripts/sync-upstream-products.test.ts` | **Create** | `bun:test` unit tests for the TS logic above | +| `.github/workflows/sync-upstream-products.yml` | Modify | Job-level receiver `if:` gating on `client_payload.source_repo`; pass dispatched ref via `env:` (never into a `run:` line) | +| `scripts/install.ps1`, `web/public/install.ps1`, `docs/public/install.ps1` | **Delete** (`git rm`) | The 3 dead, byte-identical tracked mirrors (canonical pair is `public/install.{sh,ps1}`) | +| `docker-compose.yml`, `docker-compose.private-ghcr.yml`, `deploy/traefik/`, `deploy/.env.example`, `deploy/README.md` | Delete/rewrite (Task 14, gated by OP-4) | Retire the orphaned GHCR+Watchtower+Traefik path; canonical deploy = Dokploy build-from-source | + +**Deferred (out of P0 scope) — §7 right-sizing items, not on the release path:** +- *Installer raw-GitHub `` fallback header line* (a documented comment in `install-native.sh`/`install.ps1` headers pointing at `github.com/.../releases/latest/download` as the non-`pythinker.com` fallback). **Deferred:** cosmetic doc-only text; the real download already targets the raw GitHub URL, and the circular `pythinker.com` reference is only in comment/fallback prose (§7). No runtime behavior change. +- *`.sha256` sidecar existence check in the site sync* (defense-in-depth for a cron-mid-upload race). **Deferred:** promote already gates every `.sha256` sidecar on the normal release path (`promote-release.yml:67-94`), and the site sync derives asset URLs from the live API `tag_name`; this is belt-and-suspenders, not a fix for a live failure (§7). + +--- + +## PR-code-1 — Dispatch App migration, dead-docs retirement, installer hardening + +Branch: `release-orch/p0-dispatch-and-installers`. Touches `scripts/install*.{sh,ps1}` and `release-*.yml` → **requires a `## Unreleased` CHANGELOG bullet** (`changelog-entry-required.yml:82-88` matches these paths; the gate is a required check under branch protection). Add it as the first task so CI is green from the start. + +### Task 1 — Add the CHANGELOG entry (unblock the required gate) + +**Files:** Modify `CHANGELOG.md` (the `## Unreleased` block). + +- [ ] 1.1 Create the branch: + ```bash + git -C /home/ai/Projects/pythinker-code-main switch -c release-orch/p0-dispatch-and-installers + ``` + Expected: `Switched to a new branch 'release-orch/p0-dispatch-and-installers'`. +- [ ] 1.2 Confirm the `## Unreleased` heading exists and view its current body: + ```bash + awk '/^## Unreleased/{f=1} f&&/^## [0-9]/{exit} f' /home/ai/Projects/pythinker-code-main/CHANGELOG.md + ``` + Expected: prints the `## Unreleased` heading and any existing bullets (may be just the heading). +- [ ] 1.3 Add a bullet under `## Unreleased` (C5 — hand-authored, not `[skip changelog]`). Use Edit to insert directly after the `## Unreleased` line: + ``` + - Release pipeline: migrate the pythinker-home website-sync dispatch to the org-owned `pythinker-release-bot` GitHub App and fail loud on an empty token; retire the dead pythinker-core API-docs gh-pages publish step; add exponential backoff to the native install scripts and fix the Windows installer's release-pagination cliff. + ``` +- [ ] 1.4 Approximate the gate locally (HONEST: this is an approximation, not the gate). The real `changelog-entry-required.yml` gate diffs the `## Unreleased` block against the PR base and requires ≥1 **added** non-blank line; only CI can run that diff-against-base. Locally, diff the working tree against `origin/main` and confirm the new bullet appears as an added line: + ```bash + git -C /home/ai/Projects/pythinker-code-main fetch origin main + git -C /home/ai/Projects/pythinker-code-main diff origin/main -- CHANGELOG.md | grep -E '^\+- ' | grep -c 'pythinker-release-bot' + ``` + Expected: `1` (the bullet is a net-added line). This mirrors the gate's "added line" intent; the authoritative pass/fail is the `changelog` check on the PR. +- [ ] 1.5 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add CHANGELOG.md + git -C /home/ai/Projects/pythinker-code-main commit -m "docs(changelog): note P0 dispatch + installer changes" + ``` + Expected: one file changed, one insertion. + +### Task 2 — Migrate `dispatch-pythinker-home-sync.yml` to the App token + fail-loud + +This is CI-wiring: verify with `actionlint` locally, then `gh workflow run` post-merge (the workflow does not run on `pull_request`). Copy the mint step verbatim from `homebrew-tap.yml:79-86`, swapping the secret names and `repositories`. + +**Files:** Modify `.github/workflows/dispatch-pythinker-home-sync.yml:25-47`. Verify: `actionlint`. + +- [ ] 2.1 Replace the `dispatch` job's `steps:` block (current lines 25-47, where the single `Trigger pythinker-home sync` step reads DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }} and silently `exit 0` on empty) with a job-level `env:` + a mint step + a dispatch step. The exact replacement for lines 25-47: + ```yaml + env: + DISPATCH_OWNER: TechMatrix-labs + DISPATCH_REPO: pythinker-home + steps: + # Mint a short-lived installation token for the org-owned + # pythinker-release-bot App (Contents: write on pythinker-home only). + # Replaces a personal PAT: org-owned (survives member/org changes), + # ~1h TTL, minted fresh each run, scoped to the single private site repo. + - name: Mint GitHub App token for pythinker-home + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }} + owner: ${{ env.DISPATCH_OWNER }} + repositories: ${{ env.DISPATCH_REPO }} + + - name: Trigger pythinker-home sync + env: + DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.sha }} + DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }} + DISPATCH_REPO: ${{ env.DISPATCH_REPO }} + run: | + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::error::No dispatch token: the pythinker-release-bot App token mint produced an empty value. Confirm PYTHINKER_RELEASE_BOT_APP_ID and PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY org secrets are set and the App is installed on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2 + exit 1 + fi + payload=$(jq -n \ + --arg source_repo "$SOURCE_REPO" \ + --arg tag "$RELEASE_TAG" \ + '{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}') + curl --fail-with-body \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \ + -d "$payload" + ``` + Notes: the `permissions: contents: read` on the `dispatch` job (lines 23-24) stays (the App token does the cross-repo write, not `GITHUB_TOKEN`); the silent `exit 0`-on-empty is replaced by `exit 1` (Mode-A fail-loud, §5). `RELEASE_TAG` stays `github.sha` (this file's path-trigger sends a SHA, §7 — handled receiver-side in PR-home-3). +- [ ] 2.2 Lint the file: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/dispatch-pythinker-home-sync.yml + ``` + Expected: prints nothing and exits 0 (`echo $?` → `0`). +- [ ] 2.3 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/dispatch-pythinker-home-sync.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci(dispatch): mint pythinker-release-bot App token, fail loud on empty" + ``` + +### Task 3 — Retire the dead pythinker-core gh-pages docs step + +The target `PythinkerAI/pythinker-core` is a confirmed 404 (§4, table row 8); the whole `docs` job exists only to publish pdoc there. Drop the entire `docs` job (lines 67-127) — without the publish step it would build docs and discard them. + +**Files:** Modify `.github/workflows/release-pythinker-core.yml` (remove the `docs:` job, lines 67-127). Verify: `actionlint`. + +- [ ] 3.1 Delete the `docs:` job block — everything from line 67 (` docs:`) through line 127 (end of file; the last line is ` git -C "$PAGES_DIR" push origin gh-pages`). The file is 127 lines total. The remaining jobs are `validate` and `publish`. Use Edit to remove the block; confirm the file now ends after the `publish` job's `packages-dir: dist/pythinker-core` line. +- [ ] 3.2 Lint: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/release-pythinker-core.yml + ``` + Expected: prints nothing and exits 0. +- [ ] 3.3 Confirm no lingering reference to the deleted secret in this file: + ```bash + grep -n PYTHINKER_CORE_PAGES_TOKEN /home/ai/Projects/pythinker-code-main/.github/workflows/release-pythinker-core.yml || echo "clean" + ``` + Expected: `clean`. +- [ ] 3.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/release-pythinker-core.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci(core): drop dead pdoc gh-pages publish (404 target)" + ``` + +### Task 4 — `install-native.sh`: exponential backoff on asset-wait + +The current loop (lines 229-238) is flat 6×10s. Replace with exponential backoff capped at 120s, ~6m total budget. This is a bash logic change — verify with `shellcheck` + a local dry-run of the backoff arithmetic. + +**Files:** Modify `scripts/install-native.sh:229-238`. Verify: `shellcheck` + local arithmetic check. + +- [ ] 4.1 The current block to replace (lines 229-238): + ```bash + attempt=0 + until release_has_assets; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 6 ]; then + fail "release assets for v${VERSION} are not available yet: ${tarball_url} + The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" + fi + step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}/6)" + sleep 10 + done + ``` + Replace with exponential backoff (4s → cap 120s, ~6m cumulative): + ```bash + # Exponential backoff: the GitHub Release can briefly advertise a version + # whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, + # before giving up — long enough to ride out a slow multi-arch upload. + attempt=0 + delay=4 + elapsed=0 + max_elapsed=360 + until release_has_assets; do + attempt=$((attempt + 1)) + if [ "$elapsed" -ge "$max_elapsed" ]; then + fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} + The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" + fi + step "Waiting for v${VERSION} assets to finish publishing (attempt ${attempt}, retry in ${delay}s)" + sleep "$delay" + elapsed=$((elapsed + delay)) + delay=$((delay * 2)) + [ "$delay" -gt 120 ] && delay=120 + done + ``` +- [ ] 4.2 Verify the backoff sequence and total budget with a standalone reproduction: + ```bash + delay=4; elapsed=0; max=360; seq=""; + while [ "$elapsed" -lt "$max" ]; do seq="$seq $delay"; elapsed=$((elapsed+delay)); delay=$((delay*2)); [ "$delay" -gt 120 ] && delay=120; done + echo "delays:$seq total:${elapsed}s" + ``` + Expected: `delays: 4 8 16 32 64 120 120 total:364s` (7 retries, geometric early then 120s-capped; the loop stops once `elapsed >= 360`). +- [ ] 4.3 Lint the whole file and confirm it is clean: + ```bash + shellcheck /home/ai/Projects/pythinker-code-main/scripts/install-native.sh; echo "exit=$?" + ``` + Expected: `exit=0` (no findings). If shellcheck reports pre-existing findings unrelated to lines 229-238, confirm none are newly introduced by the diff region (the new code uses only quoted POSIX arithmetic and already-defined helpers). +- [ ] 4.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add scripts/install-native.sh + git -C /home/ai/Projects/pythinker-code-main commit -m "fix(install): exponential backoff on native asset-wait" + ``` + +### Task 5 — `install.ps1`: `/releases/latest`-first, paginated fallback, backoff + +The current `Get-LatestVersion` (lines 136-164) scans `releases?per_page=20` and picks the first non-prerelease with the asset pair — a real cliff if the target release is page-2 (`scripts/install.ps1:144`). Fix: hit `/releases/latest` first (preserves the `$release.prerelease` skip at line 152 and the `.exe`+`.sha256` asset-pair guard at line 158 by re-applying both checks), use the paginated scan only as fallback, and add backoff. PowerShell can only be lint-checked with PSScriptAnalyzer on Windows — mark this **manual/CI-verified**. + +**Files:** Modify `scripts/install.ps1:136-164` (`Get-LatestVersion`). Verify: PSScriptAnalyzer (manual) + post-merge real run. + +- [ ] 5.1 Replace the `Get-LatestVersion` function (lines 136-164) with a `/releases/latest`-first resolver that preserves the prerelease skip and asset-pair guard, then falls back to a paginated scan, with backoff: + ```powershell + function Test-ReleaseHasInstaller($release) { + if ($release.draft -or $release.prerelease) { return $null } + $tag = [string]$release.tag_name + if (-not $tag) { return $null } + $candidate = $tag.TrimStart('v') + $exe = "PythinkerSetup-$candidate.exe" + $names = @($release.assets | ForEach-Object { [string]$_.name }) + if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate } + return $null + } + + function Get-LatestVersion { + Step "Looking up latest Pythinker release" + # /releases/latest is prerelease-excluding and not page-bound, so it is the + # correct primary source (fixes the per_page=20 pagination cliff). The + # GitHub Release can briefly advertise a version whose Windows installer is + # still uploading, so retry with exponential backoff (~6m). A paginated + # scan is only a fallback if /latest somehow lacks the asset pair. + $latestApi = "https://api.github.com/repos/$Repo/releases/latest" + $listApi = "https://api.github.com/repos/$Repo/releases?per_page=100" + $delay = 4 + $elapsed = 0 + $maxElapsed = 360 + while ($true) { + try { + $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi + $found = Test-ReleaseHasInstaller $latest + if ($found) { OK "Latest version is $found"; return $found } + } catch { } + try { + $releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi + foreach ($release in @($releases)) { + $found = Test-ReleaseHasInstaller $release + if ($found) { OK "Latest version is $found"; return $found } + } + } catch { } + if ($elapsed -ge $maxElapsed) { + Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION" + } + Step "Windows installer asset not ready yet; retry in ${delay}s" + Start-Sleep -Seconds $delay + $elapsed += $delay + $delay = [Math]::Min($delay * 2, 120) + } + } + ``` +- [ ] 5.2 Static syntax check the file parses (no PSScriptAnalyzer on Linux, but PowerShell-on-Linux can tokenize it). If `pwsh` is available locally: + ```bash + command -v pwsh >/dev/null && pwsh -NoProfile -Command "[void][System.Management.Automation.PSParser]::Tokenize((Get-Content -Raw '/home/ai/Projects/pythinker-code-main/scripts/install.ps1'), [ref]\$null); 'parsed OK'" || echo "pwsh not present — defer parse check to CI/Windows" + ``` + Expected: `parsed OK` (if `pwsh` present) or `pwsh not present — defer parse check to CI/Windows`. If deferred, the real verification is the post-merge manual Windows run in Phase verification. +- [ ] 5.3 Confirm the two preserved guards are present in the new code: + ```bash + grep -nE '\$release\.prerelease|"\$exe\.sha256"' /home/ai/Projects/pythinker-code-main/scripts/install.ps1 + ``` + Expected: two matches — the `$release.draft -or $release.prerelease` skip and the `($names -contains "$exe.sha256")` asset-pair check, both inside `Test-ReleaseHasInstaller`. +- [ ] 5.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add scripts/install.ps1 + git -C /home/ai/Projects/pythinker-code-main commit -m "fix(install): /releases/latest-first + paginated fallback + backoff on Windows" + ``` + +### Task 6 — Open PR-code-1 + +- [ ] 6.1 Push and open the PR: + ```bash + git -C /home/ai/Projects/pythinker-code-main push -u origin release-orch/p0-dispatch-and-installers + gh pr create --repo TechMatrix-labs/pythinker-code --base main \ + --title "ci: dispatch App migration + retire dead docs step + installer hardening" \ + --body "P0 (1/2): migrate dispatch-pythinker-home-sync.yml to the pythinker-release-bot App token with fail-loud-on-empty; remove the dead pythinker-core pdoc gh-pages step (404 target); install-native.sh exponential backoff; install.ps1 /releases/latest-first with paginated fallback and backoff. No agent runtime deps. Sequencing note: promote-release.yml gate changes ship in PR-code-2 with the reconcile backstop." + ``` + Expected: prints the new PR URL. +- [ ] 6.2 Wait for required checks (including `changelog`) to pass and CodeRabbit commit status on the head SHA to be `success` (C2). Verify before merge: + ```bash + gh pr checks --repo TechMatrix-labs/pythinker-code + gh api repos/TechMatrix-labs/pythinker-code/commits/$(gh pr view --repo TechMatrix-labs/pythinker-code --json headRefOid -q .headRefOid)/status --jq '.statuses[] | select(.context=="CodeRabbit") | .state' + ``` + Expected: all checks `pass`; the CodeRabbit line prints `success`. Do not merge until then (C2). +- [ ] 6.3 Merge (after CodeRabbit `success`): + ```bash + gh pr merge --repo TechMatrix-labs/pythinker-code --squash + ``` + Expected: `✓ Squashed and merged pull request #`. +- [ ] 6.4 **Post-merge dispatch smoke test** (needs OP-1..OP-3 done): trigger the dispatch workflow manually and confirm the App-token path fires: + ```bash + gh workflow run dispatch-pythinker-home-sync.yml --repo TechMatrix-labs/pythinker-code + gh run watch --repo TechMatrix-labs/pythinker-code $(gh run list --repo TechMatrix-labs/pythinker-code --workflow dispatch-pythinker-home-sync.yml -L1 --json databaseId -q '.[0].databaseId') + ``` + Expected: the run is green; the "Mint GitHub App token" step succeeds and the dispatch POST returns 204. Then confirm pythinker-home received it: + ```bash + gh run list --repo TechMatrix-labs/pythinker-home --workflow sync-upstream-products.yml -L1 + ``` + Expected: a fresh `repository_dispatch` run is listed. + +--- + +## PR-code-2 — promote-release fail-loud + reconcile backstop (one PR, hard sequencing) + +Branch: `release-orch/p0-promote-reconcile`. Touches `promote-release.yml` and adds `release-readiness-reconcile.yml` (matches `release-*.yml` in `changelog-entry-required.yml:88`) → **requires a `## Unreleased` bullet**. Reconcile + Homebrew-gate-removal ship together so the §5 hard rule ("reconcile lands before/with gate removal") is satisfied with no window where the tap has neither gate nor backstop. + +### Task 7 — CHANGELOG entry for PR-code-2 + +**Files:** Modify `CHANGELOG.md`. + +- [ ] 7.1 Branch from fresh main: + ```bash + git -C /home/ai/Projects/pythinker-code-main switch main && git -C /home/ai/Projects/pythinker-code-main pull --ff-only + git -C /home/ai/Projects/pythinker-code-main switch -c release-orch/p0-promote-reconcile + ``` + Expected: `Switched to a new branch 'release-orch/p0-promote-reconcile'`. +- [ ] 7.2 Add a bullet under `## Unreleased` (C5): + ``` + - Release promotion: block the prerelease→latest flip until `pythinker-code` and every pinned sub-package (`pythinker-core`, `pythinker-host`, `pythinker-review`) resolve on PyPI; remove Homebrew from the promote gate (now backed by a new `release-readiness-reconcile.yml` drift backstop); add per-channel bottleneck detail to the failure Slack, a per-release `release-readiness` tracking issue whose rows tick as channels go ready, and a separate App-authed site-dispatch job that fails loud on an empty token. + ``` +- [ ] 7.3 Approximate the gate locally (HONEST: approximation only; the authoritative check is CI's diff-against-base): + ```bash + git -C /home/ai/Projects/pythinker-code-main diff origin/main -- CHANGELOG.md | grep -E '^\+- ' | grep -c 'release-readiness-reconcile' + ``` + Expected: `1`. +- [ ] 7.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add CHANGELOG.md + git -C /home/ai/Projects/pythinker-code-main commit -m "docs(changelog): note promote fail-loud + reconcile backstop" + ``` + +### Task 8 — promote-release: sub-package PyPI-existence blocking check + remove Homebrew gate + +**Files:** Modify `.github/workflows/promote-release.yml`. The readiness step is lines 59-153. Verify: `actionlint` + post-merge `workflow_dispatch` rehearsal. + +- [ ] 8.1 In the `Wait for install-channel readiness` step, replace the single PyPI URL (line 95) and the Homebrew formula URL (line 96) with a list of all four PyPI URLs that must return 200, deleting `homebrew_formula_url`. Replace lines 95-96 with: + ```bash + # BLOCKING set: /releases/latest and pip must both resolve before the flip. + # pythinker-code AND every pinned sub-package must publish, else + # `pip install pythinker-code==X` 500s on a lagging transitive pin. + pypi_urls=( + "https://pypi.org/pypi/pythinker-code/${version}/json" + "https://pypi.org/pypi/pythinker-core/1.1.1/json" + "https://pypi.org/pypi/pythinker-host/1.0.0/json" + "https://pypi.org/pypi/pythinker-review/0.1.0/json" + ) + ``` + (The sub-package pins are hardcoded here to the values frozen in `pyproject.toml` — core 1.1.1, host 1.0.0, review 0.1.0. P1 will source these from the dep-check; for P0 they are literals matching the SSOT.) +- [ ] 8.2 Replace the per-attempt PyPI/Homebrew readiness evaluation (lines 118-127) with a loop over `pypi_urls`, dropping the Homebrew block entirely: + ```bash + pypi_ready=true + missing_pypi=() + for u in "${pypi_urls[@]}"; do + if ! curl -fsSL --retry 2 --retry-delay 2 -o /dev/null "$u"; then + pypi_ready=false + missing_pypi+=("$u") + fi + done + ``` +- [ ] 8.3 Replace the all-ready condition (line 129) — remove `&& "$homebrew_ready" == "true"`: + ```bash + if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" ]]; then + ``` +- [ ] 8.4 Replace the per-attempt diagnostics (lines 139-144) so each lagging channel is named, and remove the two Homebrew diagnostic lines: + ```bash + if [[ "$pypi_ready" != "true" ]]; then + printf 'PyPI not serving yet: %s\n' "${missing_pypi[*]}" + fi + ``` + (This removes the old `homebrew_ready` "not at version yet" block at lines 142-144; line 137-138's `Missing release assets` block is kept unchanged.) +- [ ] 8.5 Give the readiness step `id: readiness_wait`. Build a local shell `bottleneck` variable, write it to `$GITHUB_OUTPUT` as a multi-line output named `bottleneck`, write a human-readable step summary, and (after the loop, when not ready) **tick the readiness issue rows** by PATCHing the issue body to reflect which channels are ready. Replace the final not-ready block (current lines 150-153) with: + ```bash + # Build the live-status checklist body from the loop's final flags so the + # release-readiness issue is an accurate per-channel status pane (§5). + assets_box="[ ]"; [[ "${#missing_assets[@]}" -eq 0 ]] && assets_box="[x]" + pypi_code_box="[ ]"; pypi_subpkg_box="[ ]" + if [[ "$pypi_ready" == "true" ]]; then + pypi_code_box="[x]"; pypi_subpkg_box="[x]" + else + # Code resolves iff the code URL is not in the missing set. + case " ${missing_pypi[*]} " in + *"pythinker-code/${version}/json"*) ;; + *) pypi_code_box="[x]" ;; + esac + fi + issue_body="$(printf '%s\n' \ + "Tracking install-channel readiness for **${TAG}**." \ + "" \ + "- ${assets_box} GitHub Release assets" \ + "- ${pypi_code_box} PyPI: pythinker-code" \ + "- ${pypi_subpkg_box} PyPI: pinned sub-packages (core/host/review)" \ + "- [ ] Homebrew tap (best-effort)" \ + "- [ ] Site version.json (best-effort)")" + gh api -X PATCH "repos/$REPO/issues/${{ steps.readiness.outputs.number }}" \ + -f body="$issue_body" >/dev/null + + bottleneck="$( + if [[ "${#missing_assets[@]}" -gt 0 ]]; then printf 'Missing assets: %s\n' "${missing_assets[*]}"; fi + if [[ "$pypi_ready" != "true" ]]; then printf 'PyPI not resolvable: %s\n' "${missing_pypi[*]}"; fi + )" + { + echo "### Release readiness for ${TAG} — NOT READY" + [[ -n "$bottleneck" ]] && printf '%s\n' "$bottleneck" + } >> "$GITHUB_STEP_SUMMARY" + { + echo "bottleneck<> "$GITHUB_OUTPUT" + gh issue comment "${{ steps.readiness.outputs.number }}" --repo "$REPO" \ + --body "$(printf 'Promotion stuck for %s after ~%dm.\n\n%s\n\nRe-run via `workflow_dispatch tag=%s` once the bottleneck clears.' "$TAG" "$budget_min" "${bottleneck:-unknown bottleneck}" "$TAG")" + echo "::error::Install channels were not fully ready after ${budget_min} minutes" + exit 1 + ``` + Notes: `bottleneck` is a **local shell variable** read in the same step that wrote it — it is NOT routed through `$GITHUB_ENV` (which only propagates to *later* steps and would always be empty here). Cross-job propagation to the Slack job uses the `$GITHUB_OUTPUT` `bottleneck` value exposed as a job output in Task 10.3. The issue-body PATCH ticks rows so the issue is a live status pane. The `Promote release` step (Task 9.4) ticks the remaining rows / closes the issue on success. +- [ ] 8.6 Lint + commit: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml + ``` + Expected: prints nothing, exits 0. Then: + ```bash + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/promote-release.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci(promote): block flip on all sub-pkg PyPI pins; drop Homebrew gate; tick readiness rows" + ``` + +### Task 9 — promote-release: `issues:write` + release-readiness issue (upsert by exact title) + +**Files:** Modify `.github/workflows/promote-release.yml` (`promote` job `permissions:` and a new step). Verify: `actionlint` + post-merge rehearsal. + +- [ ] 9.1 Extend the `promote` job permissions (current lines 36-37 `permissions:` / `contents: write`) to add `issues: write`: + ```yaml + permissions: + contents: write + issues: write + ``` +- [ ] 9.2 Add a step **before** the readiness wait (between `Resolve and validate tag` and `Wait for install-channel readiness`) that upserts the per-release tracking issue by **exact-title REST list** (not the async search API, which races duplicates). Insert after the `tag` step (after current line 57): + ```yaml + - name: Upsert release-readiness issue + id: readiness + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + title="release-readiness: ${TAG}" + # Exact-title match over ALL open issues (--paginate scans every page; + # the search API is async-indexed and races duplicate creation). + number=$(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ + | jq -r --arg t "$title" '.[] | select(.title==$t) | .number' \ + | head -n1) + body=$'Tracking install-channel readiness for **'"$TAG"$'**.\n\n- [ ] GitHub Release assets\n- [ ] PyPI: pythinker-code\n- [ ] PyPI: pinned sub-packages (core/host/review)\n- [ ] Homebrew tap (best-effort)\n- [ ] Site version.json (best-effort)' + if [ -z "$number" ]; then + number=$(gh api -X POST "repos/$REPO/issues" -f title="$title" -f body="$body" --jq '.number') + echo "Created issue #$number" + else + echo "Reusing issue #$number" + fi + echo "number=$number" >> "$GITHUB_OUTPUT" + ``` + Notes: `gh api --paginate ... | jq -r --arg t ...` — the exact-title filter is a **standalone `jq`** consuming the paginated `gh api` output. `gh api`'s own `--jq` takes only a jq-program string and rejects `--arg`, so the variable injection must be on the piped `jq`. `--paginate` ensures the match scans all open issues, not just page 1. +- [ ] 9.3 The stuck-failure issue comment is already emitted inside the readiness-wait step (Task 8.5, using the local `bottleneck` shell variable and `steps.readiness.outputs.number`). No separate step is needed; confirm the readiness-wait step's `env:` block carries GH_TOKEN: ${{ github.token }} and REPO: ${{ github.repository }} (it already does, current lines 60-63) so `gh issue comment` authenticates. +- [ ] 9.4 On **success**, tick all rows and close the issue. Append to the `Promote release` step (after the PATCH at current line 163-164): + ```bash + all_ready_body=$'Tracking install-channel readiness for **'"$TAG"$'**.\n\n- [x] GitHub Release assets\n- [x] PyPI: pythinker-code\n- [x] PyPI: pinned sub-packages (core/host/review)\n- [ ] Homebrew tap (best-effort)\n- [ ] Site version.json (best-effort)' + gh api -X PATCH "repos/$REPO/issues/${{ steps.readiness.outputs.number }}" -f body="$all_ready_body" >/dev/null || true + gh issue close "${{ steps.readiness.outputs.number }}" --repo "$REPO" \ + --comment "Promoted ${TAG}: prerelease=false, make_latest=true. All blocking channels ready (Homebrew/site reconcile best-effort)." || true + ``` + (The two best-effort rows stay unticked at close time — they are reconciled asynchronously by `release-readiness-reconcile.yml`, not by promote.) +- [ ] 9.5 Lint + commit: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/promote-release.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci(promote): upsert release-readiness issue by exact title; close on success" + ``` + Expected: actionlint prints nothing, exits 0. + +### Task 10 — promote-release: separate App-authed `needs: promote` dispatch job + fail-loud + per-channel Slack + +**Files:** Modify `.github/workflows/promote-release.yml`. Verify: `actionlint` + post-merge rehearsal. + +- [ ] 10.1 **Remove** the `Trigger pythinker-home sync` step from the `promote` job (current lines 166-186, which use `secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN` and the silent `::notice; exit 0`). The promote job now ends after the `Promote release` step (which closes the issue, Task 9.4). +- [ ] 10.2 Add a new top-level job `dispatch-site` with `needs: promote`, mirroring the Task 2 mint pattern, building the dispatch `tag` from the **promote job's live tag output** (not a payload ref), failing loud on empty token. Insert between the `promote` job and `notify-failure`: + ```yaml + dispatch-site: + name: Dispatch pythinker-home sync + runs-on: ubuntu-latest + needs: promote + permissions: + contents: read + env: + DISPATCH_OWNER: TechMatrix-labs + DISPATCH_REPO: pythinker-home + steps: + - name: Mint GitHub App token for pythinker-home + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }} + owner: ${{ env.DISPATCH_OWNER }} + repositories: ${{ env.DISPATCH_REPO }} + + - name: Trigger pythinker-home sync + env: + DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.promote.outputs.tag }} + DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }} + DISPATCH_REPO: ${{ env.DISPATCH_REPO }} + run: | + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::error::No dispatch token: pythinker-release-bot App mint produced an empty value. Site sync skipped, but the release IS promoted and /releases/latest is correct; it self-heals via drift reconcile / daily cron. Fix PYTHINKER_RELEASE_BOT_APP_* org secrets." >&2 + exit 1 + fi + payload=$(jq -n \ + --arg source_repo "$SOURCE_REPO" \ + --arg tag "$RELEASE_TAG" \ + '{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}') + curl --fail-with-body \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \ + -d "$payload" + ``` + Notes: `RELEASE_TAG` is `needs.promote.outputs.tag` (a validated `vX.Y.Z` from the live API, §7) — never a raw payload ref. The receiver in PR-home-3 uses this tag only for raw-source pinning; asset URLs are always built from the live API `tag_name`. +- [ ] 10.3 Expose `tag` and `bottleneck` as `promote` job outputs so `dispatch-site` and `notify-failure` can read them. Add to the `promote` job after `runs-on: ubuntu-latest` (current line 34): + ```yaml + outputs: + tag: ${{ steps.tag.outputs.tag }} + bottleneck: ${{ steps.readiness_wait.outputs.bottleneck }} + ``` + (`steps.tag` is the `Resolve and validate tag` step; `steps.readiness_wait` is the `Wait for install-channel readiness` step given `id: readiness_wait` in Task 8.5.) +- [ ] 10.4 Make `notify-failure` cover both jobs and add per-channel bottleneck detail. Change `needs: promote` (current line 191) to `needs: [promote, dispatch-site]`, extend the Slack step `env:`, and rebuild the payload `detail` from the two job results (do NOT replace the job — §5). Replace the `notify-failure` step's `env:` block (current lines 197-201) with: + ```yaml + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + TAG: ${{ inputs.tag || github.ref_name }} + PROMOTE_RESULT: ${{ needs.promote.result }} + DISPATCH_RESULT: ${{ needs.dispatch-site.result }} + BOTTLENECK: ${{ needs.promote.outputs.bottleneck }} + ``` + And replace the existing `payload=$(jq -n ...)` block (current lines 207-211) so the Slack message carries a `Detail` field with the named bottleneck: + ```bash + detail="promote=${PROMOTE_RESULT}, dispatch=${DISPATCH_RESULT}" + if [ "$PROMOTE_RESULT" != "success" ]; then + detail="$detail — release STUCK as prerelease; /releases/latest still last-good. Bottleneck: ${BOTTLENECK:-unknown}. See readiness issue." + else + detail="$detail — release PROMOTED; only site sync failed, self-heals via reconcile/cron." + fi + payload=$(jq -n \ + --arg run_url "$RUN_URL" --arg repo "$REPO" --arg tag "$TAG" --arg detail "$detail" \ + '{"text":":red_circle: *Release promotion failed*","attachments":[{"color":"danger","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Tag","value":$tag,"short":true},{"title":"Detail","value":$detail,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}') + ``` +- [ ] 10.5 Lint + confirm no PAT reference remains: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml + grep -n PYTHINKER_HOME_REPO_DISPATCH_TOKEN /home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml || echo "clean" + ``` + Expected: actionlint prints nothing (exit 0); the grep prints `clean`. +- [ ] 10.6 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/promote-release.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci(promote): App-authed needs:promote dispatch job, fail-loud, per-channel Slack" + ``` + +### Task 11 — Create `release-readiness-reconcile.yml` (the Homebrew backstop) + +Detects drift (tap formula version != `/releases/latest` OR served `public/version.json` != `/releases/latest`), re-dispatches the site sync idempotently, alerts on drift, and auto-closes `release-readiness` issues for releases **older than** the current latest. This MUST be in the same PR as the gate removal (§5 hard rule — satisfied because both are in PR-code-2). + +**Files:** Create `.github/workflows/release-readiness-reconcile.yml`. Verify: `actionlint` + post-merge `workflow_dispatch`. + +- [ ] 11.1 Create the file: + ```yaml + name: Release readiness reconcile + + # Backstop for best-effort channels (Homebrew tap, website) now that Homebrew + # is no longer a promote gate. Detects drift between /releases/latest and the + # tap formula / served public/version.json, re-dispatches the site sync + # idempotently, escalates to Slack on drift, and auto-closes release-readiness + # issues for releases OLDER than the current latest (semver-ordered, so a newer + # in-progress release's issue is left open). + + on: + workflow_dispatch: + schedule: + - cron: "47 5 * * *" + + permissions: + contents: read + issues: write + + concurrency: + group: release-readiness-reconcile + cancel-in-progress: false + + env: + DISPATCH_OWNER: TechMatrix-labs + DISPATCH_REPO: pythinker-home + + jobs: + reconcile: + runs-on: ubuntu-latest + outputs: + drift: ${{ steps.detect.outputs.drift }} + redispatched: ${{ steps.redispatch.outputs.redispatched }} + steps: + - name: Detect drift + id: detect + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + latest_tag=$(gh api "repos/$REPO/releases/latest" --jq '.tag_name') + latest="${latest_tag#v}" + echo "Latest published release: $latest_tag ($latest)" + drift="" + tap_url="https://raw.githubusercontent.com/TechMatrix-labs/homebrew-pythinker/main/Formula/pythinker-code.rb" + tap_text=$(curl -fsSL --retry 2 --retry-delay 2 "$tap_url" 2>/dev/null || true) + if ! grep -qF "version \"${latest}\"" <<<"$tap_text"; then + drift="$drift tap" + fi + ver_url="https://pythinker.com/version.json" + served=$(curl -fsSL --retry 2 --retry-delay 2 "$ver_url" 2>/dev/null | jq -r '.pythinkerCode // empty' || true) + if [ "$served" != "$latest" ]; then + drift="$drift site($served)" + fi + echo "drift=$drift" >> "$GITHUB_OUTPUT" + echo "latest=$latest" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + if [ -n "$drift" ]; then echo "::warning::Drift detected:$drift"; else echo "No drift."; fi + + - name: Auto-close stale (older-than-latest) release-readiness issues + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + LATEST_TAG: ${{ steps.detect.outputs.latest_tag }} + run: | + set -euo pipefail + # Close an open release-readiness issue ONLY if its tag is OLDER than + # the current latest (semver). A NEWER in-progress release's issue + # must stay open (§5 — do not close a release that hasn't promoted + # yet). sort -V puts the older tag first; we close only when this + # issue's tag is the older one AND differs from latest. + gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ + --jq '.[] | select(.title|startswith("release-readiness: ")) | "\(.number)\t\(.title)"' \ + | while IFS=$'\t' read -r num title; do + tag="${title#release-readiness: }" + [ "$tag" = "$LATEST_TAG" ] && continue + older=$(printf '%s\n%s\n' "$tag" "$LATEST_TAG" | sort -V | head -n1) + if [ "$older" = "$tag" ]; then + gh issue close "$num" --repo "$REPO" --comment "Auto-closed: superseded by newer published release ${LATEST_TAG}." || true + else + echo "Leaving #$num ($tag) open: newer than latest ${LATEST_TAG} (in-progress release)." + fi + done + + - name: Mint GitHub App token for pythinker-home + if: steps.detect.outputs.drift != '' + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }} + owner: ${{ env.DISPATCH_OWNER }} + repositories: ${{ env.DISPATCH_REPO }} + + - name: Re-dispatch site sync on drift + id: redispatch + if: steps.detect.outputs.drift != '' + env: + DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.detect.outputs.latest_tag }} + DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }} + DISPATCH_REPO: ${{ env.DISPATCH_REPO }} + run: | + set -euo pipefail + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "::error::Empty App token during drift re-dispatch." >&2 + exit 1 + fi + payload=$(jq -n --arg source_repo "$SOURCE_REPO" --arg tag "$RELEASE_TAG" \ + '{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}') + curl --fail-with-body -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + "https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \ + -d "$payload" + echo "redispatched=true" >> "$GITHUB_OUTPUT" + + notify-drift: + name: Notify on persistent drift + runs-on: ubuntu-latest + needs: reconcile + # Only page when this run did NOT itself re-dispatch — i.e. drift on a + # workflow_dispatch re-check after a prior cycle already re-dispatched. A + # normal release that re-dispatches this cycle will NOT page (the next + # scheduled run re-checks; if still drifted it has not re-dispatched and + # pages). This approximates the §5 "persistent across cycles" rule without + # a stored counter — accepted deviation, documented below. + if: needs.reconcile.outputs.drift != '' && needs.reconcile.outputs.redispatched != 'true' + permissions: + contents: read + steps: + - name: Post Slack alert + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DRIFT: ${{ needs.reconcile.outputs.drift }} + run: | + set -euo pipefail + if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then exit 0; fi + payload=$(jq -n --arg run_url "$RUN_URL" --arg drift "$DRIFT" \ + '{"text":":warning: *Release channel drift persisting*","attachments":[{"color":"warning","fields":[{"title":"Drifted channels","value":$drift,"short":false},{"title":"Action","value":"Drift was NOT re-dispatched this run (prior cycle already re-dispatched and it did not converge). Investigate the tap/site workflow.","short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}') + curl --fail-with-body -X POST -H "Content-Type: application/json" -d "$payload" "$SLACK_WEBHOOK_URL" + ``` + Notes: **Accepted deviation from §5 "persistent across N cycles":** there is no stored per-channel cycle counter (a counter is logged as a P0.1 refinement — YAGNI for the quick-win phase). Instead, the Slack alert is suppressed on the same run that re-dispatched (`redispatched=true`), so a normal release does not page; it only pages on a *later* run that still sees drift it did not just re-dispatch — i.e. drift that prior re-dispatch failed to converge. The `Auto-close` step uses `--paginate` to scan all open issues and `sort -V` semver ordering so only releases **older** than latest are closed (a newer in-progress release's issue is left open). `re-dispatch` reuses the App token (cron/manual carry no `client_payload`, so the receiver gate in PR-home-3 lets it through). +- [ ] 11.2 Lint: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-code-main/.github/workflows/release-readiness-reconcile.yml + ``` + Expected: prints nothing, exits 0. +- [ ] 11.3 Verify the semver close-logic with a standalone reproduction (older closes, newer stays): + ```bash + LATEST_TAG=v0.27.0 + for tag in v0.26.0 v0.27.0 v0.28.0; do + older=$(printf '%s\n%s\n' "$tag" "$LATEST_TAG" | sort -V | head -n1) + if [ "$tag" != "$LATEST_TAG" ] && [ "$older" = "$tag" ]; then echo "$tag -> CLOSE"; else echo "$tag -> keep"; fi + done + ``` + Expected: `v0.26.0 -> CLOSE`, `v0.27.0 -> keep`, `v0.28.0 -> keep` (the newer in-progress tag is left open). +- [ ] 11.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-code-main add .github/workflows/release-readiness-reconcile.yml + git -C /home/ai/Projects/pythinker-code-main commit -m "ci: add release-readiness-reconcile backstop for best-effort channels" + ``` + +### Task 11b — Open PR-code-2 + +- [ ] 11b.1 Push + PR: + ```bash + git -C /home/ai/Projects/pythinker-code-main push -u origin release-orch/p0-promote-reconcile + gh pr create --repo TechMatrix-labs/pythinker-code --base main \ + --title "ci(promote): fail-loud sub-pkg PyPI gate + drift reconcile backstop" \ + --body "P0 (2/2): block prerelease->latest until all pinned sub-packages resolve on PyPI; remove Homebrew from the gate; per-channel bottleneck Slack; release-readiness issue (issues:write) whose rows tick as channels go ready; separate App-authed needs:promote dispatch job with fail-loud token; asset URLs from API tag_name. Ships release-readiness-reconcile.yml in the SAME PR so the §5 sequencing rule (reconcile before/with gate removal) holds; reconcile auto-closes only OLDER-than-latest readiness issues (semver). Depends on PR-code-1 (dispatch App migration) for the full PAT retirement." + ``` + Expected: prints the new PR URL. +- [ ] 11b.2 Wait for checks + CodeRabbit `success` on the head SHA (C2), as in Task 6.2: + ```bash + gh pr checks --repo TechMatrix-labs/pythinker-code + gh api repos/TechMatrix-labs/pythinker-code/commits/$(gh pr view --repo TechMatrix-labs/pythinker-code --json headRefOid -q .headRefOid)/status --jq '.statuses[] | select(.context=="CodeRabbit") | .state' + ``` + Expected: all checks `pass`; CodeRabbit `success`. Merge with `gh pr merge --repo TechMatrix-labs/pythinker-code --squash` only after `success`. + +--- + +## PR-home-3 — Site: receiver gate, version.json, Mode-B fix, dead mirrors, deploy + +Branch: `release-orch/p0-site`, in `/home/ai/Projects/pythinker-site/site`. This repo has **no JS test runner and is not subject to `changelog-entry-required.yml`** (different repo). Use the built-in `bun:test` runner (zero new deps). The TS module currently runs `await main()` at top level (line 390) and exports nothing — so the first task makes it importable, which is a hard prerequisite for every TS TDD step. + +### Task 12 — Make the TS importable, then TDD the version.json emit + ref-pin + line-366 fix + +**Files:** Modify `scripts/sync-upstream-products.ts`; Create `scripts/sync-upstream-products.test.ts`. Verify: `bun test` (real failing-first). + +- [ ] 12.1 Branch: + ```bash + git -C /home/ai/Projects/pythinker-site/site switch -c release-orch/p0-site + ``` + Expected: `Switched to a new branch 'release-orch/p0-site'`. +- [ ] 12.2 **Refactor for testability (prerequisite).** Change the top-level entrypoint (line 390 `await main();`) to guard it, and export the functions under test. Replace line 390 with: + ```ts + if (import.meta.main) { + await main(); + } + + export { + products, + buildMetadata, + renderReadme, + resolveRawRef, + buildVersionJson, + }; + ``` + (`renderReadme`, `resolveRawRef`, `buildVersionJson` are added in 12.5-12.6.) +- [ ] 12.3 Write the **failing** test file `scripts/sync-upstream-products.test.ts` first. This includes the §7 item-2 config lockstep that asserts each product's `brewCommand` and derived release URL AGREE with its `owner`/`repo` (not just `repo != "Pythinker"`): + ```ts + import { describe, expect, test } from "bun:test"; + import { + products, + buildMetadata, + renderReadme, + resolveRawRef, + buildVersionJson, + } from "./sync-upstream-products.ts"; + + const codeRelease = { + tag_name: "v0.27.0", + html_url: "https://github.com/TechMatrix-labs/pythinker-code/releases/tag/v0.27.0", + assets: [ + { name: "PythinkerSetup-0.27.0.exe", browser_download_url: "" }, + { name: "PythinkerSetup-0.27.0.exe.sha256", browser_download_url: "" }, + { name: "pythinker-code_0.27.0_amd64.deb", browser_download_url: "" }, + { name: "pythinker-code_0.27.0_arm64.deb", browser_download_url: "" }, + { name: "pythinker-code-0.27.0.x86_64.rpm", browser_download_url: "" }, + { name: "pythinker-code-0.27.0.aarch64.rpm", browser_download_url: "" }, + ], + }; + + const aiProduct = products.find((p) => p.key === "ai")!; + const codeProduct = products.find((p) => p.key === "code")!; + + describe("buildVersionJson", () => { + test("emits pythinkerCode + tag from the code release", () => { + const meta = buildMetadata(codeProduct, codeRelease); + expect(buildVersionJson(meta)).toEqual({ + pythinkerCode: "0.27.0", + tag: "v0.27.0", + }); + }); + }); + + describe("resolveRawRef", () => { + test("accepts a vX.Y.Z tag", () => { + expect(resolveRawRef("v0.27.0", "main")).toBe("v0.27.0"); + }); + test("accepts a 40-char sha", () => { + const sha = "0".repeat(40); + expect(resolveRawRef(sha, "main")).toBe(sha); + }); + test("falls back to branch on garbage", () => { + expect(resolveRawRef("not-a-ref; rm -rf /", "main")).toBe("main"); + }); + test("falls back to branch on empty", () => { + expect(resolveRawRef("", "main")).toBe("main"); + }); + }); + + describe("renderReadme (line-366 literal fix)", () => { + test("AI rewrite derives destination from product owner/repo config", () => { + const src = "see github.com/mohamed-elkholy95/Pythinker/releases for downloads"; + const out = renderReadme(aiProduct, src); + expect(out).toContain(`github.com/${aiProduct.owner}/${aiProduct.repo}/releases`); + expect(out).not.toContain("mohamed-elkholy95/Pythinker/releases"); + }); + test("code README passes through unchanged", () => { + const src = "pip install pythinker-code\n"; + expect(renderReadme(codeProduct, src)).toBe(src); + }); + }); + + describe("config integrity (Mode-B lockstep, §7 item 2)", () => { + test("no product config carries the stale pre-migration slug", () => { + for (const p of products) { + expect(p.owner.length).toBeGreaterThan(0); + expect(p.repo.length).toBeGreaterThan(0); + expect(p.repo).not.toBe("Pythinker"); + } + }); + test("each product's brewCommand agrees with its product key", () => { + // brew formula name = pythinker-; a stale slug would diverge. + for (const p of products) { + expect(p.brewCommand).toContain(`pythinker-${p.key}`); + } + }); + test("derived release URLs are built from the product owner/repo", () => { + for (const p of products) { + const meta = buildMetadata(p, { + tag_name: "v0.27.0", + html_url: `https://github.com/${p.owner}/${p.repo}/releases/tag/v0.27.0`, + assets: codeRelease.assets, + }); + const releaseRepo = p.releaseRepo ?? p.repo; + const expectedBase = `https://github.com/${p.owner}/${releaseRepo}/releases`; + expect(meta.latestReleaseUrl).toContain(expectedBase); + expect(meta.releaseDownloadBaseUrl).toContain(`${expectedBase}/latest/download`); + expect(meta.releaseDownloadBaseUrl).toContain(p.owner); + } + }); + }); + ``` + (The third config test reuses `codeRelease.assets`, whose asset names match the `pythinker-code`/`pythinker-ai` patterns `buildMetadata` requires; both products' asset regexes accept the `pythinker-_0.27.0_*` / `PythinkerSetup-*` names present in that fixture.) +- [ ] 12.4 Run the test and watch it **fail** (functions not exported / not defined): + ```bash + cd /home/ai/Projects/pythinker-site/site && bun test scripts/sync-upstream-products.test.ts + ``` + Expected: failures — `resolveRawRef`/`buildVersionJson`/`renderReadme` are not exported (import errors or "is not a function"). +- [ ] 12.5 Implement `renderReadme` (replaces the inline ternary at lines 365-367). Add the function near `buildMetadata`: + ```ts + // The AI README still carries the pre-migration "Pythinker" repo slug in its + // release links (an old repo name that is NOT in any product config). Rewrite + // it to the configured owner/repo so the served README never points at the + // dead repo. Destination is DERIVED from config (not a second hardcoded + // literal) so a future owner/repo change can't reintroduce the 0.24.0 drift. + function renderReadme(product: ProductConfig, readme: string): string { + if (product.key !== "ai") return readme; + const legacy = "github.com/mohamed-elkholy95/Pythinker/releases"; + const dest = `github.com/${product.owner}/${product.repo}/releases`; + return readme.replaceAll(legacy, dest); + } + ``` +- [ ] 12.6 Implement `resolveRawRef` and `buildVersionJson`. Add near the top-level helpers (e.g. after `rawUrl`): + ```ts + // The dispatched ref is either a release tag (vX.Y.Z) or a 40-char commit SHA + // (the install-script/README push path sends github.sha). Validate strictly — + // it is interpolated into a raw.githubusercontent URL — and fall back to the + // product branch on anything else. NEVER used to build release-asset URLs + // (those always come from the live API tag_name). + function resolveRawRef(ref: string | undefined, branch: string): string { + if (ref && /^(v\d+\.\d+\.\d+|[0-9a-f]{40})$/.test(ref)) return ref; + return branch; + } + + function buildVersionJson(meta: ProductMetadata): { pythinkerCode: string; tag: string } { + return { pythinkerCode: meta.version, tag: meta.tag }; + } + ``` +- [ ] 12.7 Wire the ref-pin into `rawUrl`/`syncProduct`, replace the line-366 call site with `renderReadme`, and emit `version.json` in `main`. Change `rawUrl` (line 184) to accept a ref: + ```ts + function rawUrl(product: ProductConfig, sourcePath: string, ref: string): string { + return `https://raw.githubusercontent.com/${product.owner}/${product.repo}/${ref}/${sourcePath}`; + } + ``` + Replace `syncProduct` (lines 359-382) so it computes the ref once and threads it through both raw fetches and uses `renderReadme`: + ```ts + async function syncProduct(product: ProductConfig): Promise { + const ref = resolveRawRef(process.env.SYNC_SOURCE_REF, product.branch); + const [release, readme] = await Promise.all([ + fetchJson(apiLatestReleaseUrl(product)), + fetchText(rawUrl(product, product.readmeSourcePath, ref)), + ]); + const readmeContents = renderReadme(product, readme); + writeTextFile(product.readmeTargetPath, readmeContents); + for (const mirror of product.installMirrors ?? []) { + const installSource = await fetchText(rawUrl(product, mirror.sourcePath, ref)); + validateMirrorSource(installSource, mirror.validators); + for (const targetPath of mirror.targetPaths) { + writeTextFile(targetPath, installSource); + } + } + const metadata = buildMetadata(product, release); + writeTextFile(product.metadataPath, renderMetadataModule(product.metadataConstName, metadata)); + return metadata; + } + ``` + Replace `main` (lines 384-388) to emit `public/version.json` from the code product: + ```ts + async function main(): Promise { + const ai = await syncProduct(products[0]); + const code = await syncProduct(products[1]); + updateLlmsText(ai, code); + writeTextFile("public/version.json", `${JSON.stringify(buildVersionJson(code), null, 2)}\n`); + } + ``` +- [ ] 12.8 Run the test and watch it **pass**: + ```bash + cd /home/ai/Projects/pythinker-site/site && bun test scripts/sync-upstream-products.test.ts + ``` + Expected: all tests pass (e.g. `11 pass, 0 fail`). This is the real gate for the TS changes (it imports and exercises the edited module). +- [ ] 12.9 Commit (NOTE: do NOT rely on `bun run typecheck` here — `site/tsconfig.json`'s `include` is `["src/**/*", "src/**/*.vue", "env.d.ts"]`, so `vue-tsc` never sees `scripts/`; the `bun test` in 12.8 is the authoritative verification for this file): + ```bash + git -C /home/ai/Projects/pythinker-site/site add scripts/sync-upstream-products.ts scripts/sync-upstream-products.test.ts + git -C /home/ai/Projects/pythinker-site/site commit -m "feat(sync): version.json emit, ref-pinned raw fetch, config-derived README rewrite + tests" + ``` + +### Task 13 — Receiver source-repo gate + `git rm` dead mirrors + +**Files:** Modify `.github/workflows/sync-upstream-products.yml`; Modify `scripts/sync-upstream-products.ts` (drop dead mirror targets); `git rm` 3 files. Verify: `actionlint` + `bun test`. + +- [ ] 13.1 Add the job-level receiver gate to `sync-upstream-products.yml`. The gate must let cron/manual through (no payload) and only restrict `repository_dispatch`. Add to the `sync` job after `runs-on: ubuntu-latest` (current line 20): + ```yaml + if: github.event_name != 'repository_dispatch' || github.event.client_payload.source_repo == 'TechMatrix-labs/pythinker-code' + ``` +- [ ] 13.2 Thread the dispatched ref into the sync via `env:` (NEVER into a `run:` line — injection, §4). Edit the `Sync public upstream products` step (current lines 30-33): + ```yaml + - name: Sync public upstream products + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SYNC_SOURCE_REF: ${{ github.event.client_payload.tag }} + run: bun run sync:products + ``` + (The TS validates `SYNC_SOURCE_REF` via `resolveRawRef` before any use; an empty/garbage value falls back to `branch`. For cron/manual there is no payload, so `SYNC_SOURCE_REF` is empty → branch fallback.) +- [ ] 13.3 Lint: + ```bash + ~/go/bin/actionlint /home/ai/Projects/pythinker-site/site/.github/workflows/sync-upstream-products.yml + ``` + Expected: prints nothing, exits 0. +- [ ] 13.4 Drop the 3 dead mirror target paths from the TS config (lines 123-129). Edit the `scripts/install.ps1` mirror's `targetPaths` to keep only the canonical served copy: + ```ts + targetPaths: [ + "public/install.ps1", + ], + ``` + (`scripts/install.ps1`, `web/public/install.ps1`, `docs/public/install.ps1` are removed — they are byte-identical dead mirrors; canonical served pair is `public/install.{sh,ps1}`, §7.) +- [ ] 13.5 `git rm` the 3 tracked dead mirrors: + ```bash + git -C /home/ai/Projects/pythinker-site/site rm scripts/install.ps1 web/public/install.ps1 docs/public/install.ps1 + ``` + Expected: `rm 'scripts/install.ps1'`, `rm 'web/public/install.ps1'`, `rm 'docs/public/install.ps1'`. +- [ ] 13.6 Re-run the TS tests (config change must not break them): + ```bash + cd /home/ai/Projects/pythinker-site/site && bun test scripts/sync-upstream-products.test.ts + ``` + Expected: all tests still pass (e.g. `11 pass, 0 fail`). +- [ ] 13.7 Commit: + ```bash + git -C /home/ai/Projects/pythinker-site/site add .github/workflows/sync-upstream-products.yml scripts/sync-upstream-products.ts + git -C /home/ai/Projects/pythinker-site/site commit -m "ci(sync): receiver source-repo gate, ref via env; git rm 3 dead install.ps1 mirrors" + ``` + +### Task 14 — Retire orphaned GHCR+Watchtower+Traefik deploy (GATED by OP-4) + +Only do this if **OP-4** confirmed the live host runs Dokploy build-from-source. Otherwise skip and log under "Out of scope". This is reversible (`git rm`). + +**Files:** `git rm` `docker-compose.yml`, `docker-compose.private-ghcr.yml`, `deploy/traefik/` (recursive), `deploy/.env.example`; rewrite `deploy/README.md`. Verify: visual + grep for dead refs. + +- [ ] 14.1 Remove the dead compose + traefik + env files: + ```bash + git -C /home/ai/Projects/pythinker-site/site rm docker-compose.yml docker-compose.private-ghcr.yml deploy/.env.example + git -C /home/ai/Projects/pythinker-site/site rm -r deploy/traefik + ``` + Expected: `rm 'docker-compose.yml'` etc. (If any path is already absent, confirm with OP-4's host findings and adjust the list to only the tracked files; `git ls-files docker-compose.yml docker-compose.private-ghcr.yml deploy/.env.example deploy/traefik` lists what is actually tracked.) +- [ ] 14.2 Rewrite `deploy/README.md` around Dokploy build-from-source (keep `Dockerfile`/`nixpacks.toml`/`server.ts` as documented). Replace the whole file: + ```markdown + # Deployment + + The Pythinker landing site deploys via **Dokploy build-from-source**: Dokploy + builds the repo with nixpacks (`nixpacks.toml`) and runs `bun run server.ts`, + which serves the built `dist/` and a `bun:sqlite` install-counter behind a + POST endpoint. The website-sync workflow's `git push` to `main` is the deploy + trigger. + + ## What runs + - `server.ts` — Bun server: static `dist/` + `/api` install-copy counter (SQLite at `/app/.data`). + - `Dockerfile` + `nixpacks.toml` — single-container build inputs (Dokploy uses nixpacks; `Dockerfile` is a documented fallback for Railway/Render/Coolify-style hosts). + + Persist `/app/.data` across redeploys or the SQLite counter resets. + + ## Counter environment variables + ```env + INSTALL_COPY_COUNTER_HOME_INITIAL_VALUE=0 + INSTALL_COPY_COUNTER_AI_INITIAL_VALUE=0 + ``` + `HOME` is the root page (`/`); `AI` is `/ai`. Stored values only increase. + + ## Deploy dependency + This chain relies on `pythinker-home`'s `main` being unprotected so the sync + workflow can push. If `main` is ever protected, exempt `github-actions[bot]` + or the deploy chain breaks. + + > The previous GHCR image + Watchtower + Traefik compose stack was retired + > (no image was published after `docker.yml` was deleted, and the GHCR ref + > pointed at a stale org). Canonical deploy is Dokploy build-from-source. + ``` +- [ ] 14.3 Confirm no remaining references to the retired stack in tracked files: + ```bash + cd /home/ai/Projects/pythinker-site/site && git grep -nE 'watchtower|traefik|SITE_IMAGE|private-ghcr' -- . ':!deploy/README.md' || echo "clean" + ``` + Expected: `clean` (the only `traefik`/`watchtower` mentions remaining, if any, are the historical note inside `deploy/README.md`, which is excluded). +- [ ] 14.4 Commit: + ```bash + git -C /home/ai/Projects/pythinker-site/site add -A + git -C /home/ai/Projects/pythinker-site/site commit -m "chore(deploy): retire orphaned GHCR+Watchtower+Traefik; canonical = Dokploy build-from-source" + ``` + +### Task 15 — Open PR-home-3 + +- [ ] 15.1 Push + PR: + ```bash + git -C /home/ai/Projects/pythinker-site/site push -u origin release-orch/p0-site + gh pr create --repo TechMatrix-labs/pythinker-home --base main \ + --title "P0: receiver source-repo gate, version.json, Mode-B fix, dead mirrors, deploy retire" \ + --body "P0 site half: job-level receiver if: on client_payload.source_repo (cron/manual carry no payload -> allowed); ref passed via env, validated in TS, never into a run: shell; emit public/version.json {pythinkerCode,tag}; fix the line-366 hardcoded literal to derive from per-product owner/repo config; pin raw-source fetch to the dispatched ref (tag or 40-char sha) while asset URLs stay from API tag_name; git rm the 3 dead tracked install.ps1 mirrors (canonical = public/install.{sh,ps1}); retire orphaned GHCR+Watchtower+Traefik compose (Dokploy build-from-source is canonical). New bun:test unit tests (incl. §7 config lockstep) for the TS logic." + ``` + Expected: prints the new PR URL. +- [ ] 15.2 Wait for checks + CodeRabbit `success` on the head SHA (C2): + ```bash + gh pr checks --repo TechMatrix-labs/pythinker-home + gh api repos/TechMatrix-labs/pythinker-home/commits/$(gh pr view --repo TechMatrix-labs/pythinker-home --json headRefOid -q .headRefOid)/status --jq '.statuses[] | select(.context=="CodeRabbit") | .state' + ``` + Expected: checks `pass`; CodeRabbit `success`. Merge `--squash` only after `success`. (pythinker-home `main` must stay unprotected for the deploy chain — do not enable protection.) + +--- + +## Phase verification + +**Done = all three PRs merged (each past CodeRabbit `success`, C2), the App fully replaces both PAT dispatch sites, the next release flips only when every pinned sub-package resolves on PyPI, the readiness issue ticks rows then closes on success, and the site serves a correct `public/version.json` with the reconcile backstop live.** Prove it with one rehearsal + one real cycle: + +1. **App dispatch (post PR-code-1):** `gh workflow run dispatch-pythinker-home-sync.yml --repo TechMatrix-labs/pythinker-code` → `gh run watch` green; the mint step succeeds; pythinker-home shows a fresh `repository_dispatch` sync run that **passes the receiver gate** (source_repo matches). This proves the App token + receiver gate end-to-end. (Confirms OP-1..OP-3.) + +2. **Reconcile dry-run (post PR-code-2 + PR-home-3):** `gh workflow run release-readiness-reconcile.yml --repo TechMatrix-labs/pythinker-code` → `gh run watch`. With the site already at latest, expect **no drift** (`drift=` empty), the stale-issue close step runs cleanly (closing only older-than-latest issues, leaving any newer in-progress issue open), `notify-drift` does NOT run. To prove the drift path, the run's "Detect drift" log shows the served `pythinkerCode` vs latest comparison; the `notify-drift` job's `if:` (drift present AND not re-dispatched this run) confirms a normal re-dispatch run does not page. + +3. **promote rehearsal (no real tag):** after a real release tag exists, `gh workflow run promote-release.yml --repo TechMatrix-labs/pythinker-code -f tag=v` re-enters CHECKING; with all four PyPI URLs already 200 and assets present, it PROMOTES idempotently (PATCH is a no-op), the `release-readiness` issue is upserted, its rows ticked, then closed, and the `dispatch-site` job mints the App token and fires. The Slack `notify-failure` job does NOT run (no failure). This exercises the new blocking-PyPI check and the separated dispatch job without waiting on a fresh build. + +4. **First real release** (the true end-to-end): maintainer tags `vX.Y.Z`; `promote-release` waits for assets + all four PyPI pins; on ready it flips `prerelease=false, make_latest=true`, ticks+closes the readiness issue, and the `needs: promote` dispatch job updates pythinker-home → Dokploy redeploys → `https://pythinker.com/version.json` returns `{"pythinkerCode":"X.Y.Z","tag":"vX.Y.Z"}`. Confirm: + ```bash + curl -fsSL https://pythinker.com/version.json + gh release view vX.Y.Z --repo TechMatrix-labs/pythinker-code --json isLatest,isPrerelease + pip index versions pythinker-code # or: pip install pythinker-code==X.Y.Z --dry-run + ``` + Expected: `version.json` == X.Y.Z; release `isLatest=true, isPrerelease=false`; `pip install` resolves all transitive pins (no 500 on a lagging sub-package). + +5. **Retire the PATs (OP-5):** after step 4's green cycle, delete `PYTHINKER_HOME_REPO_DISPATCH_TOKEN` and `PYTHINKER_CORE_PAGES_TOKEN`. Re-run step 1's dispatch once more to confirm nothing depended on the deleted PAT (still green via the App). + +**Negative-path checks (must hold):** if a sub-package PyPI pin lags, the promote run stays in CHECKING and on budget-exhaust **keeps the release as prerelease** (so `/releases/latest` serves last-good), PATCHes the readiness issue body with the unticked sub-package row, comments the per-channel bottleneck on the issue, posts the red Slack with the `Detail` field carrying the named bottleneck (via `needs.promote.outputs.bottleneck`), and exits 1 — never flips to a half-resolvable `pip install`. If the `dispatch-site` job fails (empty/missing App token), it exits 1 loud with the "release IS promoted, self-heals via reconcile/cron" Slack detail and does NOT contaminate the promote success signal. The readiness-issue auto-close must NEVER close a release-readiness issue whose tag is semver-newer than `/releases/latest` (Task 11.3 reproduction proves the `sort -V` guard). diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md new file mode 100644 index 00000000..b749eecc --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md @@ -0,0 +1,1115 @@ +# Release Tool + Version Single-Source-of-Truth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Make `pyproject.toml:3` the single authoritative version, build `scripts/release.py` to rewrite every derived file + `uv.lock` from it and open a `release/X.Y.Z` PR, and enforce the version relationship (including the frozen `pythinker-review==0.1.0` pin) with an extended dependency-check script + a new `tests/test_version_lockstep.py` that runs on every PR. + +**Architecture:** `scripts/release.py` is stdlib + shells out to `git`/`gh`/`uv` (C3-exempt CI/release tooling — the shipped agent gains zero runtime deps). It is factored so all rewrite logic is pure functions (semver/monotonic validation, tomlkit rewrites with a `tomllib` parse-back assertion, CHANGELOG `## Unreleased`→`## X.Y.Z (DATE)` promotion, pattern-targeted README/asset rewrites) — those get real failing-test-first pytest; the orchestration (git/gh/uv) is verified via `--dry-run`. The lockstep test asserts every version-bearing string on every main commit; `--version` flag examples are asserted shape-only (the documented §3 exception). `update.py` gains a `PYTHINKER_MANAGED` env hook ahead of the existing brew path-sniff (with a usable channel-native hint wired into both consumer paths) so P2 channels ship self-updating, with a mandatory brew-unchanged regression test. + +**Tech Stack:** Python 3.12+ (stdlib `argparse`/`subprocess`/`tomllib`/`re`/`datetime` + `tomlkit` 0.15.0 already at `pyproject.toml:50`), `uv` (`/home/ai/.local/bin/uv`), `gh` CLI, pytest 9 (run via `uv run pytest`). + +--- + +## Prerequisites (manual / operator) + +P1 is purely local tooling + tests. It creates **no** GitHub Apps, **no** org/repo secrets, and **no** new repos (those are P0/P2). The only sensitive, outward-facing action is the post-merge tag push, which is the deliberate last human step under C1. + +- [ ] **Operator — confirm local tooling.** `uv` must be on `PATH` (verified at `/home/ai/.local/bin/uv`) and `gh` must be authenticated (`gh auth status`). `release.py` shells out to both. +- [ ] **Operator — post-merge tag push (C1, after each release PR merges).** `release.py` does *not* push tags; it prints the exact command(s). For a pure code release: `git tag vX.Y.Z && git push origin vX.Y.Z`. When `--bump-core/--bump-host` was used, push the sub-package tags first (`git tag pythinker-core-A.B.C && git push origin pythinker-core-A.B.C`, likewise host), wait for their OIDC PyPI publish jobs to land, **then** push `vX.Y.Z`. +- [ ] **Operator — merge gate (C2).** Before merging any P1 PR, confirm the `CodeRabbit` commit status on the PR head SHA is `success`. + +There are no admin/secret/App actions in P1. + +--- + +## File Structure + +**Created** +- `scripts/release.py` — the release orchestrator: 4 phases (validate → rewrite-from-SSOT + `uv lock` → local gates → branch/PR), CLI `--set-version X.Y.Z [--bump-core A.B.C] [--bump-host A.B.C] [--dry-run]`. +- `tests/test_version_lockstep.py` — stdlib+tomllib CI test (every PR): semver shape; core/host/review pins == sub-pkg versions; review frozen at 0.1.0; README heading + pip snippet; asset-name shapes == VERSION across README + linux-installer README + getting-started.md; CHANGELOG `## X (`; `--version` flag examples are valid-semver shape only. +- `tests/test_release_py.py` — unit tests for the pure functions of `scripts/release.py` (and the extended dep-check script). + +**Modified** +- `scripts/check_pythinker_dependency_versions.py` — add a `--pythinker-review-pyproject` arg + a third `("pythinker-review", ...)` tuple so the `pythinker-review==0.1.0` pin must match `packages/pythinker-review`. +- `.github/workflows/ci-pythinker-cli.yml:253-256` — pass `--pythinker-review-pyproject` to the dep-check call (or argparse fails CI red, since the new arg is `required=True`). +- `.github/workflows/release-pythinker-cli.yml:57-60` — same new arg for the release-time dep-check call. +- `src/pythinker_code/ui/shell/update.py` — `MANAGED_CHANNEL_MARKER` constant (after `NATIVE_INSTALLER_MARKER:61`); `PYTHINKER_MANAGED` env read at the top of `_detect_upgrade_command()` (line 95); a managed-channel branch in `_update_prompt_text()` (line 615) so the rendered "Update method" is a real channel-native hint; a managed-channel early-return in `do_update()` (after the detection at line 1215, before the readiness gate at line 1216) so a managed install neither mis-fires the PyPI readiness check nor tries to exec the marker. Brew path left unchanged. +- `tests/ui_and_conv/test_shell_update.py` — add the brew-unchanged + `PYTHINKER_MANAGED` regression tests (this is the file that actually imports `update`; `tests/test_release_update_pipeline.py` is workflow-text only and does NOT import `update`). +- `tests/test_release_update_pipeline.py` — add a test asserting `changelog-entry-required.yml` skips on both the `chore(release)*` title (line 54) and the `release/*` head branch (line 57) — the skip-contract that `release.py.open_pr()` depends on. (Workflow-text file, the correct home for this assertion.) +- `docs/en/release-notes/breaking-changes.md` — add a `## Unreleased` anchor (currently absent) so `release.py`'s heading promotion is uniform across all three changelog files. +- `.agents/skills/release/SKILL.md` — repoint the `update_files` (lines 22-25) and `uv_sync` (line 35) nodes at `uv run python scripts/release.py`. + +--- + +## Task 1 — Extend `check_pythinker_dependency_versions.py` with the `pythinker-review` tuple + +**Branch:** `git switch -c p1/release-tool` (created here; **all** subsequent tasks commit to this one branch — the required-arg change and both workflow-caller edits MUST ship in the same PR, or a partial merge turns CI red with `argparse: the following arguments are required: --pythinker-review-pyproject`). + +**Files:** +- Modify: `scripts/check_pythinker_dependency_versions.py:43-68` +- Modify: `.github/workflows/ci-pythinker-cli.yml:253-256` +- Modify: `.github/workflows/release-pythinker-cli.yml:57-60` +- Test: `tests/test_release_py.py` (new — `subprocess`-invokes the script) + +1. - [ ] Create the branch. `git switch -c p1/release-tool`. +2. - [ ] Write the failing test. Create `tests/test_release_py.py` with: + + ```python + from __future__ import annotations + + import subprocess + import sys + from pathlib import Path + + REPO_ROOT = Path(__file__).resolve().parents[1] + DEP_CHECK = REPO_ROOT / "scripts" / "check_pythinker_dependency_versions.py" + + + def _write(tmp_path: Path, name: str, body: str) -> Path: + p = tmp_path / name + p.write_text(body, encoding="utf-8") + return p + + + def _run_dep_check(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(DEP_CHECK), *args], + capture_output=True, + text=True, + ) + + + def test_dep_check_passes_when_review_pin_matches(tmp_path: Path) -> None: + root = _write( + tmp_path, + "root.toml", + '[project]\nname="pythinker-code"\nversion="0.27.0"\n' + 'dependencies=["pythinker-core[contrib]==1.1.1","pythinker-host==1.0.0",' + '"pythinker-review==0.1.0"]\n', + ) + core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') + host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') + review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.1.0"\n') + result = _run_dep_check( + "--root-pyproject", str(root), + "--pythinker-core-pyproject", str(core), + "--pythinker-host-pyproject", str(host), + "--pythinker-review-pyproject", str(review), + ) + assert result.returncode == 0, result.stderr + + + def test_dep_check_fails_when_review_pin_drifts(tmp_path: Path) -> None: + root = _write( + tmp_path, + "root.toml", + '[project]\nname="pythinker-code"\nversion="0.27.0"\n' + 'dependencies=["pythinker-core[contrib]==1.1.1","pythinker-host==1.0.0",' + '"pythinker-review==0.1.0"]\n', + ) + core = _write(tmp_path, "core.toml", '[project]\nname="pythinker-core"\nversion="1.1.1"\n') + host = _write(tmp_path, "host.toml", '[project]\nname="pythinker-host"\nversion="1.0.0"\n') + review = _write(tmp_path, "review.toml", '[project]\nname="pythinker-review"\nversion="0.2.0"\n') + result = _run_dep_check( + "--root-pyproject", str(root), + "--pythinker-core-pyproject", str(core), + "--pythinker-host-pyproject", str(host), + "--pythinker-review-pyproject", str(review), + ) + assert result.returncode == 1 + assert "pythinker-review version mismatch" in result.stderr + ``` + + (The expected substring matches the script's existing `f"{name} version mismatch: ..."` error format at `check_pythinker_dependency_versions.py:82`.) +3. - [ ] Run it and see it fail. `uv run pytest tests/test_release_py.py -q` → both tests fail (non-zero exit because argparse rejects the unknown flag: `error: unrecognized arguments: --pythinker-review-pyproject`). +4. - [ ] Add the argparse flag. In `scripts/check_pythinker_dependency_versions.py`, after `parser.add_argument("--pythinker-host-pyproject", type=Path, required=True)` (line 47), add: + + ```python + parser.add_argument("--pythinker-review-pyproject", type=Path, required=True) + ``` + +5. - [ ] Add the third tuple. Change the loop header (lines 65-68) from: + + ```python + for name, pyproject_path in ( + ("pythinker-core", args.pythinker_core_pyproject), + ("pythinker-host", args.pythinker_host_pyproject), + ): + ``` + + to: + + ```python + for name, pyproject_path in ( + ("pythinker-core", args.pythinker_core_pyproject), + ("pythinker-host", args.pythinker_host_pyproject), + ("pythinker-review", args.pythinker_review_pyproject), + ): + ``` + +6. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q` → `2 passed`. +7. - [ ] Update the CI caller. In `.github/workflows/ci-pythinker-cli.yml`, change the existing dependency-check block to use the project-managed launcher and include review: + + ```yaml + uv run python scripts/check_pythinker_dependency_versions.py \ + --root-pyproject pyproject.toml \ + --pythinker-core-pyproject packages/pythinker-core/pyproject.toml \ + --pythinker-host-pyproject packages/pythinker-host/pyproject.toml \ + --pythinker-review-pyproject packages/pythinker-review/pyproject.toml + ``` + +8. - [ ] Update the release caller. In `.github/workflows/release-pythinker-cli.yml`, apply the identical change to the block at lines 57-60 (same trailing-`\` addition on the host line + the new review line). +9. - [ ] Sanity-check the real workspace passes. `uv run python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. +10. - [ ] Lint the workflows. `uvx actionlint .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml` (if `actionlint` is unavailable, fall back to `uv run python -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml`) → no output / exit 0. +11. - [ ] Commit. `git add scripts/check_pythinker_dependency_versions.py tests/test_release_py.py .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml && git commit -m "feat(release): enforce pythinker-review pin in dependency check"` + +--- + +## Task 2 — `release.py` Phase-1 validation helpers (pure, TDD) + +**Files:** +- Create: `scripts/release.py` (validation helpers + changelog-path constant only this task) +- Test: `tests/test_release_py.py` + +1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + + ```python + import importlib.util + + _spec = importlib.util.spec_from_file_location( + "release_tool", REPO_ROOT / "scripts" / "release.py" + ) + assert _spec and _spec.loader + release_tool = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(release_tool) + + + def test_parse_semver_accepts_xyz() -> None: + assert release_tool.parse_semver("0.28.0") == (0, 28, 0) + + + def test_parse_semver_rejects_non_xyz() -> None: + import pytest + + with pytest.raises(release_tool.ReleaseError): + release_tool.parse_semver("0.28") + with pytest.raises(release_tool.ReleaseError): + release_tool.parse_semver("v0.28.0") + + + def test_assert_monotonic_allows_increase() -> None: + release_tool.assert_monotonic(current="0.27.0", target="0.28.0") + + + def test_assert_monotonic_rejects_equal_or_lower() -> None: + import pytest + + with pytest.raises(release_tool.ReleaseError): + release_tool.assert_monotonic(current="0.27.0", target="0.27.0") + with pytest.raises(release_tool.ReleaseError): + release_tool.assert_monotonic(current="0.27.0", target="0.26.0") + ``` + +2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q` → `ModuleNotFoundError`/import error (file does not exist yet). +3. - [ ] Create the module with the validation helpers + the shared changelog-path constant. Write `scripts/release.py`: + + ```python + #!/usr/bin/env python3 + """Pythinker-code release orchestrator. + + Rewrites every version-derived file + uv.lock from the single source of + truth (pyproject.toml:3), runs the same gates CI runs, and opens a + release/X.Y.Z PR. It never pushes to main and never pushes the tag — the + maintainer pushes the tag(s) after the PR merges (C1). + + stdlib + shells out to git/gh/uv. The shipped agent gains zero runtime deps + (C3: CI/release-tooling exemption). + """ + + from __future__ import annotations + + import argparse + import re + import subprocess + import sys + import tomllib + from datetime import date + from pathlib import Path + + import tomlkit + + REPO_ROOT = Path(__file__).resolve().parents[1] + ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" + CORE_PYPROJECT = REPO_ROOT / "packages" / "pythinker-core" / "pyproject.toml" + HOST_PYPROJECT = REPO_ROOT / "packages" / "pythinker-host" / "pyproject.toml" + REVIEW_PYPROJECT = REPO_ROOT / "packages" / "pythinker-review" / "pyproject.toml" + + # Single source for the three hand-authored changelog files. validate() asserts + # the `## Unreleased` anchor in ALL of them before any write, and rewrite() + # promotes the SAME list — defined once so the two can never drift (atomic + # Phase-2 guarantee: no partial-write if a docs file is missing its anchor). + CHANGELOG_FILES = ( + REPO_ROOT / "CHANGELOG.md", + REPO_ROOT / "docs" / "en" / "release-notes" / "changelog.md", + REPO_ROOT / "docs" / "en" / "release-notes" / "breaking-changes.md", + ) + + SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + + class ReleaseError(Exception): + """Raised when a precondition or rewrite invariant fails.""" + + + def parse_semver(version: str) -> tuple[int, int, int]: + m = SEMVER_RE.match(version) + if not m: + raise ReleaseError(f"not a valid x.y.z version: {version!r}") + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + + def assert_monotonic(*, current: str, target: str) -> None: + if parse_semver(target) <= parse_semver(current): + raise ReleaseError( + f"target version {target} must be strictly greater than current {current}" + ) + + + def read_project_version(pyproject_path: Path) -> str: + with pyproject_path.open("rb") as fh: + data = tomllib.load(fh) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise ReleaseError(f"missing project.version in {pyproject_path}") + return version + ``` + +4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q` → previous tests still pass + the 4 new ones pass. +5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): add release.py validation helpers"` + +--- + +## Task 3 — `release.py` SSOT rewrite of pyproject + sub-package pins (tomlkit + tomllib parse-back, TDD) + +**Files:** +- Modify: `scripts/release.py` +- Test: `tests/test_release_py.py` + +1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + + ```python + def test_set_root_version_rewrites_and_parses_back(tmp_path: Path) -> None: + src = ( + '[project]\nname = "pythinker-code"\nversion = "0.27.0"\n' + 'dependencies = [\n' + ' "pythinker-core[contrib]==1.1.1",\n' + ' "pythinker-host==1.0.0",\n' + ' "pythinker-review==0.1.0",\n' + ']\n' + ) + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + release_tool.set_root_version(p, "0.28.0") + assert release_tool.read_project_version(p) == "0.28.0" + + + def test_set_dependency_pin_updates_extras_form(tmp_path: Path) -> None: + src = ( + '[project]\nname = "x"\nversion = "0.1.0"\n' + 'dependencies = [\n "pythinker-core[contrib]==1.1.1",\n "rich==15.0.0",\n]\n' + ) + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + release_tool.set_dependency_pin(p, "pythinker-core", "1.2.0") + with p.open("rb") as fh: + deps = tomllib.load(fh)["project"]["dependencies"] + assert "pythinker-core[contrib]==1.2.0" in deps + assert "rich==15.0.0" in deps # untouched + + + def test_set_dependency_pin_rejects_missing(tmp_path: Path) -> None: + import pytest + + src = '[project]\nname="x"\nversion="0.1.0"\ndependencies=["rich==15.0.0"]\n' + p = tmp_path / "pyproject.toml" + p.write_text(src, encoding="utf-8") + with pytest.raises(release_tool.ReleaseError): + release_tool.set_dependency_pin(p, "pythinker-core", "1.2.0") + ``` + +2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k "set_root_version or set_dependency_pin"` → `AttributeError: module 'release_tool' has no attribute 'set_root_version'`. +3. - [ ] Implement the rewrites. Append to `scripts/release.py`: + + ```python + _DEP_PIN_RE = re.compile( + r"^(?P[A-Za-z0-9._-]+)(?P\[[^\]]+\])?==(?P[^;\s]+)(?P.*)$" + ) + + + def _dump_and_verify(path: Path, doc: tomlkit.TOMLDocument) -> None: + """Write `doc` then re-read with tomllib to confirm it parses.""" + path.write_text(tomlkit.dumps(doc), encoding="utf-8") + with path.open("rb") as fh: + tomllib.load(fh) # raises tomllib.TOMLDecodeError if we produced junk + + + def set_root_version(pyproject_path: Path, version: str) -> None: + parse_semver(version) + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + doc["project"]["version"] = version # type: ignore[index] + _dump_and_verify(pyproject_path, doc) + if read_project_version(pyproject_path) != version: + raise ReleaseError(f"parse-back failed: {pyproject_path} did not re-read as {version}") + + + def set_dependency_pin(pyproject_path: Path, name: str, version: str) -> None: + """Rewrite the `name[extras]==` pin in [project].dependencies, preserving extras.""" + parse_semver(version) + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + deps = doc["project"]["dependencies"] # type: ignore[index] + found = False + for i, dep in enumerate(deps): + m = _DEP_PIN_RE.match(str(dep)) + if m and m.group("name") == name: + extras = m.group("extras") or "" + rest = m.group("rest") or "" + deps[i] = f"{name}{extras}=={version}{rest}" + found = True + break + if not found: + raise ReleaseError(f"no `=={''}` pin for {name} in {pyproject_path}") + _dump_and_verify(pyproject_path, doc) + # parse-back assertion: the intended pin re-reads to the intended version + with pyproject_path.open("rb") as fh: + reread = tomllib.load(fh)["project"]["dependencies"] + expected = next((d for d in reread if d.split("==")[0].split("[")[0] == name), None) + if expected is None or expected.split("==", 1)[1].split(";")[0].strip() != version: + raise ReleaseError(f"parse-back failed: {name} pin in {pyproject_path} != {version}") + ``` + +4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k "set_root_version or set_dependency_pin"` → `3 passed`. +5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): add tomlkit version/pin rewrites with parse-back assertions"` + +--- + +## Task 4 — `release.py` CHANGELOG `## Unreleased` → `## X.Y.Z (DATE)` promotion (body preserved, C5, TDD) + +**Files:** +- Modify: `scripts/release.py` +- Modify: `docs/en/release-notes/breaking-changes.md:1-5` +- Test: `tests/test_release_py.py` + +1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + + ```python + def test_promote_changelog_preserves_body_and_reinserts_unreleased(tmp_path: Path) -> None: + src = ( + "# Changelog\n\n" + "## Unreleased\n\n" + "- **Did a thing.** Detail line.\n\n" + "## 0.27.0 (2026-05-31)\n\n- Older entry.\n" + ) + p = tmp_path / "CHANGELOG.md" + p.write_text(src, encoding="utf-8") + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + out = p.read_text(encoding="utf-8") + assert "## Unreleased\n" in out # empty anchor re-inserted + assert "## 0.28.0 (2026-06-01)\n" in out + assert "- **Did a thing.** Detail line." in out # authored body preserved + # the new dated section sits above the previous release + assert out.index("## 0.28.0 (2026-06-01)") < out.index("## 0.27.0 (2026-05-31)") + # the empty Unreleased anchor sits above the new dated section + assert out.index("## Unreleased") < out.index("## 0.28.0 (2026-06-01)") + + + def test_promote_changelog_empty_unreleased_is_ok(tmp_path: Path) -> None: + src = "# Changelog\n\n## Unreleased\n\n## 0.27.0 (2026-05-31)\n\n- Older.\n" + p = tmp_path / "CHANGELOG.md" + p.write_text(src, encoding="utf-8") + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + out = p.read_text(encoding="utf-8") + assert "## 0.28.0 (2026-06-01)" in out + assert "## Unreleased" in out + + + def test_promote_changelog_missing_anchor_raises(tmp_path: Path) -> None: + import pytest + + p = tmp_path / "CHANGELOG.md" + p.write_text("# Changelog\n\n## 0.27.0 (2026-05-31)\n", encoding="utf-8") + with pytest.raises(release_tool.ReleaseError): + release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") + ``` + +2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k promote_changelog` → `AttributeError: ... 'promote_changelog'`. +3. - [ ] Implement promotion. Append to `scripts/release.py`: + + ```python + _UNRELEASED_RE = re.compile(r"^## Unreleased[ \t]*$", re.MULTILINE) + + + def promote_changelog(path: Path, version: str, *, release_date: str) -> None: + """Rename `## Unreleased` to `## X.Y.Z (DATE)`, preserving its body (C5), + and re-insert a fresh empty `## Unreleased` above it. + """ + parse_semver(version) + text = path.read_text(encoding="utf-8") + m = _UNRELEASED_RE.search(text) + if m is None: + raise ReleaseError(f"no `## Unreleased` anchor in {path}") + # Replace the heading line in place, then prepend a new empty anchor. + dated = f"## {version} ({release_date})" + promoted = text[: m.start()] + dated + text[m.end() :] + new_text = ( + promoted[: m.start()] + "## Unreleased\n\n" + promoted[m.start() :] + ) + path.write_text(new_text, encoding="utf-8") + ``` + +4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k promote_changelog` → `3 passed`. +5. - [ ] Add the missing `## Unreleased` anchor to breaking-changes.md so promotion is uniform across all three files. In `docs/en/release-notes/breaking-changes.md`, change lines 1-5 from: + + ``` + # Breaking changes and migration + + This page documents breaking changes in Pythinker Code releases and provides migration guidance. + + ## 0.27.0 (2026-05-31) + ``` + + to: + + ``` + # Breaking changes and migration + + This page documents breaking changes in Pythinker Code releases and provides migration guidance. + + ## Unreleased + + ## 0.27.0 (2026-05-31) + ``` + +6. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py docs/en/release-notes/breaking-changes.md && git commit -m "feat(release): promote changelog Unreleased heading preserving body"` + +--- + +## Task 5 — `release.py` pattern-targeted README/docs/asset rewrites (NOT a blanket replace, TDD) + +**Files:** +- Modify: `scripts/release.py` +- Test: `tests/test_release_py.py` + +Rewrite **only** these patterns (everything else — including the `--version 0.27.0` flag examples at README:303 and getting-started.md:34 — is left untouched, per §3): the `## 🆕 What's New in X` heading, the `pythinker-code==X` pip snippet, `PythinkerSetup-X.Y.Z.exe`, `pythinker-code_X.Y.Z_.deb`, `pythinker-code-X.Y.Z..rpm`, and `/releases/download/vX.Y.Z/`. **Badges are a deliberate no-op:** the only version-bearing badge, the PyPI badge at README:12, is shields.io-live (`https://img.shields.io/pypi/v/pythinker-code...`) and the Python badge at README:13 is a `3.12%2B` requires-python floor — no badge carries a literal package version, so the contract's "badges" clause is satisfied by zero rewrites (a lockstep guard in Task 7 prevents future hardcoded-version-badge drift). + +1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + + ```python + def test_rewrite_version_strings_targets_only_release_patterns() -> None: + text = ( + "## 🆕 What's New in 0.27.0\n" + "pip install --upgrade pythinker-code==0.27.0\n" + "PythinkerSetup-0.27.0.exe\n" + "pythinker-code_0.27.0_amd64.deb\n" + "pythinker-code-0.27.0.x86_64.rpm\n" + "releases/download/v0.27.0/pythinker-code_0.27.0_arm64.deb\n" + "bash -s -- --version 0.27.0\n" # flag example: MUST be preserved + ) + out = release_tool.rewrite_version_strings(text, old="0.27.0", new="0.28.0") + assert "## 🆕 What's New in 0.28.0" in out + assert "pythinker-code==0.28.0" in out + assert "PythinkerSetup-0.28.0.exe" in out + assert "pythinker-code_0.28.0_amd64.deb" in out + assert "pythinker-code-0.28.0.x86_64.rpm" in out + assert "releases/download/v0.28.0/pythinker-code_0.28.0_arm64.deb" in out + # the flag example is the documented exception — untouched + assert "--version 0.27.0" in out + assert "--version 0.28.0" not in out + ``` + +2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k rewrite_version_strings` → `AttributeError`. +3. - [ ] Implement targeted rewrites. Append to `scripts/release.py`: + + ```python + def rewrite_version_strings(text: str, *, old: str, new: str) -> str: + """Replace ONLY release-pattern occurrences of `old` with `new`. + + Deliberately skips `--version ` flag examples (the documented + §3 exception) so they stay shape-only — the lockstep test enforces this. + """ + o = re.escape(old) + patterns = [ + (rf"(What's New in ){o}", rf"\g<1>{new}"), + (rf"(pythinker-code==){o}", rf"\g<1>{new}"), + (rf"(PythinkerSetup-){o}(\.exe)", rf"\g<1>{new}\g<2>"), + (rf"(pythinker-code_){o}(_[a-z0-9]+\.deb)", rf"\g<1>{new}\g<2>"), + (rf"(pythinker-code-){o}(\.[a-z0-9_]+\.rpm)", rf"\g<1>{new}\g<2>"), + (rf"(releases/download/v){o}(/)", rf"\g<1>{new}\g<2>"), + ] + for pat, repl in patterns: + text = re.sub(pat, repl, text) + return text + + + def rewrite_version_in_files(paths: list[Path], *, old: str, new: str) -> None: + for path in paths: + original = path.read_text(encoding="utf-8") + path.write_text(rewrite_version_strings(original, old=old, new=new), encoding="utf-8") + ``` + +4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k rewrite_version_strings` → `1 passed`. +5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): pattern-targeted README/asset version rewrites"` + +--- + +## Task 6 — `release.py` orchestration (4 phases) + `--dry-run` (verified by dry-run, not fake pytest) + +**Files:** +- Modify: `scripts/release.py` +- Test: dry-run walkthrough (orchestration is git/gh/uv — no local pytest faking those) + +The git/gh/uv orchestration is genuine I/O and is verified by `--dry-run` + a rehearsal in "Phase verification". `--dry-run` runs Phase 1 (validate) + prints the intended rewrites and tag order, but writes no files, runs no `uv lock`, and creates no branch/PR. Note: `validate()` asserts the `## Unreleased` anchor in **all three** changelog files (via `CHANGELOG_FILES`) before any write, so a missing anchor in a docs file fails loud in Phase 1 and never leaves a partially-rewritten tree (atomic Phase-2 guarantee). + +1. - [ ] Implement the phases + CLI. Append to `scripts/release.py`: + + ```python + def _run(cmd: list[str], *, dry_run: bool, check: bool = True) -> subprocess.CompletedProcess[str]: + if dry_run: + print(f"[dry-run] {' '.join(cmd)}") + return subprocess.CompletedProcess(cmd, 0, "", "") + print(f"$ {' '.join(cmd)}") + return subprocess.run(cmd, cwd=REPO_ROOT, text=True, check=check) + + + def _git_capture(cmd: list[str]) -> str: + return subprocess.run( + cmd, cwd=REPO_ROOT, text=True, capture_output=True, check=True + ).stdout.strip() + + + def validate(target: str) -> None: + """Phase 1 — fail loud, no writes.""" + parse_semver(target) + if _git_capture(["git", "status", "--porcelain"]): + raise ReleaseError("working tree is not clean; commit or stash first") + _git_capture(["git", "fetch", "origin"]) + local = _git_capture(["git", "rev-parse", "main"]) + remote = _git_capture(["git", "rev-parse", "origin/main"]) + if local != remote: + raise ReleaseError("local main != origin/main; rebase onto origin/main first") + assert_monotonic(current=read_project_version(ROOT_PYPROJECT), target=target) + # Assert the `## Unreleased` anchor in ALL changelog files BEFORE any write + # (same list rewrite() promotes) so Phase 2 cannot partially rewrite the tree. + for changelog in CHANGELOG_FILES: + if _UNRELEASED_RE.search(changelog.read_text(encoding="utf-8")) is None: + raise ReleaseError(f"{changelog} has no `## Unreleased` section") + # The primary CHANGELOG's body may legitimately be empty (CI-only/docs release): + # warn, do not abort. + primary = CHANGELOG_FILES[0].read_text(encoding="utf-8") + m = _UNRELEASED_RE.search(primary) + assert m is not None # guaranteed by the loop above + body = primary[m.end():].split("\n## ", 1)[0].strip() + if not body: + print("warning: `## Unreleased` body is empty (CI-only/docs release?)") + + + def rewrite(target: str, *, bump_core: str | None, bump_host: str | None) -> None: + """Phase 2 — rewrite all derived files + regenerate uv.lock.""" + old = read_project_version(ROOT_PYPROJECT) + set_root_version(ROOT_PYPROJECT, target) + if bump_core: + set_root_version(CORE_PYPROJECT, bump_core) + set_dependency_pin(ROOT_PYPROJECT, "pythinker-core", bump_core) + if bump_host: + set_root_version(HOST_PYPROJECT, bump_host) + set_dependency_pin(ROOT_PYPROJECT, "pythinker-host", bump_host) + today = date.today().isoformat() + for changelog in CHANGELOG_FILES: + promote_changelog(changelog, target, release_date=today) + rewrite_version_in_files( + [ + REPO_ROOT / "README.md", + REPO_ROOT / "packages" / "linux-installer" / "README.md", + REPO_ROOT / "docs" / "en" / "guides" / "getting-started.md", + ], + old=old, + new=target, + ) + + + GATES = [ + ["python", "scripts/check_version_tag.py", "--pyproject", "pyproject.toml", + "--expected-version", "{target}"], + ["python", "scripts/check_pythinker_dependency_versions.py", + "--root-pyproject", "pyproject.toml", + "--pythinker-core-pyproject", "packages/pythinker-core/pyproject.toml", + "--pythinker-host-pyproject", "packages/pythinker-host/pyproject.toml", + "--pythinker-review-pyproject", "packages/pythinker-review/pyproject.toml"], + ["uv", "sync", "--frozen", "--all-extras", "--all-packages"], + ["uv", "run", "pytest", "tests/test_version_lockstep.py", "-q"], + ] + + + def run_gates(target: str) -> None: + """Phase 3 — the same gates CI runs; abort before push on any failure.""" + for tmpl in GATES: + cmd = [part.format(target=target) for part in tmpl] + result = subprocess.run(cmd, cwd=REPO_ROOT, text=True) + if result.returncode != 0: + raise ReleaseError(f"local gate failed: {' '.join(cmd)}") + # README/CHANGELOG fixed-string greps (grep -qF, not regex). + for needle, path in ( + (f"What's New in {target}", "README.md"), + (f"pythinker-code=={target}", "README.md"), + (f"## {target} (", "CHANGELOG.md"), + ): + if subprocess.run(["grep", "-qF", needle, path], cwd=REPO_ROOT).returncode != 0: + raise ReleaseError(f"expected string {needle!r} not found in {path}") + + + def open_pr(target: str, *, bump_core: str | None, bump_host: str | None, dry_run: bool) -> None: + """Phase 4 — branch + commit + push + PR (never main, C1).""" + branch = f"release/{target}" + _run(["git", "switch", "-c", branch], dry_run=dry_run) + _run(["git", "add", "-A"], dry_run=dry_run) + _run(["git", "commit", "-m", f"chore(release): prepare {target}"], dry_run=dry_run) + _run(["git", "push", "-u", "origin", branch], dry_run=dry_run) + _run( + ["gh", "pr", "create", "--base", "main", "--head", branch, + "--title", f"chore(release): prepare {target}", + "--body", f"Automated release prep for {target}. Tag after merge (C1)."], + dry_run=dry_run, + ) + print("\nAfter the PR merges and CodeRabbit status is success, push the tag(s):") + if bump_core: + print(f" git tag pythinker-core-{bump_core} && git push origin pythinker-core-{bump_core}") + if bump_host: + print(f" git tag pythinker-host-{bump_host} && git push origin pythinker-host-{bump_host}") + if bump_core or bump_host: + print(" # wait for the sub-package OIDC publish jobs to land on PyPI, THEN:") + print(f" git tag v{target} && git push origin v{target}") + + + def main() -> int: + parser = argparse.ArgumentParser(description="Prepare a pythinker-code release PR.") + parser.add_argument("--set-version", required=True, help="target X.Y.Z") + parser.add_argument("--bump-core", default=None, help="new pythinker-core A.B.C") + parser.add_argument("--bump-host", default=None, help="new pythinker-host A.B.C") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + target = args.set_version + try: + validate(target) + if args.dry_run: + print(f"[dry-run] would rewrite SSOT -> {target}" + + (f", core -> {args.bump_core}" if args.bump_core else "") + + (f", host -> {args.bump_host}" if args.bump_host else "")) + print("[dry-run] would run: uv lock; gates; branch+PR") + open_pr(target, bump_core=args.bump_core, bump_host=args.bump_host, dry_run=True) + return 0 + rewrite(target, bump_core=args.bump_core, bump_host=args.bump_host) + _run(["uv", "lock"], dry_run=False) + run_gates(target) + open_pr(target, bump_core=args.bump_core, bump_host=args.bump_host, dry_run=False) + except ReleaseError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + + if __name__ == "__main__": + raise SystemExit(main()) + ``` + + (No dead `text = ROOT_PYPROJECT` line — the anchor check is the `CHANGELOG_FILES` loop inside `validate()`.) +2. - [ ] Lint the module. `uv run ruff check scripts/release.py && uv run ruff format --check scripts/release.py` → exit 0 (run `uv run ruff format scripts/release.py` first if formatting fails). There should be zero F841/unused-variable findings. +3. - [ ] Confirm the unit tests still pass. `uv run pytest tests/test_release_py.py -q` → all pure-function tests pass (orchestration is not under pytest). +4. - [ ] Dry-run verification (no writes). On a clean tree synced to origin/main: `uv run python scripts/release.py --set-version 0.28.0 --dry-run`. Expected: prints the `warning` only if Unreleased body is empty, then `[dry-run] would rewrite SSOT -> 0.28.0`, `[dry-run] git switch -c release/0.28.0`, ... `[dry-run] gh pr create ...`, and the tag-order block ending `git tag v0.28.0 && git push origin v0.28.0`. Confirm `git status --porcelain` is still empty afterward (dry-run wrote nothing). +5. - [ ] Commit. `git add scripts/release.py && git commit -m "feat(release): add 4-phase orchestration with uv lock + frozen-sync gate"` + +--- + +## Task 7 — `tests/test_version_lockstep.py` (runs on every PR; equality on assets, shape on flags, TDD) + +**Files:** +- Create: `tests/test_version_lockstep.py` +- Test: itself — it must pass against the current repo at `0.27.0` / core `1.1.1` / host `1.0.0` / review `0.1.0`. + +This test is the CI safety net. It asserts only relationships true on every main commit (never "a tag exists"). The real tree was verified: CHANGELOG.md has `## 0.27.0 (` at line 20 and `## Unreleased` at line 16, so `test_changelog_has_dated_heading_for_version` is green as-is. + +1. - [ ] Write the test as failing-by-construction first, then make it green against the real tree. Create `tests/test_version_lockstep.py`: + + ```python + from __future__ import annotations + + import re + import tomllib + from pathlib import Path + + REPO_ROOT = Path(__file__).resolve().parents[1] + SEMVER = r"\d+\.\d+\.\d+" + + + def _version(rel: str) -> str: + with (REPO_ROOT / rel).open("rb") as fh: + return tomllib.load(fh)["project"]["version"] + + + def _root_deps() -> list[str]: + with (REPO_ROOT / "pyproject.toml").open("rb") as fh: + return tomllib.load(fh)["project"]["dependencies"] + + + def _pin(name: str) -> str: + for dep in _root_deps(): + head = dep.split("==", 1) + if len(head) == 2 and head[0].split("[")[0] == name: + return head[1].split(";")[0].strip() + raise AssertionError(f"no =={''} pin for {name}") + + + VERSION = _version("pyproject.toml") + + + def test_version_is_semver() -> None: + assert re.fullmatch(SEMVER, VERSION), VERSION + + + def test_subpackage_pins_match_versions() -> None: + assert _pin("pythinker-core") == _version("packages/pythinker-core/pyproject.toml") + assert _pin("pythinker-host") == _version("packages/pythinker-host/pyproject.toml") + assert _pin("pythinker-review") == _version("packages/pythinker-review/pyproject.toml") + + + def test_review_is_frozen_at_0_1_0() -> None: + assert _pin("pythinker-review") == "0.1.0" + + + def test_readme_heading_and_pip_snippet() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert f"What's New in {VERSION}" in readme + assert f"pythinker-code=={VERSION}" in readme + + + def test_changelog_has_dated_heading_for_version() -> None: + changelog = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + assert f"## {VERSION} (" in changelog + + + def test_asset_names_match_version_across_files() -> None: + files = [ + REPO_ROOT / "README.md", + REPO_ROOT / "packages" / "linux-installer" / "README.md", + REPO_ROOT / "docs" / "en" / "guides" / "getting-started.md", + ] + # Each asset shape, where present, must carry VERSION (never a stale one). + shape_res = [ + re.compile(rf"PythinkerSetup-({SEMVER})\.exe"), + re.compile(rf"pythinker-code_({SEMVER})_[a-z0-9]+\.deb"), + re.compile(rf"pythinker-code-({SEMVER})\.[a-z0-9_]+\.rpm"), + re.compile(rf"releases/download/v({SEMVER})/"), + ] + for path in files: + text = path.read_text(encoding="utf-8") + for rx in shape_res: + for found in rx.findall(text): + assert found == VERSION, f"{path}: {found} != {VERSION}" + + + def test_no_hardcoded_version_badge_in_readme() -> None: + # Guard the contract's "badges" clause: the only version-bearing badge is the + # shields.io-live PyPI badge (img.shields.io/pypi/v/...). Fail if a future edit + # hardcodes VERSION into a shields.io badge label/path, which would silently drift. + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + for line in readme.splitlines(): + if "img.shields.io" in line and re.search(rf"badge/[^)]*{re.escape(VERSION)}", line): + raise AssertionError(f"hardcoded-version badge found: {line!r}") + + + def test_install_flag_examples_are_valid_semver_shape_only() -> None: + # The documented §3 exception: `--version ` teaches flag syntax and + # is NOT lockstepped to VERSION — only asserted to be valid semver shape. + flag_re = re.compile(rf"--version ({SEMVER})") + for rel in ("README.md", "docs/en/guides/getting-started.md"): + text = (REPO_ROOT / rel).read_text(encoding="utf-8") + for found in flag_re.findall(text): + assert re.fullmatch(SEMVER, found), found + ``` + + (No dead `v = re.escape(VERSION)` line — the asset test iterates `shape_res` over `SEMVER` and compares each capture to `VERSION` directly, so ruff F841 cannot fire.) +2. - [ ] Run against the real tree and see it pass. `uv run pytest tests/test_version_lockstep.py -q` → all pass (current repo: VERSION `0.27.0`, review pin `0.1.0`, asset names all `0.27.0`, flag examples `0.27.0` valid shape, no hardcoded-version badge). +3. - [ ] Lint the test (F841 guard). `uv run ruff check tests/test_version_lockstep.py` → exit 0 (proves no dead-assignment regression slipped in). +4. - [ ] Prove the lockstep actually bites (temporary mutation). Edit `README.md` heading to `What's New in 0.99.0`, run `uv run pytest tests/test_version_lockstep.py -q -k readme_heading` → it FAILS. Revert the edit (`git checkout README.md`), re-run → passes. This confirms the equality assertion is load-bearing. +5. - [ ] Confirm the gate set in `release.py` already invokes this test (Task 6 `GATES` includes `pytest tests/test_version_lockstep.py`). No change needed; just verify the path matches. +6. - [ ] Commit. `git add tests/test_version_lockstep.py && git commit -m "test(release): add version lockstep guard for every PR"` + +--- + +## Task 8 — `PYTHINKER_MANAGED` env hook in `update.py` + consumer handling + brew-unchanged regression test (TDD) + +**Files:** +- Modify: `src/pythinker_code/ui/shell/update.py` — `MANAGED_CHANNEL_MARKER` (after line 61), env read in `_detect_upgrade_command()` (line 95), branch in `_update_prompt_text()` (line 615), early-return in `do_update()` (after line 1215). +- Test: `tests/ui_and_conv/test_shell_update.py` (this file imports `from pythinker_code.ui.shell import update`; `tests/test_release_update_pipeline.py` is workflow-text only and does NOT import `update`). + +Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff (line 98). The env read is the literal first logic of `_detect_upgrade_command()` so non-brew managed channels (Docker/Nix/Scoop/WinGet) short-circuit. Crucially, the marker return must be *consumed*, not rendered raw: there are exactly two call sites of `_detect_upgrade_command()` — `_update_prompt_text()` (615) and `do_update()` (1215). `_update_prompt_text` needs its own branch (else it renders `__pythinker_managed_channel__ docker` as the "Update method"); `do_update` needs an early-return placed **after detection (1215) but before the `_update_candidate_unavailable_reason` readiness gate (1216)** so a managed install does not mis-fire the PyPI-still-publishing check and never reaches the exec path. The managed early-return mirrors the existing native-can't-auto-update path (lines 1246-1252): print a manual-action hint and return `UpdateResult.UPDATE_AVAILABLE`. + +1. - [ ] Write the failing tests. Append to `tests/ui_and_conv/test_shell_update.py`: + + ```python + def test_brew_unchanged_when_pythinker_managed_unset(monkeypatch): + monkeypatch.delenv("PYTHINKER_MANAGED", raising=False) + monkeypatch.setattr( + update.sys, "executable", + "/opt/homebrew/Cellar/pythinker-code/0.27.0/libexec/bin/python", + ) + monkeypatch.setattr(update, "_is_native_build", lambda: False) + assert update._detect_upgrade_command() == ["brew", "upgrade", "pythinker-code"] + + + def test_brew_unchanged_even_with_native_marker(monkeypatch): + # The .pythinker-native marker also trips _is_native_build(); the cellar + # path-sniff must win first so brew installs stay on `brew upgrade`. + monkeypatch.delenv("PYTHINKER_MANAGED", raising=False) + monkeypatch.setattr( + update.sys, "executable", + "/opt/homebrew/Cellar/pythinker-code/0.27.0/libexec/bin/python", + ) + monkeypatch.setattr(update, "_is_native_build", lambda: True) + assert update._detect_upgrade_command() == ["brew", "upgrade", "pythinker-code"] + + + def test_pythinker_managed_channel_short_circuits(monkeypatch): + monkeypatch.setenv("PYTHINKER_MANAGED", "docker") + monkeypatch.setattr(update.sys, "executable", "/usr/local/bin/python") + cmd = update._detect_upgrade_command() + assert cmd == [update.MANAGED_CHANNEL_MARKER, "docker"] + + + def test_update_prompt_text_renders_managed_channel_hint(monkeypatch): + # The contract requires a usable channel-native hint, not a raw marker. + monkeypatch.setenv("PYTHINKER_MANAGED", "docker") + monkeypatch.setattr(update.sys, "executable", "/usr/local/bin/python") + text = update._update_prompt_text("0.27.0", "0.28.0") + rendered = text.plain + assert "docker" in rendered + assert update.MANAGED_CHANNEL_MARKER not in rendered + ``` + +2. - [ ] Run and see it fail. `uv run pytest tests/ui_and_conv/test_shell_update.py -q -k "pythinker_managed or brew_unchanged or managed_channel_hint"` → `test_pythinker_managed_channel_short_circuits` fails (`AttributeError: ... MANAGED_CHANNEL_MARKER`); the others fail too because the constant does not exist at import time. +3. - [ ] Add the marker constant. In `src/pythinker_code/ui/shell/update.py`, after the existing `NATIVE_INSTALLER_MARKER = "__pythinker_native_installer__"` (line 61), add: + + ```python + MANAGED_CHANNEL_MARKER = "__pythinker_managed_channel__" + ``` + +4. - [ ] Add the env read at the top of `_detect_upgrade_command()`. Change lines 95-99 from: + + ```python + def _detect_upgrade_command() -> list[str]: + """Pick the right upgrade argv based on how this interpreter was installed.""" + exe = sys.executable.replace("\\", "/").lower() + if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: + return ["brew", "upgrade", "pythinker-code"] + ``` + + to: + + ```python + def _detect_upgrade_command() -> list[str]: + """Pick the right upgrade argv based on how this interpreter was installed.""" + # Channel-managed installs (Docker/Nix/Scoop/WinGet) export PYTHINKER_MANAGED + # so the updater emits a channel-native hint instead of shelling pip/uv. + # Brew deliberately does NOT set it — its cellar path-sniff below is the + # load-bearing, behavior-unchanged path. + managed = os.environ.get("PYTHINKER_MANAGED") + if managed: + return [MANAGED_CHANNEL_MARKER, managed] + exe = sys.executable.replace("\\", "/").lower() + if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: + return ["brew", "upgrade", "pythinker-code"] + ``` + + (`os` is already imported at line 5; no new import.) +5. - [ ] Add the managed branch to `_update_prompt_text()`. Change lines 615-619 from: + + ```python + upgrade_command = _detect_upgrade_command() + if upgrade_command == [NATIVE_INSTALLER_MARKER]: + update_method = "downloads the native updater automatically" + else: + update_method = _format_upgrade_command(upgrade_command) + ``` + + to: + + ```python + upgrade_command = _detect_upgrade_command() + if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: + update_method = f"managed by {upgrade_command[1]} — update via your {upgrade_command[1]} channel" + elif upgrade_command == [NATIVE_INSTALLER_MARKER]: + update_method = "downloads the native updater automatically" + else: + update_method = _format_upgrade_command(upgrade_command) + ``` + +6. - [ ] Add the managed early-return to `do_update()`. Change lines 1215-1218 from: + + ```python + upgrade_command = _detect_upgrade_command() + unavailable_reason = await _update_candidate_unavailable_reason( + session, latest_version, upgrade_command + ) + ``` + + to (insert the early-return BEFORE the readiness gate, so a managed channel never mis-fires the PyPI check or the exec path): + + ```python + upgrade_command = _detect_upgrade_command() + if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: + channel = upgrade_command[1] + _print( + f"[{_t.warning}]Pythinker is managed by your {channel} channel. " + f"Update {current_version} → {latest_version} via {channel} " + "(rebuild/repull the image or run the channel's upgrade command).[/]" + ) + return UpdateResult.UPDATE_AVAILABLE + unavailable_reason = await _update_candidate_unavailable_reason( + session, latest_version, upgrade_command + ) + ``` + +7. - [ ] Run and see it pass. `uv run pytest tests/ui_and_conv/test_shell_update.py -q -k "pythinker_managed or brew_unchanged or managed_channel_hint"` → `4 passed`. +8. - [ ] Confirm no regression in the existing updater tests + types. `uv run pytest tests/ui_and_conv/test_shell_update.py -q` → all pass; `uv run pyright src/pythinker_code/ui/shell/update.py` → 0 errors (the file is in the `strict` set at `pyproject.toml:138`). +9. - [ ] Commit. `git add src/pythinker_code/ui/shell/update.py tests/ui_and_conv/test_shell_update.py && git commit -m "feat(update): add PYTHINKER_MANAGED channel hint with consumer handling; keep brew unchanged"` + +--- + +## Task 9 — Assert the `release/*` + `chore(release)` skip-contract (P1-scope guard, TDD) + +**Files:** +- Modify: `tests/test_release_update_pipeline.py` (append — this is the existing workflow-text assertion home) + +`release.py.open_pr()` emits a branch named `release/X.Y.Z` and a PR title `chore(release): prepare X.Y.Z`. The `changelog-entry-required.yml` workflow must skip its "require a CHANGELOG entry" check for exactly that shape (title `chore(release)*` at line 54, head branch `release/*` at line 57), because a release-prep PR consumes `## Unreleased` into a dated block and resets it, which would otherwise read as a net removal and fail. This coupling is real and load-bearing, so it is asserted here (kept out of the version-string-focused lockstep test, per the punch-list). + +1. - [ ] Write the test. Append to `tests/test_release_update_pipeline.py`: + + ```python + def test_changelog_workflow_skips_release_prep_prs() -> None: + """release.py opens `release/X.Y.Z` PRs titled `chore(release): prepare X.Y.Z`. + + changelog-entry-required.yml MUST skip its required check for that shape, + or every release PR is blocked under branch protection. Assert both the + title guard and the head-branch guard so neither half silently regresses. + """ + wf = (WORKFLOWS / "changelog-entry-required.yml").read_text() + # Title guard: chore(release)* → skip. + assert '"chore(release)"*)' in wf, "missing chore(release) title skip" + # Head-branch guard: release/* → skip. + assert "release/*)" in wf, "missing release/* branch skip" + ``` + +2. - [ ] Run against the real workflow and see it pass. `uv run pytest tests/test_release_update_pipeline.py -q -k changelog_workflow_skips_release_prep` → `1 passed` (the guards exist today at `changelog-entry-required.yml:54` and `:57`). +3. - [ ] Prove the guard bites (temporary mutation). Comment out the `release/*)` case line in `.github/workflows/changelog-entry-required.yml`, re-run the test → it FAILS. Restore the line (`git checkout .github/workflows/changelog-entry-required.yml`), re-run → passes. +4. - [ ] Commit. `git add tests/test_release_update_pipeline.py && git commit -m "test(release): assert changelog workflow skips release-prep PRs"` + +--- + +## Task 10 — Repoint the release SKILL at `scripts/release.py` + +**Files:** +- Modify: `.agents/skills/release/SKILL.md` (the `update_files` node at lines 22-25 and the `uv_sync` node at line 35) + +1. - [ ] Read the current nodes. Use the Read tool on `.agents/skills/release/SKILL.md` lines 1-55 (covers the `---` front matter, the `update_files` node body at 22-25, and the `uv_sync: "Run uv sync."` line at 35 — both edited nodes are in view; prefer Read over sed per repo CLAUDE.md). +2. - [ ] Replace the manual-bump prose in `update_files`. Change the node body (lines 22-25) from: + + ``` + update_files: |md + Update the relevant pyproject.toml (and rust/Cargo.toml if root version changes), + CHANGELOG.md (keep the Unreleased header), and breaking-changes.md in both languages. + | + ``` + + to: + + ``` + update_files: |md + Run `uv run python scripts/release.py --set-version X.Y.Z [--bump-core A.B.C --bump-host A.B.C]`. + It rewrites pyproject.toml:3, the sub-package pins, uv.lock, all three changelog files + (preserving the authored Unreleased body), and the README/asset names from the single + source of truth, then runs the local gates and opens the `release/X.Y.Z` PR. + There is no `--bump-review` (review is frozen at 0.1.0). + | + ``` + +3. - [ ] Update the `uv_sync` node. Change line 35 from: + + ``` + uv_sync: "Run uv sync." + ``` + + to: + + ``` + uv_sync: "release.py already runs `uv lock` + `uv sync --frozen --all-extras --all-packages` as Phase-2/3 steps; no separate uv sync needed." + ``` + +4. - [ ] Verify the front matter + d2 graph still parse (no structural breakage): Read lines 1-6 → unchanged `---`/`name:`/`description:`/`type:` front matter, and confirm the edited node lines are still inside the ```` ```d2 ```` fenced block. +5. - [ ] Commit. `git add .agents/skills/release/SKILL.md && git commit -m "docs(release): repoint release skill at scripts/release.py"` + +--- + +## Task 11 — Open the P1 PR (C1) and merge gate (C2) + +**Files:** none (process) + +Because every task committed to the single `p1/release-tool` branch (Task 1 onward), the required dep-check arg and both workflow-caller edits are atomic in one PR — there is no cherry-pick or stacked-PR reconciliation to do. + +1. - [ ] Confirm the full local gate set is green before pushing. `uv run pytest tests/test_release_py.py tests/test_version_lockstep.py tests/ui_and_conv/test_shell_update.py tests/test_release_update_pipeline.py -q` → all pass; `uv run ruff check scripts/release.py tests/test_release_py.py tests/test_version_lockstep.py && uv run ruff format --check scripts/release.py tests/test_release_py.py tests/test_version_lockstep.py` → exit 0; `uv run pyright src/pythinker_code/ui/shell/update.py` → 0 errors. +2. - [ ] Confirm the workspace version checks pass exactly as CI will run them: `uv run python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. +3. - [ ] Confirm the branch history is one coherent stack. `git log --oneline -8 p1/release-tool` shows the dep-check, release.py (validation/rewrites/promotion/asset/orchestration), lockstep, skip-contract, updater, and SKILL commits all on `p1/release-tool`. Push: `git push -u origin p1/release-tool`. +4. - [ ] Open the PR. `gh pr create --base main --head p1/release-tool --title "feat(release): release.py + version lockstep SSOT (P1)" --body "Adds scripts/release.py (4-phase SSOT release orchestrator), tests/test_version_lockstep.py (every-PR version guard), the pythinker-review dependency-check tuple (with both CI callers updated atomically), the changelog-workflow skip-contract assertion, and the PYTHINKER_MANAGED updater hook with a brew-unchanged regression test and a managed-channel rendered hint. No new agent runtime deps (C3)."` +5. - [ ] Wait for CI and CodeRabbit. Confirm required checks (`check`, `test`, `changelog`, `release-validate` as applicable) pass and the `CodeRabbit` commit status on the PR head SHA is `success` (C2) before merging. Read CodeRabbit's "Actionable comments" and resolve or surface them — do not merge past unresolved findings. Per the project CLAUDE.md / MEMORY note, reject a CodeRabbit camelCase-for-Python finding if one appears (false positive; codebase is snake_case). +6. - [ ] Merge after C2 is satisfied. `gh pr merge p1/release-tool --squash` (the local CodeRabbit merge-gate hook enforces the status check). + +--- + +## Phase verification + +**What "done" looks like:** `pyproject.toml:3` is the only place a human edits the version; everything else is derived by `scripts/release.py` or guarded by `tests/test_version_lockstep.py`; the `pythinker-review==0.1.0` freeze is enforced in the dependency check (both CI callers updated atomically) and the lockstep test; `_detect_upgrade_command()` honors `PYTHINKER_MANAGED` with a real channel-native hint in BOTH consumer paths while brew behavior is provably unchanged; and the `release/*` + `chore(release)` skip-contract is asserted so release PRs are never blocked by `changelog-entry-required.yml`. + +**End-to-end rehearsal (the proof, no tag pushed):** + +1. - [ ] On a clean tree synced to `origin/main`, run a real (non-dry-run) rehearsal to a throwaway version: `uv run python scripts/release.py --set-version 0.28.0`. Expected: Phase 1 validates all three changelog anchors; Phase 2 rewrites the files + runs `uv lock`; Phase 3 runs all four gates (`check_version_tag`, the extended `check_pythinker_dependency_versions`, `uv sync --frozen --all-extras --all-packages`, `pytest tests/test_version_lockstep.py`) plus the `grep -qF` checks — all green; Phase 4 creates branch `release/0.28.0`, commits `chore(release): prepare 0.28.0`, pushes, and opens a PR, then prints `git tag v0.28.0 && git push origin v0.28.0`. +2. - [ ] Prove the stress-test catch is covered: confirm `uv.lock` changed in the rehearsal commit (`git show --stat release/0.28.0 | grep uv.lock`) and that `uv sync --frozen --all-extras --all-packages` ran clean inside Phase 3 (no "lockfile out of date" error). This is the exact failure that would otherwise turn the release PR's own CI red. +3. - [ ] Confirm the documented exception held: `grep -n "version 0.28.0" docs/en/guides/getting-started.md` returns nothing — the `--version 0.27.0` flag example is unchanged (still `--version 0.27.0`), while `What's New in 0.28.0`, `pythinker-code==0.28.0`, and `PythinkerSetup-0.28.0.exe` are all present in their respective files. +4. - [ ] Confirm the skip-contract makes the rehearsal PR pass the changelog gate: the PR head branch is `release/0.28.0` and the title is `chore(release): prepare 0.28.0`, so `changelog-entry-required.yml` skips (matching the guards Task 9 asserts) and does not block on the now-empty `## Unreleased`. +5. - [ ] Tear down the rehearsal (no tag was pushed): `gh pr close release/0.28.0 --delete-branch` and `git switch main && git branch -D release/0.28.0` and `git push origin --delete release/0.28.0`. Verify `git log --oneline -1 origin/main` is untouched (C1: nothing reached main, no tag was created). +6. - [ ] Sub-package rehearsal (optional, validates `--bump-core`): `uv run python scripts/release.py --set-version 0.28.0 --bump-core 1.2.0 --dry-run` → prints the ordered tag sequence (`pythinker-core-1.2.0` first, wait-for-PyPI note, then `v0.28.0`) and the intended pin rewrite `pythinker-core[contrib]==1.2.0`, writing nothing. + +**Files relevant to this phase (absolute paths):** +- `/home/ai/Projects/pythinker-code-main/scripts/release.py` +- `/home/ai/Projects/pythinker-code-main/scripts/check_pythinker_dependency_versions.py` +- `/home/ai/Projects/pythinker-code-main/tests/test_version_lockstep.py` +- `/home/ai/Projects/pythinker-code-main/tests/test_release_py.py` +- `/home/ai/Projects/pythinker-code-main/tests/ui_and_conv/test_shell_update.py` +- `/home/ai/Projects/pythinker-code-main/tests/test_release_update_pipeline.py` +- `/home/ai/Projects/pythinker-code-main/src/pythinker_code/ui/shell/update.py` +- `/home/ai/Projects/pythinker-code-main/.github/workflows/ci-pythinker-cli.yml` +- `/home/ai/Projects/pythinker-code-main/.github/workflows/release-pythinker-cli.yml` +- `/home/ai/Projects/pythinker-code-main/.github/workflows/changelog-entry-required.yml` +- `/home/ai/Projects/pythinker-code-main/docs/en/release-notes/breaking-changes.md` +- `/home/ai/Projects/pythinker-code-main/.agents/skills/release/SKILL.md` + +--- + +## Punch-list resolution notes (how each review item was addressed) + +- **specCoverageGaps #1 (PYTHINKER_MANAGED hint not consumed):** Fixed in Task 8 — added consumer handling in the exactly-two call sites (`_update_prompt_text` branch + `do_update` early-return placed before the readiness gate at line 1216), returning `UPDATE_AVAILABLE` like the native-can't-auto-update path, plus a `text.plain` rendered-hint test. The marker is never rendered raw or exec'd. +- **specCoverageGaps #2 (release/* + chore(release) skip-contract):** Added Task 9 — a real failing-first workflow-text test in `tests/test_release_update_pipeline.py` asserting both guards (`chore(release)*` title line 54, `release/*` branch line 57), with a bite-proof mutation step. Kept out of the version-focused lockstep test. +- **placeholders #1 (`v = re.escape(VERSION)` dead in lockstep):** Removed — Task 7's asset test iterates `shape_res` over `SEMVER` and compares captures to `VERSION` directly; added a ruff-check step (Task 7 step 3) to prove no F841. +- **placeholders #2 (`text = ROOT_PYPROJECT` dead in validate):** Removed from the step-1 code block — `validate()` now does the `CHANGELOG_FILES` anchor loop directly; no committed-then-deleted dead line. +- **consistencyIssues #1 (incoherent branch strategy):** Fixed — single `p1/release-tool` branch created in Task 1; the cherry-pick/stacked-PR fork is gone (Task 11). The required-arg + both workflow callers are atomic in one PR. +- **consistencyIssues #2 (SKILL recon sed range):** Fixed — Task 10 uses Read over lines 1-55 (covers `update_files` at 22-25 and `uv_sync` at 35; the real `uv_sync` line is 35, not 46). +- **consistencyIssues #3 (badges no-op):** Documented in Task 5 (PyPI badge is shields.io-live, Python badge is a `3.12%2B` floor) + added an optional lockstep guard `test_no_hardcoded_version_badge_in_readme` (Task 7) to prevent future drift. +- **constraintIssues #1 (validate only checks CHANGELOG anchor):** Fixed — introduced the module-level `CHANGELOG_FILES` constant (Task 2); `validate()` loops it to assert the `## Unreleased` anchor in all three files before any write, and `rewrite()` promotes the same list, so they cannot drift and Phase 2 stays atomic. +- **Bonus (test file mismatch):** `tests/test_release_update_pipeline.py` is workflow-text and does not import `update`; the updater unit tests were moved to `tests/ui_and_conv/test_shell_update.py` (which does), and Task 11 step 1's pytest command lists both files correctly. diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md new file mode 100644 index 00000000..b0c6ab62 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md @@ -0,0 +1,1382 @@ +# P2 — Broadened Distribution (Docker/GHCR, Scoop, Nix, WinGet) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Ship four best-effort distribution channels (Docker/GHCR, Scoop, Nix `apps.default`, manual WinGet) that all set `PYTHINKER_MANAGED` for channel-native upgrades, never gate `promote-release`, and carry version-less README snippets so they never enter the version-sprawl set. + +**Architecture:** All four channels are additive workflows in `pythinker-code` (the source of truth). Docker builds a thin `python:3.14-slim` image that `pip install`s the already-published wheel (zero new runtime deps, C3), built multi-arch native (amd64 + ubuntu-24.04-arm), pushed by digest, stitched with `buildx imagetools`, `:latest` advanced only for a promoted (non-prerelease) release with an ancestor-check. Scoop mirrors the trusted Homebrew tap pattern exactly: a generator (`packages/scoop-bucket/generate-manifest.py`) polls the EXISTING Windows onedir zip from the release, and `scoop-bucket.yml` (in pythinker-code) mints the `pythinker-scoop-publisher` App token and git-pushes the manifest to the org repo `TechMatrix-labs/scoop-pythinker`. Nix gains an `apps.default` plus a `PYTHINKER_MANAGED=nix` wrapper env and a monthly `update-flake-lock` PR. WinGet is a manual `workflow_dispatch` using an isolated fine-grained PAT. + +**Tech Stack:** GitHub Actions, `docker/build-push-action` + `buildx imagetools` (GHCR), `actions/create-github-app-token` (Scoop App), stdlib Python generator (`urllib`, mirrors `generate-formula.py`), `uv run pytest` (generator test), Nix flakes (`uv2nix`), `wingetcreate`. + +--- + +## Cross-phase dependency (READ FIRST) + +This phase **depends on P1** for the `PYTHINKER_MANAGED=` env read at the top of `_detect_upgrade_command()` in `src/pythinker_code/ui/shell/update.py`, and on the **P0 App-token pattern** (`actions/create-github-app-token`, copied from `.github/workflows/homebrew-tap.yml:79-86`). + +- **Do not implement or unit-test the `PYTHINKER_MANAGED` env read here** — that code + its pytest belong to P1. P2 only *sets* the variable per channel and verifies it is set via integration checks (Docker `env`, Nix `result/bin` wrapper grep, manifest env block). If P1 has not merged when you start, surface it and either (a) wait, or (b) land P2 channels and open a follow-up that adds the `PYTHINKER_MANAGED=...` settings once P1 merges. The channels work without it (they just show the generic upgrade hint); the env read is what makes the hint channel-native. +- Only **one** task in this phase has a real failing-test-first pytest: Task 2.2 (`generate-manifest.py`). Every other task is CI-wiring (workflow YAML, App tokens, cross-repo push, Nix build) and is verified by `actionlint` + a documented dry-run on a throwaway tag, honestly marked CI-vs-local. + +**Hard sequencing within P2 (from spec §6 recommended order):** Docker → Scoop → Nix → WinGet. WinGet is last and gated to manual dispatch only. + +**Deviation from the literal contract phrasing (stated up front — DISCLOSED + JUSTIFIED; confirm with the contract owner):** the contract says scoop-pythinker has "its own `.github/workflows/scoop-bucket.yml`". Implemented as the Homebrew mirror instead: **`scoop-bucket.yml` lives in `pythinker-code`** and git-pushes the manifest into `scoop-pythinker`. Rationale: if the workflow ran *inside* scoop-pythinker committing to itself, the `pythinker-scoop-publisher` App would never be exercised (a same-repo `GITHUB_TOKEN` would suffice), contradicting the App's reason to exist. Running it in pythinker-code and pushing cross-repo is the only design where the App token is actually used — exactly as `homebrew-tap.yml` uses `pythinker-tap-publisher` (verified against `homebrew-tap.yml:79-145`). This is the sole disclosure of the deviation; it is not a blocker, but the contract owner should ratify it. + +--- + +## Prerequisites (manual / operator) + +These touch org admin, secrets, and an external repo — they are **operator actions**, not code steps. Do them before the Scoop/WinGet tasks. Verify each with the `gh` command shown. + +### OP-1 — Create the public org repo `TechMatrix-labs/scoop-pythinker` +```bash +gh repo create TechMatrix-labs/scoop-pythinker \ + --public \ + --description "Scoop bucket for Pythinker Code. Auto-updated by pythinker-code/.github/workflows/scoop-bucket.yml on every semver release tag. Do not hand-edit bucket/*." \ + --disable-wiki +# Verify: +gh repo view TechMatrix-labs/scoop-pythinker --json visibility,name -q '.name + " " + .visibility' +# Expected: scoop-pythinker public +``` +Leave it empty — `scoop-bucket.yml`'s first run initializes `main` exactly like `homebrew-tap.yml:98-145` does for the empty tap. + +### OP-2 — Create + install the `pythinker-scoop-publisher` GitHub App +In the org **Settings → Developer settings → GitHub Apps → New GitHub App** (UI; cannot be scripted): +- **Name:** `pythinker-scoop-publisher` +- **Homepage URL:** `https://github.com/TechMatrix-labs/scoop-pythinker` +- **Webhook:** uncheck Active. +- **Repository permissions:** `Contents: Read and write`, `Metadata: Read-only` (mandatory). Nothing else. +- Create, then **Generate a private key** (downloads a `.pem`). Note the **App ID**. +- **Install App** → choose **Only select repositories** → select **only** `TechMatrix-labs/scoop-pythinker`. + +Verify the installation is scoped to exactly one repo: +```bash +gh api /orgs/TechMatrix-labs/installations --jq '.installations[] | select(.app_slug=="pythinker-scoop-publisher") | {app_id, repository_selection}' +# Expected: repository_selection "selected" +``` + +### OP-3 — Add the App credentials as ORG secrets (visible to pythinker-code) +```bash +# App ID (numeric, from OP-2): +gh secret set SCOOP_BUCKET_APP_ID --org TechMatrix-labs --visibility selected --repos pythinker-code --body "" +# Private key (the .pem downloaded in OP-2): +gh secret set SCOOP_BUCKET_APP_PRIVATE_KEY --org TechMatrix-labs --visibility selected --repos pythinker-code < /path/to/pythinker-scoop-publisher.private-key.pem +# Verify both exist: +gh secret list --org TechMatrix-labs | grep SCOOP_BUCKET_APP +# Expected: SCOOP_BUCKET_APP_ID and SCOOP_BUCKET_APP_PRIVATE_KEY listed +``` + +### OP-4 — (WinGet, Task 4.1 only) Create the isolated fine-grained PAT `WINGET_SUBMIT_TOKEN` +A GitHub App **cannot** open PRs against the external `microsoft/winget-pkgs`, so WinGet needs a classic/fine-grained PAT on a fork. Create a fine-grained PAT (UI: **Settings → Developer settings → Fine-grained tokens**) scoped to your `microsoft/winget-pkgs` fork with `Contents: Read and write` + `Pull requests: Read and write`, short expiry. Then: +```bash +gh secret set WINGET_SUBMIT_TOKEN --repo TechMatrix-labs/pythinker-code --body "" +gh secret list --repo TechMatrix-labs/pythinker-code | grep WINGET_SUBMIT_TOKEN +# Expected: WINGET_SUBMIT_TOKEN listed +``` + +### OP-5 — Confirm GHCR is enabled for the org +GHCR (`ghcr.io`) needs no secret (uses `GITHUB_TOKEN` + `packages: write`), but the org must allow Actions to create packages. Verify after the first Docker run that the package exists: +```bash +gh api /orgs/TechMatrix-labs/packages?package_type=container --jq '.[].name' +# After first successful docker.yml run, expect: pythinker-code +``` + +--- + +## File Structure + +| Action | Path | Responsibility | +|---|---|---| +| Create | `Dockerfile` | Thin `python:3.14-slim` image; `pip install pythinker-code==${V}` from PyPI; sets `PYTHINKER_MANAGED=docker`; entrypoint `pythinker`. | +| Create | `.dockerignore` | Keep the build context tiny (the wheel comes from PyPI, not the repo). | +| Create | `.github/workflows/docker.yml` | On semver release tags (`v+([0-9]).+([0-9]).+([0-9])`): wait-for-PyPI, build amd64 + arm64 by digest, stitch manifest to `ghcr.io/techmatrix-labs/pythinker-code:`, advance `:latest` only when the release is promoted (non-prerelease) + ancestor-check. Best-effort. | +| Create | `packages/scoop-bucket/generate-manifest.py` | Stdlib generator: poll the release, read the EXISTING Windows onedir zip + `.sha256`, render `bucket/pythinker-code.json`. Mirrors `packages/homebrew-tap/generate-formula.py`. | +| Create | `packages/scoop-bucket/pythinker-code.json.tmpl` | Scoop manifest template with `__VERSION__`/`__URL__`/`__SHA256__` placeholders; `bin: pythinker\pythinker.exe`; `env_set: PYTHINKER_MANAGED=scoop`; version-less `autoupdate`. | +| Create | `.github/workflows/scoop-bucket.yml` | On semver release tags (`v+([0-9]).+([0-9]).+([0-9])`) + `workflow_dispatch`: generate the manifest, mint `pythinker-scoop-publisher` token, git-push `bucket/pythinker-code.json` into `scoop-pythinker`. Mirrors `homebrew-tap.yml`. | +| Create | `tests/test_scoop_manifest.py` | Failing-test-first pytest for `generate-manifest.py` (mirrors `tests/test_homebrew_formula.py`). | +| Modify | `flake.nix` | Add `apps.default` (`type=app`, `program=.../bin/pythinker`); add `--set PYTHINKER_MANAGED "nix"` to the `makeWrapper` installPhase. | +| Modify | `.github/workflows/ci-pythinker-cli.yml` | Extend the existing `nix-test` job to also run `nix run .#default -- --version` (the `apps.default` smoke check). | +| Create | `.github/workflows/update-flake-lock.yml` | Monthly cron: `nix flake update` → open a PR. | +| Create | `.github/workflows/winget.yml` | Manual `workflow_dispatch(version)` only: `wingetcreate update --submit` via the isolated PAT. | +| Modify | `README.md` | Add version-less Docker / Scoop / Nix install snippets (C4). | + +--- + +## TASK 1 — Docker / GHCR + +### Task 1.1 — Thin Dockerfile + .dockerignore + +**Files:** +- Create: `Dockerfile` +- Create: `.dockerignore` +- Verify: local `docker build` (buildah-backed) — CI does the multi-arch push. + +**Steps:** + +- [ ] 1. Create `Dockerfile`. The image installs the already-published wheel (zero new runtime deps, C3-safe) and sets `PYTHINKER_MANAGED=docker` so the in-app updater shows a docker-native hint. `PYTHINKER_VERSION` is a build arg supplied by `docker.yml`. + +```dockerfile +# syntax=docker/dockerfile:1 +# Thin Pythinker Code image: installs the published wheel from PyPI so the +# container ships the exact same artifact users get from `pip install`. No +# source build, no new runtime deps (C3). The version is pinned at build time +# by docker.yml AFTER the wheel is confirmed live on PyPI. +FROM python:3.14-slim + +# Build-time pin. docker.yml passes --build-arg PYTHINKER_VERSION=. +ARG PYTHINKER_VERSION +RUN test -n "$PYTHINKER_VERSION" || (echo "PYTHINKER_VERSION build-arg is required" >&2; exit 1) + +# ripgrep is the one external binary the agent shells out to; install it so the +# container is self-contained (matches the Nix wrapper's --prefix PATH ripgrep). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ripgrep git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir "pythinker-code==${PYTHINKER_VERSION}" + +# Channel marker: the in-app updater (P1) reads this and prints a docker-native +# upgrade hint instead of trying to pip-upgrade inside an immutable image. +ENV PYTHINKER_MANAGED=docker + +ENTRYPOINT ["pythinker"] +CMD ["--help"] +``` + +- [ ] 2. Create `.dockerignore` so the build context stays tiny (the wheel comes from PyPI; nothing from the repo is copied in). + +``` +* +!Dockerfile +``` + +- [ ] 3. Local sanity build (buildah is the local docker shim; this proves the Dockerfile parses and the wheel installs). Use a real published version to avoid a 404: + +```bash +docker build --build-arg PYTHINKER_VERSION=0.27.0 -t pythinker-docker-test:local /home/ai/Projects/pythinker-code-main +``` +Expected tail: `Successfully tagged ... pythinker-docker-test:local` (buildah: `COMMIT`). If buildah rejects `--build-arg`, run with `dangerouslyDisableSandbox` is NOT needed — instead skip local and rely on the CI dry-run in Task 1.3; note that in the commit message. + +- [ ] 4. Verify the channel marker is baked in: +```bash +docker run --rm pythinker-docker-test:local env | grep PYTHINKER_MANAGED +``` +Expected: `PYTHINKER_MANAGED=docker` + +- [ ] 5. Commit on a feature branch (C1 — no direct main): +```bash +git switch -c feat/p2-docker-ghcr +git add Dockerfile .dockerignore +git commit -m "feat(docker): thin python:3.14-slim image installing the published wheel" +``` + +### Task 1.2 — docker.yml multi-arch GHCR workflow + +**Files:** +- Create: `.github/workflows/docker.yml` +- Verify: `actionlint` (CI-wiring, no local pytest possible) + a throwaway-tag dry-run (Task 1.3). + +**Steps:** + +- [ ] 1. Create `.github/workflows/docker.yml`. Topology mirrors h-agent's `docker-publish.yml` (build-amd64 / build-arm64 / merge / move-latest), with three pythinker-specific changes the advisor flagged: (a) GHCR login with `GITHUB_TOKEN` (not Docker Hub); (b) a **wait-for-PyPI** pre-check before buildx (the wheel publishes in a parallel job and may 404); (c) `:latest` advances **only when the release is non-prerelease** (promote has flipped it) AND the ancestor-check passes. Lowercase image name is mandatory for GHCR. + +> **Runbook note — `:latest` does NOT auto-advance after promotion.** `docker.yml` triggers only on semver release tags (`v+([0-9]).+([0-9]).+([0-9])`) and `workflow_dispatch` — there is **no `release:` trigger**. At tag-push time the GitHub Release is still a prerelease (created prerelease by `release-pythinker-cli.yml`), so the `move-latest` gate evaluates `isPrerelease == true` and **skips** — `:latest` is intentionally NOT moved. `promote-release.yml` later flips the release to non-prerelease via a release *edit*, which does **not** re-fire `docker.yml`. Therefore a maintainer MUST manually re-dispatch after promotion to advance `:latest`: +> ```bash +> gh workflow run docker.yml -f version=X.Y.Z # run AFTER promote flips vX.Y.Z to non-prerelease +> ``` +> On that post-promotion dispatch the gate sees `isPrerelease == false`, the ancestor-check passes, and `:latest` advances to `X.Y.Z`. This manual step is the accepted design (it keeps `:latest` from ever leading `/releases/latest`); it is repeated in Phase-verification step 4. If hands-off advancement is ever wanted, add `release: {types: [released]}` to `docker.yml` and re-verify the gate — explicitly out of scope here. + +```yaml +name: Docker (GHCR) + +on: + push: + tags: + - "v+([0-9]).+([0-9]).+([0-9])" + workflow_dispatch: + inputs: + version: + description: "Version to (re)build (e.g. 0.27.0)" + required: true + type: string + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/techmatrix-labs/pythinker-code + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +# One run per tag; never cancel a tag/dispatch run (each must publish its digest). +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: false + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - name: Resolve version + id: ver + env: + GITHUB_REF: ${{ github.ref }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ -n "${INPUT_VERSION:-}" ]]; then + version="$INPUT_VERSION" + else + echo "::error::No version source available" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + # The wheel publishes in release-pythinker-cli.yml's parallel publish-python + # job; a tag-triggered docker build can outrun it and 404. Mirror + # homebrew-tap.yml's bounded poll (lines 58-69): wait up to 30m for the + # PyPI version JSON to return 200 before any arch builds. + - name: Wait for the wheel on PyPI + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + deadline=$(( $(date +%s) + 30 * 60 )) + url="https://pypi.org/pypi/pythinker-code/${PKG_VERSION}/json" + until [ "$(curl -s -o /dev/null -w '%{http_code}' "$url")" = "200" ]; do + if [ "$(date +%s)" -gt "$deadline" ]; then + echo "::error::pythinker-code==${PKG_VERSION} not on PyPI within 30 minutes" >&2 + exit 1 + fi + echo "pythinker-code==${PKG_VERSION} not on PyPI yet; sleeping 30s" + sleep 30 + done + echo "pythinker-code==${PKG_VERSION} is live on PyPI" + + build-amd64: + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push amd64 by digest + id: push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + build-args: | + PYTHINKER_VERSION=${{ needs.resolve.outputs.version }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.resolve.outputs.version }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: digest-amd64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + build-arm64: + needs: resolve + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push arm64 by digest + id: push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/arm64 + build-args: | + PYTHINKER_VERSION=${{ needs.resolve.outputs.version }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.resolve.outputs.version }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: digest-arm64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: [resolve, build-amd64, build-arm64] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Stitch both per-arch digests into the version-tagged manifest list. + - name: Create version manifest and push + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + TAG: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + args=() + for digest_file in *; do + args+=("${IMAGE_NAME}@sha256:${digest_file}") + done + docker buildx imagetools create -t "${IMAGE_NAME}:${TAG}" "${args[@]}" + docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" + + move-latest: + needs: [resolve, merge] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + concurrency: + group: docker-move-latest + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Gate :latest on the GH release being PROMOTED (non-prerelease). At + # tag-push time the release is still prerelease until promote-release.yml + # flips it; advancing :latest to an unpromoted release would publish a + # "latest" ahead of /releases/latest. Best-effort: if the release isn't + # promoted yet, skip cleanly (the version tag is already pushed; a later + # docker workflow_dispatch re-run after promotion advances :latest). + - name: Decide whether to move :latest + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + is_pre=$(gh release view "v${VERSION}" \ + --repo "${GITHUB_REPOSITORY}" --json isPrerelease -q '.isPrerelease' 2>/dev/null || echo "true") + if [ "$is_pre" != "false" ]; then + echo "Release v${VERSION} is still prerelease (or missing); not advancing :latest." + echo "move=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Ancestor-check (defense-in-depth, mirrors h-agent move-latest): + # only advance if our commit descends from the current :latest. + image_json=$(docker buildx imagetools inspect "${IMAGE_NAME}:latest" \ + --format '{{ json (index .Image "linux/amd64") }}' 2>/dev/null || true) + if [ -z "${image_json}" ]; then + echo "move=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + current_sha=$(printf '%s' "${image_json}" | jq -r '.config.Labels."org.opencontainers.image.revision" // ""') + if [ -z "${current_sha}" ] || [ "${current_sha}" = "${GITHUB_SHA}" ]; then + echo "move=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --prune origin "+refs/heads/main:refs/remotes/origin/main" || true + fi + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + echo "Registry :latest points at an unknown commit; refusing to overwrite." + echo "move=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then + echo "move=true" >> "$GITHUB_OUTPUT" + else + echo "Existing :latest is newer (likely a backport); leaving it alone." + echo "move=false" >> "$GITHUB_OUTPUT" + fi + - name: Move :latest + if: steps.gate.outputs.move == 'true' + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + docker buildx imagetools create --tag "${IMAGE_NAME}:latest" "${IMAGE_NAME}:${VERSION}" + docker buildx imagetools inspect "${IMAGE_NAME}:latest" +``` + +- [ ] 2. Lint the workflow (actionlint is NOT on this machine's PATH — run it pinned via Docker so the check is real, not claimed): +```bash +docker run --rm -v /home/ai/Projects/pythinker-code-main:/repo -w /repo rhysd/actionlint:latest -color .github/workflows/docker.yml +``` +Expected: no output, exit 0. (If the buildah shim cannot run this image, fall back to `gh workflow view` after push and rely on the Task 1.3 dry-run; say which you used.) + +- [ ] 3. Commit: +```bash +git add .github/workflows/docker.yml +git commit -m "feat(docker): multi-arch GHCR workflow with PyPI-wait and promoted-only :latest" +``` + +### Task 1.3 — Docker PR + CI dry-run verification + +**Files:** none (verification + merge). + +**Steps:** + +- [ ] 1. Push the branch and open the PR (C1): +```bash +git push -u origin feat/p2-docker-ghcr +gh pr create --base main --title "feat(docker): GHCR distribution channel" \ + --body "P2 Docker/GHCR channel. Thin python:3.14-slim image installing the published wheel; multi-arch (amd64 + ubuntu-24.04-arm); push-by-digest + imagetools manifest; :latest only for promoted releases. Best-effort, never gates promote. Sets PYTHINKER_MANAGED=docker (consumed by the P1 updater hook)." +``` + +- [ ] 2. CI dry-run (this is the **only** way to verify the multi-arch push end-to-end — there is no local pytest for this). Run the workflow manually against an already-published, already-promoted version: +```bash +gh workflow run docker.yml --ref feat/p2-docker-ghcr -f version=0.27.0 +gh run watch "$(gh run list --workflow=docker.yml --limit 1 --json databaseId -q '.[0].databaseId')" +``` +Expected observable result: `resolve` passes the PyPI-wait (0.27.0 is already live), `build-amd64` + `build-arm64` push digests, `merge` creates `ghcr.io/techmatrix-labs/pythinker-code:0.27.0`. **`move-latest` reports `move=true` and DOES advance `:latest` to `0.27.0`.** The gate keys on the GitHub Release's `isPrerelease` field, **not** on the branch the dispatch ran from: `v0.27.0` is an already-promoted (non-prerelease) release, so `is_pre=false` and (on a first run with no existing `:latest`) `move=true`. This is benign — `0.27.0` is the current released version, so `:latest` is simply re-pointed at the artifact it already represents; the wheel installed is the published PyPI artifact regardless of which branch built the image. The `move=false` skip path is exercised only against a still-prerelease tag (see Phase-verification step 5), which is the real tag-push behavior. Confirm the image: +```bash +docker buildx imagetools inspect ghcr.io/techmatrix-labs/pythinker-code:0.27.0 +``` +Expected: a manifest list with `linux/amd64` and `linux/arm64`. + +- [ ] 3. Confirm the channel marker survives into the published image: +```bash +docker run --rm ghcr.io/techmatrix-labs/pythinker-code:0.27.0 env | grep PYTHINKER_MANAGED +``` +Expected: `PYTHINKER_MANAGED=docker` (verified in CI/locally against the pulled image — NOT a pytest; this is an integration check). + +- [ ] 4. **CodeRabbit gate (C2):** confirm the `CodeRabbit` commit status on the PR head SHA is `success` before merging: +```bash +gh pr view --json statusCheckRollup,commits -q '.commits[-1].oid' +gh api "/repos/TechMatrix-labs/pythinker-code/commits//status" --jq '.statuses[] | select(.context=="CodeRabbit") | .state' +``` +Expected: `success`. Read its summary + any "Actionable comments posted: N" before merging. Then merge via the UI/`gh pr merge --squash` only after green. + +--- + +## TASK 2 — Scoop + +### Task 2.1 — Scoop manifest template + +**Files:** +- Create: `packages/scoop-bucket/pythinker-code.json.tmpl` +- Verify: read by the generator test in Task 2.2. + +**Steps:** + +- [ ] 1. Create `packages/scoop-bucket/pythinker-code.json.tmpl`. Placeholders mirror the Homebrew template's `__NAME__` convention. `bin` is `pythinker\pythinker.exe`: the onedir zip roots every file under a single `pythinker/` directory (`release-pythinker-cli.yml:476` builds `arcname = f"pythinker/{...}"`, same single-root layout the Homebrew formula chdirs into), and **no `extract_dir` is set** — so Scoop extracts the zip as-is, the natural `pythinker/` root is preserved, and `bin` resolves to `$dir\pythinker\pythinker.exe`. Setting `extract_dir: "pythinker"` would promote that subdirectory's *contents* to `$dir`, leaving the exe at `$dir\pythinker.exe`, after which `bin: pythinker\pythinker.exe` would double-nest to a non-existent `$dir\pythinker\pythinker.exe` and `scoop install` would fail to create the shim — do NOT re-add it. `env_set.PYTHINKER_MANAGED=scoop` is what the P1 updater reads. `autoupdate.url` is version-less (Scoop substitutes `$version` itself), which keeps the manifest out of the sprawl set. + +```json +{ + "version": "__VERSION__", + "description": "Pythinker Code is your next CLI agent.", + "homepage": "https://pythinker.com", + "license": "Apache-2.0", + "architecture": { + "64bit": { + "url": "__URL__", + "hash": "__SHA256__" + } + }, + "bin": "pythinker\\pythinker.exe", + "env_set": { + "PYTHINKER_MANAGED": "scoop" + }, + "checkver": { + "github": "https://github.com/TechMatrix-labs/pythinker-code" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/TechMatrix-labs/pythinker-code/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" + } + }, + "hash": { + "url": "$url.sha256" + } + } +} +``` + +- [ ] 2. Validate it is well-formed JSON before relying on it as a template fixture: +```bash +uv run python -c "import json,pathlib; json.loads(pathlib.Path('packages/scoop-bucket/pythinker-code.json.tmpl').read_text())" +``` +Expected: no output, exit 0 (the `__VERSION__` etc. are valid JSON string values, so it parses as-is). + +- [ ] 3. Commit on a new branch: +```bash +git switch main && git pull +git switch -c feat/p2-scoop +git add packages/scoop-bucket/pythinker-code.json.tmpl +git commit -m "feat(scoop): manifest template pointing at the windows onedir zip" +``` + +### Task 2.2 — generate-manifest.py (TDD — real failing test first) + +**Files:** +- Create: `tests/test_scoop_manifest.py` (write FIRST) +- Create: `packages/scoop-bucket/generate-manifest.py` +- Test runner: `uv run pytest tests/test_scoop_manifest.py -vv` + +**Steps:** + +- [ ] 1. Write the failing test FIRST. It mirrors `tests/test_homebrew_formula.py` exactly: `importlib`-load the generator, build a fake single-asset map for the Windows zip, render, and assert the manifest JSON. The asset name is the EXACT one produced by `release-pythinker-cli.yml:470` (`pythinker-{tag}-x86_64-pc-windows-msvc-onedir.zip`). + +```python +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR = ROOT / "packages" / "scoop-bucket" / "generate-manifest.py" +TEMPLATE = ROOT / "packages" / "scoop-bucket" / "pythinker-code.json.tmpl" + + +def load_generator() -> ModuleType: + spec = importlib.util.spec_from_file_location("scoop_generate_manifest", GENERATOR) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _fake_assets(generator: ModuleType, version: str) -> dict[str, dict[str, str]]: + name = generator.windows_zip_asset_name(version) + return { + name: { + "browser_download_url": f"https://example.invalid/{name}", + "digest": "sha256:" + ("a" * 64), + } + } + + +def test_scoop_manifest_renders_windows_zip() -> None: + generator = load_generator() + version = "1.2.3" + assets = _fake_assets(generator, version) + + manifest_text = generator.render_manifest( + TEMPLATE.read_text(encoding="utf-8"), generator.manifest_replacements(version, assets) + ) + manifest = json.loads(manifest_text) + + assert manifest["version"] == "1.2.3" + assert ( + manifest["architecture"]["64bit"]["url"] + == "https://example.invalid/pythinker-1.2.3-x86_64-pc-windows-msvc-onedir.zip" + ) + assert manifest["architecture"]["64bit"]["hash"] == "a" * 64 + assert manifest["bin"] == "pythinker\\pythinker.exe" + assert manifest["env_set"]["PYTHINKER_MANAGED"] == "scoop" + + +def test_scoop_manifest_fails_when_asset_missing() -> None: + generator = load_generator() + with pytest.raises(RuntimeError, match="release asset missing"): + generator.manifest_replacements("1.2.3", {}) + + +def test_windows_zip_asset_name_matches_release_workflow() -> None: + generator = load_generator() + # Exact shape produced by release-pythinker-cli.yml's onedir packaging step. + assert ( + generator.windows_zip_asset_name("0.27.0") + == "pythinker-0.27.0-x86_64-pc-windows-msvc-onedir.zip" + ) +``` + +- [ ] 2. Run it and watch it fail for the right reason (the generator does not exist yet): +```bash +uv run pytest tests/test_scoop_manifest.py -vv +``` +Expected: collection/import error — `FileNotFoundError`/`spec is None` because `packages/scoop-bucket/generate-manifest.py` does not exist. (Red.) + +- [ ] 3. Write the minimal generator. It reuses the Homebrew generator's verified helper shapes (`_fetch_json`, `_fetch_text`, `_parse_sha256_text`, `_asset_digest_sha256`, `fetch_release_assets`) but polls the **single Windows zip** instead of the four mac/linux NATIVE_TARGETS (per spec §6 Scoop row). Full code: + +```python +"""Generate the Scoop manifest for pythinker-code from GitHub Releases. + +Runs in scoop-bucket.yml after the Windows onedir zip is attached to the +Pythinker GitHub Release. Points at the EXISTING +pythinker-{version}-x86_64-pc-windows-msvc-onedir.zip asset produced by +release-pythinker-cli.yml — it does NOT clone the Homebrew generator's +mac/linux NATIVE_TARGETS (Scoop is Windows-only). + +Usage: + python generate-manifest.py \ + --version 0.27.0 \ + --template packages/scoop-bucket/pythinker-code.json.tmpl \ + --output bucket/pythinker-code.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import urllib.request +from pathlib import Path +from typing import Any + +GITHUB_REPO = "TechMatrix-labs/pythinker-code" +GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" + + +def windows_zip_asset_name(version: str) -> str: + """Exact Windows onedir zip name from release-pythinker-cli.yml.""" + return f"pythinker-{version}-x86_64-pc-windows-msvc-onedir.zip" + + +def _fetch_json(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(request, timeout=30) as resp: + data = json.load(resp) + if not isinstance(data, dict): + raise RuntimeError(f"unexpected JSON payload from {url}") + return data + + +def _fetch_text(url: str) -> str: + with urllib.request.urlopen(url, timeout=30) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def _parse_sha256_text(text: str) -> str | None: + match = re.search(r"(?i)\b([a-f0-9]{64})\b", text) + return match.group(1).lower() if match else None + + +def _asset_digest_sha256(asset: dict[str, Any]) -> str | None: + digest = asset.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:"): + return None + sha = digest[len("sha256:") :].lower() + return sha if re.fullmatch(r"[a-f0-9]{64}", sha) else None + + +def fetch_release_assets(version: str) -> dict[str, dict[str, Any]]: + release = _fetch_json(GITHUB_RELEASE_API.format(version=version)) + tag_name = release.get("tag_name") + if tag_name != f"v{version}": + raise RuntimeError(f"release tag mismatch: expected v{version}, got {tag_name!r}") + assets: dict[str, dict[str, Any]] = {} + for asset in release.get("assets", []): + if isinstance(asset, dict) and isinstance(asset.get("name"), str): + assets[asset["name"]] = asset + return assets + + +def _asset_url_and_sha(assets: dict[str, dict[str, Any]], asset_name: str) -> tuple[str, str]: + asset = assets.get(asset_name) + if asset is None: + raise RuntimeError(f"release asset missing: {asset_name}") + url = asset.get("browser_download_url") + if not isinstance(url, str) or not url: + raise RuntimeError(f"release asset {asset_name} has no browser_download_url") + sha = _asset_digest_sha256(asset) + if sha is not None: + return url, sha + sha_asset = assets.get(asset_name + ".sha256") + if sha_asset is None: + raise RuntimeError(f"release asset checksum missing: {asset_name}.sha256") + sha_url = sha_asset.get("browser_download_url") + if not isinstance(sha_url, str) or not sha_url: + raise RuntimeError(f"release asset checksum {asset_name}.sha256 has no download URL") + sha = _parse_sha256_text(_fetch_text(sha_url)) + if sha is None: + raise RuntimeError(f"could not parse SHA-256 for {asset_name}") + return url, sha + + +def manifest_replacements(version: str, assets: dict[str, dict[str, Any]]) -> dict[str, str]: + url, sha = _asset_url_and_sha(assets, windows_zip_asset_name(version)) + return {"__VERSION__": version, "__URL__": url, "__SHA256__": sha} + + +def render_manifest(template: str, replacements: dict[str, str]) -> str: + manifest = template + for placeholder, value in replacements.items(): + manifest = manifest.replace(placeholder, value) + leftovers = sorted(set(re.findall(r"__[A-Z0-9_]+__", manifest))) + if leftovers: + raise RuntimeError(f"unresolved template placeholders: {', '.join(leftovers)}") + # Parse-back assertion: the rendered manifest must be valid JSON. + json.loads(manifest) + return manifest + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--version", required=True) + ap.add_argument("--template", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + + assets = fetch_release_assets(args.version) + replacements = manifest_replacements(args.version, assets) + manifest = render_manifest(args.template.read_text(encoding="utf-8"), replacements) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(manifest, encoding="utf-8") + + digest = hashlib.sha256(manifest.encode("utf-8")).hexdigest() + print(f"manifest written to {args.output}") + print(f"version : {args.version}") + print(f"manifest sha: {digest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] 4. Run the test and watch it pass (Green): +```bash +uv run pytest tests/test_scoop_manifest.py -vv +``` +Expected: `3 passed`. + +- [ ] 5. Run the repo lint on the new files so the PR's `check` job will be green (matches `make check-pythinker-code`): +```bash +uv run ruff check tests/test_scoop_manifest.py +``` +Expected: `All checks passed!` (the generator lives under `packages/scoop-bucket/` which is generator code; if ruff flags it, match the homebrew generator's style — it passes today.) + +- [ ] 6. Commit: +```bash +git add packages/scoop-bucket/generate-manifest.py tests/test_scoop_manifest.py +git commit -m "feat(scoop): manifest generator polling the windows onedir zip" +``` + +### Task 2.3 — scoop-bucket.yml (mirror homebrew-tap.yml, App-authed cross-repo push) + +**Files:** +- Create: `.github/workflows/scoop-bucket.yml` +- Verify: `actionlint` + dry-run on a throwaway version (Task 2.4). + +**Steps:** + +- [ ] 1. Create `.github/workflows/scoop-bucket.yml`. This is `homebrew-tap.yml` with the generator/template/output/target swapped and the App swapped to `pythinker-scoop-publisher`. The token-mint step is the literal contract reference pattern (`actions/create-github-app-token`, pinned SHA copied from `homebrew-tap.yml:81`). The empty-repo first-run git dance and the empty-token fail-loud guard are copied verbatim (they are load-bearing). + +```yaml +name: Update Scoop bucket + +on: + push: + tags: + - "v+([0-9]).+([0-9]).+([0-9])" + workflow_dispatch: + inputs: + version: + description: "Version to push to the Scoop bucket (e.g. 0.27.0)" + required: true + type: string + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: read + env: + BUCKET_OWNER: TechMatrix-labs + BUCKET_REPO: scoop-pythinker + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + + - name: Resolve version + id: ver + env: + GITHUB_REF: ${{ github.ref }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ -n "${INPUT_VERSION:-}" ]]; then + version="$INPUT_VERSION" + else + echo "::error::No version source available" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Generate Scoop manifest + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euxo pipefail + mkdir -p out/bucket + deadline=$(( $(date +%s) + 30 * 60 )) + until python packages/scoop-bucket/generate-manifest.py \ + --version "$PKG_VERSION" \ + --template packages/scoop-bucket/pythinker-code.json.tmpl \ + --output out/bucket/pythinker-code.json; do + if [ "$(date +%s)" -gt "$deadline" ]; then + echo "::error::Windows onedir zip for ${PKG_VERSION} was not ready within 30 minutes" >&2 + exit 1 + fi + echo "Windows zip not ready for ${PKG_VERSION}; sleeping 30s" + sleep 30 + done + echo "--- generated manifest ---" + cat out/bucket/pythinker-code.json + + # Mint a short-lived installation token for the org-owned + # pythinker-scoop-publisher App (Contents: Read and write on + # scoop-pythinker only). Same pattern as homebrew-tap.yml's tap-publisher. + - name: Mint GitHub App token for the bucket repo + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.SCOOP_BUCKET_APP_ID }} + private-key: ${{ secrets.SCOOP_BUCKET_APP_PRIVATE_KEY }} + owner: ${{ env.BUCKET_OWNER }} + repositories: ${{ env.BUCKET_REPO }} + + - name: Sync manifest into bucket repo (handles empty repo on first run) + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + BUCKET_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euxo pipefail + if [ -z "${BUCKET_TOKEN:-}" ]; then + echo "::error::No bucket token available — the GitHub App token mint produced an empty value. Confirm SCOOP_BUCKET_APP_ID and SCOOP_BUCKET_APP_PRIVATE_KEY are set and the App is installed on ${BUCKET_OWNER}/${BUCKET_REPO} with Contents: Read and write, then re-run." >&2 + exit 1 + fi + rm -rf bucket-repo + mkdir bucket-repo + cd bucket-repo + git init -q -b main + git remote add origin \ + "https://x-access-token:${BUCKET_TOKEN}@github.com/${BUCKET_OWNER}/${BUCKET_REPO}.git" + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch --depth 1 origin main + git reset --hard FETCH_HEAD + fi + + mkdir -p bucket + cp ../out/bucket/pythinker-code.json bucket/pythinker-code.json + + if [ ! -f README.md ]; then + cat > README.md < **Honesty note — what is and is NOT test-verified.** The Task 2.2 pytest only asserts the manifest *shape* (`bin`, `env_set`, url, hash), and this dry-run only confirms the manifest *file lands* in scoop-pythinker with the right `version`/`env_set`. Neither runs `scoop install` on a Windows host, so true shim/install correctness (that `bin: pythinker\pythinker.exe` resolves against the extracted `pythinker/` root and Scoop creates a working `pythinker` shim) is **outside this plan's automated checks**. It is therefore NOT claimed as test-verified — only the manifest shape and the cross-repo publish are. Real install verification requires a Windows host running `scoop bucket add pythinker https://github.com/TechMatrix-labs/scoop-pythinker && scoop install pythinker-code`; do that once manually after the first publish. + +- [ ] 3. CodeRabbit gate (C2) as in Task 1.3 step 4, then merge. + +--- + +## TASK 3 — Nix (apps.default + PYTHINKER_MANAGED + CI smoke + monthly flake.lock PR) + +### Task 3.1 — apps.default + PYTHINKER_MANAGED wrapper env in flake.nix + +**Files:** +- Modify: `flake.nix` (installPhase ~lines 99-108; add `apps` after `packages`/`formatter` ~line 131-132) +- Verify: CI `nix-test` job (Nix is NOT on this machine — verification is CI-only; say so). + +**Steps:** + +- [ ] 1. Branch: +```bash +git switch main && git pull +git switch -c feat/p2-nix +``` + +- [ ] 2. Add the `PYTHINKER_MANAGED=nix` channel marker to the `makeWrapper` call so the P1 updater shows a nix-native hint. The current installPhase (flake.nix:99-108) ends the `makeWrapper` with `--set PYTHINKER_CLI_NO_AUTO_UPDATE "1"`. Add one more `--set` line. Before: +```nix + makeWrapper ${pythinkerCodePackage}/bin/pythinker $out/bin/pythinker \ + --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ + --set PYTHINKER_CLI_NO_AUTO_UPDATE "1" +``` +After: +```nix + makeWrapper ${pythinkerCodePackage}/bin/pythinker $out/bin/pythinker \ + --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ + --set PYTHINKER_CLI_NO_AUTO_UPDATE "1" \ + --set PYTHINKER_MANAGED "nix" +``` + +- [ ] 3. Add the `apps` output. The flake has `packages` (line 47) and `formatter` (line 132) but **no `apps` stanza** (the contract net-new). Add `apps` using the same `forAllSystems` helper. Insert directly after the `formatter = ...;` line (line 132), before the closing `};` of the outputs attrset (line 133). New block: +```nix + apps = forAllSystems ( + { system, ... }: + { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/pythinker"; + }; + } + ); +``` +> `self` is already in scope (bound in the outputs lambda, line 24). `nix run .` works today via `meta.mainProgram` (proven by `ci-pythinker-cli.yml:328`); this makes `nix run .#default` explicit and is the canonical app entry the spec requires. + +- [ ] 4. Validate the flake parses (Nix is unavailable locally — do a Nix-free syntax sanity check, then rely on CI for the real evaluation). At minimum confirm the braces balance and `nixfmt` would accept it by eye; the authoritative check is the CI `nix-test` job (Task 3.2). State in the commit that flake evaluation is verified in CI. + +- [ ] 5. Commit: +```bash +git add flake.nix +git commit -m "feat(nix): add apps.default and PYTHINKER_MANAGED=nix wrapper env" +``` + +### Task 3.2 — Extend the existing nix-test CI job (do NOT add a new workflow) + +**Files:** +- Modify: `.github/workflows/ci-pythinker-cli.yml` (the `nix-test` job, line 327-328) +- Verify: the job itself in CI on the PR. + +**Steps:** + +- [ ] 1. The advisor confirmed: `nix-test` (lines 305-328) ALREADY runs `nix run .#pythinker-code` and `nix run .`. Adding a whole new "nix build/run CI check" workflow would duplicate it. Extend the existing final step instead. Before (lines 327-328): +```yaml + - name: Run nix package + run: nix run .#pythinker-code -- --version && nix run . -- --help +``` +After: +```yaml + - name: Run nix package + run: nix run .#pythinker-code -- --version && nix run . -- --help + + - name: Run nix app (apps.default) and assert PYTHINKER_MANAGED + run: | + set -euo pipefail + nix run .#default -- --version + nix build .#default + grep -q 'PYTHINKER_MANAGED' result/bin/pythinker + echo "apps.default runs and the wrapper sets PYTHINKER_MANAGED" +``` +> The `grep` on `result/bin/pythinker` is the integration check that `PYTHINKER_MANAGED` is set by the wrapper (the makeWrapper-generated launcher is a shell script that `export`s its `--set` vars). This verifies the channel marker in CI — NOT a pytest. + +- [ ] 2. Lint: +```bash +docker run --rm -v /home/ai/Projects/pythinker-code-main:/repo -w /repo rhysd/actionlint:latest -color .github/workflows/ci-pythinker-cli.yml +``` +Expected: no output, exit 0. + +- [ ] 3. Commit: +```bash +git add .github/workflows/ci-pythinker-cli.yml +git commit -m "test(nix): smoke nix run .#default and assert PYTHINKER_MANAGED in nix-test" +``` + +### Task 3.3 — Monthly update-flake-lock PR workflow + +**Files:** +- Create: `.github/workflows/update-flake-lock.yml` +- Verify: `actionlint` + a manual `workflow_dispatch`. + +**Steps:** + +- [ ] 1. Create `.github/workflows/update-flake-lock.yml`. Monthly cron + manual dispatch; uses `DeterminateSystems/update-flake-lock` which opens a PR. **C1 gotcha (advisor item 5):** a PR opened with the default `GITHUB_TOKEN` does NOT trigger required status checks, so under branch protection it can never satisfy the merge gate. Options: (a) pass an App/PAT token so the PR triggers checks, or (b) document that the operator must close+reopen (or push an empty commit to) the PR to fire checks. This plan uses (b) — no new long-lived secret — and the workflow body says so. If the org later wants hands-off merges, swap `token:` to a fine-grained PAT in a follow-up. + +```yaml +name: Update flake.lock + +on: + schedule: + # 06:00 UTC on the 1st of each month. + - cron: "0 6 1 * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-lock: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Update flake.lock and open PR + uses: DeterminateSystems/update-flake-lock@main + with: + pr-title: "chore(nix): monthly flake.lock update" + pr-labels: dependencies + # NOTE: this PR is opened with the default GITHUB_TOKEN, which does + # NOT trigger required status checks. Under main branch protection + # (enforce_admins=true) the maintainer must push an empty commit or + # close+reopen the PR to fire CI before it can merge. To make this + # hands-off, replace this with a fine-grained PAT/App token in a + # follow-up. Branch prefix is the action default (update_flake_lock_action). + branch: update-flake-lock +``` + +- [ ] 2. Lint: +```bash +docker run --rm -v /home/ai/Projects/pythinker-code-main:/repo -w /repo rhysd/actionlint:latest -color .github/workflows/update-flake-lock.yml +``` +Expected: no output, exit 0. + +- [ ] 3. Commit: +```bash +git add .github/workflows/update-flake-lock.yml +git commit -m "chore(nix): monthly update-flake-lock PR workflow" +``` + +### Task 3.4 — Nix PR + CI verification + +**Files:** none (verification + merge). + +**Steps:** + +- [ ] 1. Push + PR (C1): +```bash +git push -u origin feat/p2-nix +gh pr create --base main --title "feat(nix): apps.default + channel marker + monthly lock PR" \ + --body "P2 Nix polish. Adds apps.default (nix run .#default), sets PYTHINKER_MANAGED=nix in the wrapper, extends nix-test to smoke the app + assert the marker, adds a monthly update-flake-lock PR. Nix evaluation verified in the nix-test CI job (Nix unavailable locally)." +``` + +- [ ] 2. Verify in CI (the only place Nix runs): on the PR, the `nix-test` job's new step must pass on all three platforms: +```bash +gh run watch "$(gh run list --workflow=ci-pythinker-cli.yml --branch feat/p2-nix --limit 1 --json databaseId -q '.[0].databaseId')" +``` +Expected: `nix-test` green; step log shows `apps.default runs and the wrapper sets PYTHINKER_MANAGED`. + +- [ ] 3. Dry-run the lock workflow manually (proves it opens a PR; no tag needed): +```bash +gh workflow run update-flake-lock.yml --ref feat/p2-nix +gh run watch "$(gh run list --workflow=update-flake-lock.yml --limit 1 --json databaseId -q '.[0].databaseId')" +``` +Expected: green; a `chore(nix): monthly flake.lock update` PR appears (or "No changes" if the lock is already current). Close that bot PR after confirming — it is just a dry-run artifact. + +- [ ] 4. CodeRabbit gate (C2), then merge. + +--- + +## TASK 4 — WinGet (manual workflow_dispatch only) + +### Task 4.1 — winget.yml manual submit workflow + +**Files:** +- Create: `.github/workflows/winget.yml` +- Verify: `actionlint` + (real submit deferred — it opens a PR against microsoft/winget-pkgs; do that only on a real release). + +**Steps:** + +- [ ] 1. Requires OP-4 (`WINGET_SUBMIT_TOKEN`). Create `.github/workflows/winget.yml`. It is **manual only** (`workflow_dispatch`, no `push` trigger) — the hard gate from spec §6. It uses `vedantmgoyal9/winget-releaser` (the maintained wingetcreate wrapper) or a direct `wingetcreate update --submit`. This plan uses a direct `wingetcreate` call for transparency. + +```yaml +name: Submit to WinGet + +on: + workflow_dispatch: + inputs: + version: + description: "Released version to submit to winget-pkgs (e.g. 0.27.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + submit: + # WinGet manifests can only be submitted from Windows (wingetcreate is a + # Windows tool). Manual-only by design: a human runs this AFTER a release is + # fully promoted, so it never gates promote and never auto-fires on a tag. + runs-on: windows-latest + steps: + - name: Verify the release is published and non-prerelease + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + is_pre=$(gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" --json isPrerelease -q '.isPrerelease') + if [ "$is_pre" != "false" ]; then + echo "::error::Release v${VERSION} is not a promoted (non-prerelease) release; refusing to submit to WinGet." >&2 + exit 1 + fi + + - name: Submit manifest update with wingetcreate + shell: pwsh + env: + WINGET_TOKEN: ${{ secrets.WINGET_SUBMIT_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = "Stop" + $installerUrl = "https://github.com/TechMatrix-labs/pythinker-code/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" + Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + # PackageIdentifier must match the existing winget-pkgs entry; create it + # once manually via `wingetcreate new` before the first automated update. + .\wingetcreate.exe update TechMatrixLabs.PythinkerCode ` + --version $env:VERSION ` + --urls "$installerUrl" ` + --submit ` + --token $env:WINGET_TOKEN +``` +> The installer asset is `PythinkerSetup-.exe` — confirmed by `native_installer_asset_name()` in `src/pythinker_code/native.py:48` and the README at `README.md:151`. + +> **WinGet does NOT set `PYTHINKER_MANAGED` — accepted limitation, not an oversight.** Unlike Docker (`ENV`), Scoop (`env_set`), and Nix (`makeWrapper --set`), WinGet installs the *identical* `PythinkerSetup-.exe` that a direct `/releases/download` grab installs, and a WinGet manifest has **no `env_set` equivalent** to inject a process env var. So a WinGet install is byte-for-byte a native install and the in-app updater cannot distinguish the two. This is fine: that installer drops the `.pythinker-native` sentinel (`src/pythinker_code/native.py:17`), so `is_native_build()` returns `True` and `_detect_upgrade_command()` (`src/pythinker_code/ui/shell/update.py:100`) routes WinGet users to the **native-installer upgrade hint** — a correct, if not WinGet-specific, message. A genuinely WinGet-native upgrade hint would require a marker or wrapper that does not exist today; that is a documented **follow-up**, deliberately out of P2 scope. (The contract's "Scoop/WinGet manifests set it" is honored for Scoop; for WinGet there is no manifest mechanism to honor it, hence the documented fallback.) + +> **WinGet README snippet is intentionally DEFERRED (see Task 5.1).** No version-less WinGet row is added to the README in this phase. The `TechMatrixLabs.PythinkerCode` PackageIdentifier does not exist in `microsoft/winget-pkgs` until a human runs this workflow on a real release (and a maintainer first creates it via `wingetcreate new`). Advertising `winget install TechMatrixLabs.PythinkerCode` before that manifest is merged would point users at a non-existent package. The README row is therefore added only after the first WinGet manifest is live — tracked as a follow-up, not a silent omission. + +- [ ] 2. Lint: +```bash +docker run --rm -v /home/ai/Projects/pythinker-code-main:/repo -w /repo rhysd/actionlint:latest -color .github/workflows/winget.yml +``` +Expected: no output, exit 0. + +- [ ] 3. Branch + commit: +```bash +git switch main && git pull +git switch -c feat/p2-winget +git add .github/workflows/winget.yml +git commit -m "feat(winget): manual workflow_dispatch submit via isolated PAT" +``` + +- [ ] 4. Push + PR (C1): +```bash +git push -u origin feat/p2-winget +gh pr create --base main --title "feat(winget): manual WinGet submit workflow" \ + --body "P2 WinGet (last, manual only). workflow_dispatch-only submit to microsoft/winget-pkgs via the isolated WINGET_SUBMIT_TOKEN PAT; guarded to non-prerelease releases. Never auto-fires on a tag; never gates promote. First-ever PackageIdentifier must be created manually with 'wingetcreate new'." +``` +> Do NOT do a live `--submit` dry-run — it opens a real PR against microsoft/winget-pkgs. The first real submission happens on the next genuine release by a human running this workflow. Verification here is `actionlint` green + CodeRabbit; the live behavior is exercised on a real release. + +- [ ] 5. CodeRabbit gate (C2), then merge. + +--- + +## TASK 5 — README version-less snippets (C4) + +### Task 5.1 — Add Docker / Scoop / Nix install rows + +**Files:** +- Modify: `README.md` (the platform install table ~lines 151-155; the install detail section ~lines 210-227) +- Verify: `tests/test_version_lockstep.py` (P1) must still pass — these snippets are version-less so they add NO version-bearing strings. + +**Steps:** + +- [ ] 1. Branch (this can ride with any one channel PR, but a standalone doc PR is cleanest): +```bash +git switch main && git pull +git switch -c docs/p2-install-snippets +``` + +- [ ] 2. Add three **version-less** rows to the platform install table. After the Homebrew row (`README.md:153`), insert: +```markdown +| **🐳 Docker** | `docker run --rm -it ghcr.io/techmatrix-labs/pythinker-code` | GHCR multi-arch image | +| **🪟 Windows — Scoop** | `scoop bucket add pythinker https://github.com/TechMatrix-labs/scoop-pythinker && scoop install pythinker-code` | auto-published Scoop bucket | +| **❄️ Nix** | `nix run github:TechMatrix-labs/pythinker-code` | flake `apps.default` | +``` +> Every command is version-less (`scoop install pythinker-code`, `docker run ghcr.io/...`, `nix run github:...`) per spec §6 "C4 for new channels" — they never enter the F3 sprawl set, so the lockstep test (which only asserts version-bearing strings) is unaffected. +> +> **WinGet row is intentionally omitted here (deferral, not oversight).** Three rows are added — Docker, Scoop, Nix — and **no** WinGet row. The `TechMatrixLabs.PythinkerCode` PackageIdentifier does not exist in `microsoft/winget-pkgs` until the manual `winget.yml` workflow (Task 4.1) submits it on a real release, so advertising `winget install TechMatrixLabs.PythinkerCode` now would point users at an unpublished package. Add the version-less WinGet row (`winget install TechMatrixLabs.PythinkerCode`) in a follow-up once the first manifest is merged upstream. This deferral is also recorded in Task 4.1. + +- [ ] 3. Sanity check that you introduced no `==` or `PythinkerSetup-` strings (would break the P1 lockstep test): +```bash +git diff README.md | grep -E '^\+' | grep -E '==[0-9]+\.[0-9]+\.[0-9]+|PythinkerSetup-[0-9]' && echo "FOUND VERSIONED STRING — FIX" || echo "OK: no versioned strings added" +``` +Expected: `OK: no versioned strings added`. + +- [ ] 4. If P1 is merged, run the lockstep test to prove the README edits didn't break it: +```bash +uv run pytest tests/test_version_lockstep.py -vv +``` +Expected: `passed` (only run if P1's test exists; if P1 isn't merged yet, skip and note it). + +- [ ] 5. Commit, push, PR (C1): +```bash +git add README.md +git commit -m "docs(p2): add version-less Docker, Scoop, and Nix install snippets" +git push -u origin docs/p2-install-snippets +gh pr create --base main --title "docs(p2): version-less install snippets for new channels" \ + --body "C4-compliant version-less install commands for the P2 channels (Docker/GHCR, Scoop, Nix). No version-bearing strings added, so the lockstep test is unaffected." +``` + +- [ ] 6. CodeRabbit gate (C2), then merge. + +--- + +## Phase verification (prove the WHOLE phase works end-to-end) + +**Done means:** the next real `vX.Y.Z` tag publishes all four channels best-effort, none of them gate `promote-release`, and each non-self-updating channel sets `PYTHINKER_MANAGED`. + +1. **Pre-flight (before the next real release):** all four PRs merged; OP-1..OP-5 confirmed (`gh secret list --org TechMatrix-labs | grep SCOOP_BUCKET_APP` shows both; `gh repo view TechMatrix-labs/scoop-pythinker` is public). P1 merged (the `PYTHINKER_MANAGED` env read exists in `update.py`) — otherwise the markers are set but unread. + +2. **First real release rehearsal:** on the next maintainer release, after the human pushes `vX.Y.Z`, watch the four channel workflows fire from the tag: +```bash +for wf in docker.yml scoop-bucket.yml homebrew-tap.yml; do + echo "== $wf =="; gh run list --workflow=$wf --limit 1 --json status,conclusion,headBranch +done +``` +Expected: `docker.yml` and `scoop-bucket.yml` complete (green) **independently of** `promote-release.yml`. `winget.yml` does NOT appear (manual-only, correct). + +3. **Prove promote is NOT gated by P2:** confirm `promote-release.yml`'s blocking set contains only PyPI(code+core+host+review) + GH assets — NOT Docker/Scoop/Nix (this is P0's edit; here we only assert P2 channels are absent from the gate). Inspect: +```bash +grep -n "docker\|scoop\|ghcr\|nix" .github/workflows/promote-release.yml || echo "GOOD: no P2 channel referenced in promote gate" +``` +Expected: `GOOD: no P2 channel referenced in promote gate`. + +4. **Channel-live checks after the release:** + - Docker: `docker run --rm ghcr.io/techmatrix-labs/pythinker-code: --version` prints `X.Y.Z`; `docker run --rm ... env | grep PYTHINKER_MANAGED` → `docker`. **`:latest` does NOT auto-advance** — `docker.yml` has no `release:` trigger and promote flips the prerelease flag via a release edit that does not re-fire it (see Task 1.2 runbook note). After promotion the maintainer MUST run `gh workflow run docker.yml -f version=X.Y.Z`; only after that re-dispatch does `:latest` resolve to `X.Y.Z` (verify with `docker buildx imagetools inspect ghcr.io/techmatrix-labs/pythinker-code:latest`). + - Scoop: `gh api /repos/TechMatrix-labs/scoop-pythinker/contents/bucket/pythinker-code.json` shows `version == X.Y.Z` and `env_set.PYTHINKER_MANAGED == scoop`. + - Nix: the `nix-test` CI job on main is green and its log shows the `PYTHINKER_MANAGED` assertion passing; `nix run github:TechMatrix-labs/pythinker-code -- --version` (on a Nix host) prints `X.Y.Z`. + - WinGet: a human runs `gh workflow run winget.yml -f version=X.Y.Z` only after promotion; it opens a PR on the winget-pkgs fork. + +5. **Best-effort proof:** intentionally re-run `docker.yml` against a still-prerelease tag (e.g. immediately after a tag, before promote flips it) — `move-latest` must report `move=false` and skip, while the version tag still publishes. This proves a channel failure/lag can never advance `:latest` ahead of `/releases/latest` and can never block the release. + +**If anything is red:** the channel is best-effort, so a single red channel workflow must NOT be treated as a release failure — fix-and-rerun the channel workflow with `gh workflow run -f version=X.Y.Z`. Only `promote-release` (P0) failing is a release failure. + +--- + +## Finalize notes (review punch-list applied) + +This plan is the finalized deliverable; the review punch-list has been folded into the body. For traceability: + +- **Scoop `extract_dir` (stale BLOCKING item — dropped, NOT a live defect).** An earlier draft's punch-list claimed the Scoop manifest set both `extract_dir: "pythinker"` and `bin: "pythinker\pythinker.exe"` (double-nest). The current Task 2.1 template (the JSON block) has **no `extract_dir` key**, and the Task 2.1 prose explains why adding one would double-nest the shim. Bin resolution is correct as written: the Windows onedir zip roots all files under a single `pythinker/` directory (`release-pythinker-cli.yml` builds `arcname = f"pythinker/{...}"`), so with no `extract_dir` the natural `pythinker/` root is preserved and `bin` resolves to `$dir\pythinker\pythinker.exe`. **Do NOT re-add `extract_dir`.** +- **Docker Task 1.3 step 2 (fixed).** The dry-run rationale now matches the gate's actual `isPrerelease` logic: dispatching `version=0.27.0` yields `move=true` (benign, re-points `:latest` at the current release), and the `move=false` skip is exercised only against a still-prerelease tag (Phase-verification step 5). +- **Docker `:latest` post-promotion (fixed).** Task 1.2 runbook note + Phase-verification step 4 now state `:latest` does NOT auto-advance after promotion and require the maintainer to run `gh workflow run docker.yml -f version=X.Y.Z` after promote flips the prerelease flag. +- **WinGet `PYTHINKER_MANAGED` (acknowledged).** Task 4.1 documents that WinGet installs the identical native `.exe`, has no `env_set` mechanism, and therefore falls back to the native-installer hint via `is_native_build()`/`.pythinker-native` — an accepted limitation with a documented follow-up. +- **WinGet README row (deferred explicitly).** Tasks 4.1 and 5.1 state the version-less WinGet row is deferred until the first manifest is live in `microsoft/winget-pkgs` (don't advertise an unpublished PackageIdentifier). +- **Scoop install verification honesty (added).** Task 2.4 notes that true shim/install correctness needs a Windows host running `scoop install`; the automated checks verify only manifest shape + cross-repo publish. +- **`scoop-bucket.yml` location (disclosed deviation, confirm with contract owner).** Recorded at the top of the plan: the workflow lives in `pythinker-code` (not `scoop-pythinker`) and pushes cross-repo via the `pythinker-scoop-publisher` App — the only design that actually exercises the App, mirroring `homebrew-tap.yml`. +- **C1/C2/C3/C5 verified.** Every task is branch→PR→CodeRabbit(C2)→merge (C1); the Dockerfile's apt packages are container system deps, not pyproject runtime deps (C3); no task touches CHANGELOG (C5). Local-vs-CI honesty holds: Task 2.2 is the only real failing-test-first pytest; all workflow/Nix/cross-repo steps are marked CI-verified. diff --git a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md new file mode 100644 index 00000000..aeae50a8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md @@ -0,0 +1,358 @@ +# Welcome Banner Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the startup welcome banner to the approved "footer chip" layout — cleaner, more readable, more professional — without changing the robot logo glyphs or its colors. + +**Architecture:** All changes live in one function, `_print_welcome_info`, in `src/pythinker_code/ui/shell/__init__.py`. The "What's new / Update available" chip moves from a top-right inline cell to the Rich `Panel` **subtitle** (bottom border). The headline/strapline/help block bottom-aligns beside the 5-line robot logo. The info grid drops its `│` separator column. The panel border and title switch from a raw `grey39` literal to theme tokens so they adapt to light mode. + +**Tech Stack:** Python 3.12, Rich 15.0.0 (`Panel`, `Table`, `Text`, `Group`, `box.ROUNDED`), pytest, ruff (line-length 100), `uv`. + +--- + +## Background facts (already verified against the installed Rich 15.0.0) + +- `Panel(subtitle=, subtitle_align='right', title_align='left', border_style= + + +
+ {_PYTHINKER_CALLBACK_LOGO_SVG.replace("

{escaped_body}

+
+ +""" + + async def _handle_browser_callback( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, state: str ) -> tuple[str | None, str | None]: @@ -251,17 +321,15 @@ async def _handle_browser_callback( error = "OpenAI callback did not include an authorization code." ok = code is not None and error is None - title = "OpenAI login complete" if ok else "OpenAI login failed" - body = "You can close this window and return to Pythinker." if ok else error - html = f"

{title}

{body}

" + response_html = _callback_html(ok=ok, message=error) status = "200 OK" if ok else "400 Bad Request" writer.write( bytes( f"HTTP/1.1 {status}\r\n" "Content-Type: text/html; charset=utf-8\r\n" - f"Content-Length: {len(html.encode('utf-8'))}\r\n" + f"Content-Length: {len(response_html.encode('utf-8'))}\r\n" "Connection: close\r\n\r\n" - f"{html}", + f"{response_html}", encoding="utf-8", ) ) @@ -530,21 +598,197 @@ async def refresh_openai_chatgpt_token(refresh_token: str) -> OAuthToken: return _token_from_openai_response(payload) -async def _discover_chatgpt_models(access_token: str) -> list[ModelInfo]: - platform = Platform( - id=OPENAI_CHATGPT_PLATFORM_ID, - name="OpenAI ChatGPT Codex", - base_url=OPENAI_CHATGPT_BASE_URL, - allowed_prefixes=None, +def build_chatgpt_codex_headers( + *, access_token: str | None = None, account_id: str | None = None +) -> dict[str, str]: + """Headers expected by the ChatGPT-backed Codex endpoint. + + The ChatGPT Codex backend is account-scoped. The bearer token carries the + subscription, and ``ChatGPT-Account-ID`` disambiguates the active ChatGPT + account when OpenAI returns it in the OAuth claims. + """ + headers = { + "User-Agent": f"codex_cli_rs/0.0.0 ({USER_AGENT})", + "originator": "codex_cli_rs", + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + if account_id: + headers["ChatGPT-Account-ID"] = account_id + return headers + + +def _optional_bool(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + return None + + +def _optional_int(value: object) -> int | None: + if isinstance(value, int | float | str): + try: + return int(value) + except (TypeError, ValueError): + return None + return None + + +def _field(item: dict[str, Any], *names: str) -> object: + for name in names: + if name in item and item[name] is not None: + return item[name] + return None + + +def _string_field(item: dict[str, Any], *names: str) -> str | None: + value = _field(item, *names) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _looks_like_reasoning_model(model_id: str) -> bool: + normalized = model_id.lower() + return normalized.startswith(("gpt-5", "o1", "o3", "o4")) or "codex" in normalized + + +def _chatgpt_model_context(model_id: str, item: dict[str, Any]) -> int: + value = _optional_int( + _field( + item, + "context_window", + "contextWindow", + "context_length", + "contextLength", + "max_context_size", + ) ) - try: - return await list_models(platform, access_token) - except aiohttp.ClientResponseError as exc: - if exc.status in {401, 403}: - raise - return list(OPENAI_CHATGPT_FALLBACK_MODELS) - except Exception: - return list(OPENAI_CHATGPT_FALLBACK_MODELS) + if value and value > 0: + return value + return 128_000 if model_id.lower().endswith("spark") else 272_000 + + +def _chatgpt_model_supports_image(model_id: str, item: dict[str, Any]) -> bool: + value = _optional_bool( + _field( + item, + "supports_image_in", + "supportsImageIn", + "supports_vision", + "supportsVision", + "vision", + ) + ) + if value is not None: + return value + # OpenAI documents Spark as a text-only research-preview model. + return not model_id.lower().endswith("spark") + + +def _is_visible_chatgpt_model(item: dict[str, Any]) -> bool: + visibility = item.get("visibility") + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: + return False + for key in ("disabled", "unavailable"): + if item.get(key) is True: + return False + return all(item.get(key) is not False for key in ("available", "is_available", "isAvailable")) + + +def _parse_chatgpt_models_payload(payload: object) -> list[ModelInfo]: + if not isinstance(payload, dict): + raise ValueError("Unexpected OpenAI ChatGPT Codex models response.") + payload_dict = cast(dict[str, Any], payload) + raw_models = payload_dict.get("models") + if not isinstance(raw_models, list): + # Keep a small compatibility path in case OpenAI ever aligns this with + # the public /v1/models shape. ChatGPT Codex currently returns + # {"models": [{"slug": ...}]}. + raw_models = payload_dict.get("data") + if not isinstance(raw_models, list): + raise ValueError("OpenAI ChatGPT Codex models response did not include models.") + + raw_model_items = cast(list[object], raw_models) + sortable: list[tuple[int, int, ModelInfo]] = [] + seen: set[str] = set() + for index, raw_item in enumerate(raw_model_items): + if not isinstance(raw_item, dict): + continue + item = cast(dict[str, Any], raw_item) + model_id = _string_field(item, "slug", "id") + if not model_id or model_id in seen or not _is_visible_chatgpt_model(item): + continue + seen.add(model_id) + reasoning = _optional_bool( + _field(item, "supports_reasoning", "supportsReasoning", "reasoning") + ) + supports_reasoning = ( + reasoning if reasoning is not None else _looks_like_reasoning_model(model_id) + ) + priority = _optional_int(item.get("priority")) + display_name = _string_field(item, "display_name", "displayName", "name", "title") + sortable.append( + ( + priority if priority is not None else 10_000, + index, + ModelInfo( + id=model_id, + context_length=_chatgpt_model_context(model_id, item), + supports_reasoning=supports_reasoning, + supports_image_in=_chatgpt_model_supports_image(model_id, item), + supports_video_in=bool( + _optional_bool( + _field( + item, + "supports_video_in", + "supportsVideoIn", + "supports_video", + "supportsVideo", + ) + ) + ), + display_name=display_name, + ), + ) + ) + + sortable.sort(key=lambda entry: (entry[0], entry[1])) + models = [model for _, _, model in sortable] + if not models: + raise ValueError("No OpenAI ChatGPT Codex models are available for this account.") + return models + + +def _chatgpt_models_url(base_url: str | None = None) -> str: + root = (base_url or OPENAI_CHATGPT_BASE_URL).rstrip("/") + return f"{root}/models?client_version=1.0.0" + + +async def discover_chatgpt_models( + access_token: str, + *, + account_id: str | None = None, + base_url: str | None = None, +) -> list[ModelInfo]: + async with ( + new_client_session() as session, + session.get( + _chatgpt_models_url(base_url), + headers=build_chatgpt_codex_headers( + access_token=access_token, + account_id=account_id, + ), + raise_for_status=True, + ) as response, + ): + payload = await response.json(content_type=None) + return _parse_chatgpt_models_payload(payload) def _token_from_openai_response(payload: dict[str, Any]) -> OAuthToken: @@ -555,17 +799,15 @@ def _token_from_openai_response(payload: dict[str, Any]) -> OAuthToken: **payload, } # ChatGPT does not return `account_id` as a top-level OAuth response field; - # it lives inside the id_token JWT claims under + # it lives inside the OAuth JWT claims under # `https://api.openai.com/auth.chatgpt_account_id`. Hoist it onto the # response so OAuthToken.from_response() picks it up. Without this the - # ChatGPT usage adapter (and any other code calling - # OAuthManager.get_chatgpt_account_id) sees None. - if ( - "account_id" not in normalized - and (id_token := payload.get("id_token")) - and (account_id := _extract_chatgpt_account_id(str(id_token))) - ): - normalized["account_id"] = account_id + # ChatGPT usage adapter, model catalog endpoint, and Codex request headers + # cannot scope requests to the active Plus/Pro account. + if "account_id" not in normalized: + jwt_token = payload.get("id_token") or payload.get("access_token") + if jwt_token and (account_id := _extract_chatgpt_account_id(str(jwt_token))): + normalized["account_id"] = account_id return OAuthToken.from_response(normalized) @@ -610,7 +852,7 @@ async def _finish_chatgpt_login( api_key = "" if id_token := token_payload.get("id_token"): api_key = await _exchange_id_token_for_api_key(str(id_token)) - models = await _discover_chatgpt_models(token.access_token) + models = await discover_chatgpt_models(token.access_token, account_id=token.account_id) selected_model, thinking = _select_default_openai_model(models) except Exception as exc: from pythinker_code.telemetry.errors import report_handled_error diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index f7f1174f..9d0e81bc 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -176,10 +176,11 @@ def _select_retry_api_keys( def _openai_fallback_models(platform_id: str) -> list[ModelInfo] | None: + # ChatGPT Codex model availability is subscription/account-specific. Do not + # replace the user's live catalog with a static fallback; stale fallback + # slugs surface as 400 "model is not supported with a ChatGPT account". if platform_id == OPENAI_CHATGPT_PLATFORM_ID: - from pythinker_code.auth.openai import OPENAI_CHATGPT_FALLBACK_MODELS - - return list(OPENAI_CHATGPT_FALLBACK_MODELS) + return None if platform_id == OPENAI_API_PLATFORM_ID: from pythinker_code.auth.openai import OPENAI_API_FALLBACK_MODELS @@ -208,6 +209,24 @@ def _fallback_or_log( return models +async def _list_models_for_managed_platform( + *, + platform_id: str, + platform: Platform, + api_key: str, + account_id: str | None = None, +) -> list[ModelInfo]: + if platform_id == OPENAI_CHATGPT_PLATFORM_ID: + from pythinker_code.auth.openai import discover_chatgpt_models + + return await discover_chatgpt_models( + api_key, + account_id=account_id, + base_url=platform.base_url, + ) + return await list_models(platform, api_key) + + async def refresh_managed_models(config: Config) -> bool: if not config.is_from_default_location: return False @@ -276,8 +295,20 @@ async def refresh_managed_models(config: Config) -> bool: ) continue effective_platform = platform._replace(base_url=provider.base_url) + account_id = ( + oauth_manager.get_chatgpt_account_id(provider.oauth) + if platform_id == OPENAI_CHATGPT_PLATFORM_ID + and provider.oauth is not None + and oauth_manager is not None + else None + ) try: - models = await list_models(effective_platform, api_key) + models = await _list_models_for_managed_platform( + platform_id=platform_id, + platform=effective_platform, + api_key=api_key, + account_id=account_id, + ) except aiohttp.ClientResponseError as exc: if exc.status != 401 or provider.oauth is None or oauth_manager is None: fallback_models = _fallback_or_log(platform_id=platform_id, error=exc) @@ -311,6 +342,8 @@ async def refresh_managed_models(config: Config) -> bool: resolved_api_key=oauth_manager.resolve_api_key(provider.api_key, provider.oauth), fallback_api_key=fallback_api_key, ) + if platform_id == OPENAI_CHATGPT_PLATFORM_ID: + account_id = oauth_manager.get_chatgpt_account_id(provider.oauth) if not retry_api_keys: fallback_models = _fallback_or_log( platform_id=platform_id, @@ -325,7 +358,12 @@ async def refresh_managed_models(config: Config) -> bool: retry_exc: Exception | None = None for retry_api_key in retry_api_keys: try: - models = await list_models(effective_platform, retry_api_key) + models = await _list_models_for_managed_platform( + platform_id=platform_id, + platform=effective_platform, + api_key=retry_api_key, + account_id=account_id, + ) break except Exception as exc3: retry_exc = exc3 diff --git a/src/pythinker_code/cli/update.py b/src/pythinker_code/cli/update.py index 6045357e..366ec36b 100644 --- a/src/pythinker_code/cli/update.py +++ b/src/pythinker_code/cli/update.py @@ -10,6 +10,7 @@ @cli.callback(invoke_without_command=True) def update( + ctx: typer.Context, check_only: Annotated[ bool, typer.Option( @@ -17,10 +18,52 @@ def update( help="Only check whether an update is available; don't install.", ), ] = False, -): +) -> None: """Check for and install Pythinker CLI updates.""" - from pythinker_code.ui.shell.update import UpdateResult, do_update + if ctx.invoked_subcommand is not None: + return - result = asyncio.run(do_update(print=True, check_only=check_only)) + from pythinker_code.ui.shell.update import UpdateResult + from pythinker_code.ui.shell.update_orchestrator import run_update_job + + result = asyncio.run(run_update_job(print_output=True, check_only=check_only, source="cli")) if result in (UpdateResult.FAILED, UpdateResult.UNSUPPORTED): raise typer.Exit(1) + + +@cli.command("status") +def status_command() -> None: + """Show the last recorded update job status.""" + from pythinker_code.ui.shell.update_orchestrator import read_update_status + + status = read_update_status() + if status is None: + typer.echo("No update job recorded.") + return + typer.echo(f"State: {status.state.value}") + if status.result: + typer.echo(f"Result: {status.result}") + if status.current_version: + typer.echo(f"Current version: {status.current_version}") + if status.target_version: + typer.echo(f"Target version: {status.target_version}") + if status.message: + typer.echo(f"Message: {status.message}") + typer.echo(f"Log: {status.log_path}") + + +@cli.command("log") +def log_command( + lines: Annotated[ + int, + typer.Option("--lines", "-n", min=1, help="Number of log lines to show."), + ] = 80, +) -> None: + """Show the tail of the update log.""" + from pythinker_code.ui.shell.update_orchestrator import read_update_log_tail + + tail = read_update_log_tail(lines) + if not tail: + typer.echo("No update log recorded.") + return + typer.echo("\n".join(tail)) diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index b90c754e..4a65ab49 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -20,6 +20,10 @@ from tomlkit.exceptions import TOMLKitError from pythinker_code.exception import ConfigError +from pythinker_code.feedback_repo import ( + DEFAULT_FEEDBACK_GITHUB_REPO, + normalize_feedback_github_repo, +) from pythinker_code.hooks.config import HookDef from pythinker_code.llm import ModelCapability, ProviderType from pythinker_code.share import get_share_dir @@ -279,10 +283,15 @@ class FeedbackConfig(BaseModel): ), ) github_repo: str = Field( - default="TechMatrix-labs/pythinker-code", + default=DEFAULT_FEEDBACK_GITHUB_REPO, description="GitHub owner/repo used by /feedback GitHub OAuth submissions.", ) + @field_validator("github_repo") + @classmethod + def migrate_legacy_github_repo(cls, value: str) -> str: + return normalize_feedback_github_repo(value) + @field_serializer("api_key", when_used="json") def dump_secret(self, v: SecretStr | None): return v.get_secret_value() if v is not None else None diff --git a/src/pythinker_code/feedback.py b/src/pythinker_code/feedback.py index 6f45df75..e299ae60 100644 --- a/src/pythinker_code/feedback.py +++ b/src/pythinker_code/feedback.py @@ -14,6 +14,10 @@ import aiohttp from pythinker_code.constant import VERSION +from pythinker_code.feedback_repo import ( + DEFAULT_FEEDBACK_GITHUB_REPO, + normalize_feedback_github_repo, +) from pythinker_code.telemetry.errors import RecentError, recent_errors from pythinker_code.ui.shell.oauth import current_model_key from pythinker_code.utils.aiohttp import new_client_session @@ -44,16 +48,28 @@ "other": "other", "feedback": "other", } -_SENSITIVE_KEY_RE = re.compile(r"(api[_-]?key|token|secret|password|passwd|authorization)", re.I) +_SENSITIVE_KEY_RE = re.compile( + r"(api[_-]?key|token|secret|password|passwd|authorization|proxy|" + r"pip[_-]?[\w-]*index[_-]?url)", + re.I, +) _SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( ( - re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[A-Za-z0-9._~+/=-]{8,}"), + re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token|basic)\s+)[A-Za-z0-9._~+/=-]{8,}"), r"\1", ), ( re.compile(r"(?i)((?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*)[^\s'\"]+"), r"\1", ), + ( + re.compile( + r"(?i)((?:pip[_-]?index[_-]?url|pip[_-]?extra[_-]?index[_-]?url|" + r"https?[_-]?proxy|all[_-]?proxy)\s*[:=]\s*)[^\s'\"]+" + ), + r"\1", + ), + (re.compile(r"(?i)(https?://)[^\s/@:]+:[^\s/@]+@"), r"\1@"), (re.compile(r"\bsk-(?:ant|proj|[A-Za-z0-9])[A-Za-z0-9_-]{16,}\b"), ""), (re.compile(r"\bxox(?:a|b|p|r|s)-[A-Za-z0-9-]{10,}\b"), ""), (re.compile(r"\bAIza[0-9A-Za-z_-]{30,45}\b"), ""), @@ -263,10 +279,11 @@ async def submit_feedback_payload( def build_feedback_issue_url( - payload: dict[str, Any], repo: str = "TechMatrix-labs/pythinker-code" + payload: dict[str, Any], repo: str = DEFAULT_FEEDBACK_GITHUB_REPO ) -> str: from urllib.parse import urlencode + repo = normalize_feedback_github_repo(repo) title = build_feedback_title(payload) body = build_feedback_issue_body(payload) labels = f"feedback,feedback:{payload.get('type') or 'other'}" diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py new file mode 100644 index 00000000..24696af1 --- /dev/null +++ b/src/pythinker_code/feedback_repo.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +DEFAULT_FEEDBACK_GITHUB_REPO = "TechMatrix-labs/pythinker-code" +_LEGACY_FEEDBACK_GITHUB_OWNER = "mohamed-elkholy95" + +_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = { + _LEGACY_FEEDBACK_GITHUB_OWNER + "/Pythinker-Code", + _LEGACY_FEEDBACK_GITHUB_OWNER + "/pythinker-code", +} +_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = { + repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS +} + + +def normalize_feedback_github_repo(repo: str) -> str: + """Return the active feedback repo, migrating stale bundled defaults.""" + cleaned = repo.strip().strip("/") + if not cleaned: + return DEFAULT_FEEDBACK_GITHUB_REPO + if cleaned.lower() in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER: + return DEFAULT_FEEDBACK_GITHUB_REPO + return cleaned diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index af5f9617..47041177 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -238,12 +238,19 @@ def create_llm( case "openai_codex": from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponses + from pythinker_code.auth.openai import build_chatgpt_codex_headers + + default_headers = build_chatgpt_codex_headers( + account_id=oauth.get_chatgpt_account_id(provider.oauth) if oauth else None + ) + if provider.custom_headers: + default_headers.update(provider.custom_headers) chat_provider = OpenAIResponses( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, system_prompt_as_instructions=True, - default_headers=dict(provider.custom_headers) if provider.custom_headers else None, + default_headers=default_headers, http_client=rl_http_client, ) case "anthropic": diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index eb1daa0d..bb4bc746 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -4,6 +4,7 @@ import contextlib import re import shlex +import textwrap import time from collections import deque from collections.abc import Awaitable, Callable, Coroutine @@ -38,8 +39,13 @@ run_soul, ) from pythinker_code.soul.pythinkersoul import FLOW_COMMAND_PREFIX, PythinkerSoul -from pythinker_code.ui.shell.components.render_utils import render_message_response, sanitize_ansi -from pythinker_code.ui.shell.console import console +from pythinker_code.ui.shell.components.render_utils import ( + cell_width, + render_message_response, + sanitize_ansi, + truncate_to_width, +) +from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.echo import render_user_echo_text from pythinker_code.ui.shell.mcp_status import render_mcp_prompt from pythinker_code.ui.shell.prompt import ( @@ -56,10 +62,12 @@ from pythinker_code.ui.shell.update import ( consume_whats_new, pending_update_notice, - prompt_pre_start_update, refresh_update_cache_if_due, welcome_update_target, ) +from pythinker_code.ui.shell.update_orchestrator import ( + prompt_pre_start_update_job as prompt_pre_start_update, +) from pythinker_code.ui.shell.visualize import ( ApprovalPromptDelegate, visualize, @@ -1859,7 +1867,6 @@ def _cancel_background_tasks(self) -> None: _LOGO_FACE = "#F9F2F5" # face / chest interior (cream) _LOGO_CORAL = "#EE9983" # antenna ball, ears, accent bits _LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (light cyan) -_PYTHINKER_BORDER = "grey39" _LOGO = ( f" [{_LOGO_CORAL}]●[/]\n" @@ -1929,68 +1936,143 @@ def _welcome_banner_chip() -> Text | None: return None +_WELCOME_MAX_WIDTH = 100 +_WELCOME_LABEL_WIDTH = 10 +_WELCOME_PANEL_CHROME_WIDTH = 6 # border + horizontal padding used below + + +def _take_cells_left(text: str, max_width: int) -> str: + if max_width <= 0: + return "" + out: list[str] = [] + used = 0 + for char in text: + width = cell_width(char) + if used + width > max_width: + break + out.append(char) + used += width + return "".join(out) + + +def _take_cells_right(text: str, max_width: int) -> str: + if max_width <= 0: + return "" + out: list[str] = [] + used = 0 + for char in reversed(text): + width = cell_width(char) + if used + width > max_width: + break + out.append(char) + used += width + return "".join(reversed(out)) + + +def _truncate_middle_to_width(text: str, max_width: int) -> str: + """Cell-aware middle truncation for paths and UUID-like values.""" + if max_width <= 0: + return "" + cleaned = sanitize_ansi(text).replace("\r", " ").replace("\n", " ") + if cell_width(cleaned) <= max_width: + return cleaned + if max_width <= 1: + return truncate_to_width(cleaned, max_width) + left_width = max(1, (max_width - 1) // 2) + right_width = max(1, max_width - 1 - left_width) + return f"{_take_cells_left(cleaned, left_width)}…{_take_cells_right(cleaned, right_width)}" + + +def _welcome_panel_width() -> int: + columns = current_console_width(console, default=_WELCOME_MAX_WIDTH) + return max(1, min(columns, _WELCOME_MAX_WIDTH)) + + +def _welcome_value(label: str, value: str, max_width: int) -> str: + cleaned = sanitize_ansi(value).replace("\r", " ").replace("\n", " ") + if label.strip() in {"Directory", "Auto-save", "Session"}: + return _truncate_middle_to_width(cleaned, max_width) + return truncate_to_width(cleaned, max_width) + + +def _welcome_tip_lines(value: str, max_width: int) -> list[str]: + cleaned = sanitize_ansi(value).replace("\r", " ").replace("\n", " ").strip() + if not cleaned: + return [""] + lines = textwrap.wrap( + cleaned, + width=max(1, max_width), + break_long_words=False, + break_on_hyphens=False, + ) or [cleaned] + return [truncate_to_width(line, max_width) for line in lines] + + def _print_welcome_info( name: str, info_items: list[WelcomeInfoItem], *, banner: Text | None = None ) -> None: _t = _get_tui_tokens() - head = Text.from_markup("Welcome to Pythinker — think first, then code.") - help_text = Text.from_markup( - f"[{_t.muted}]Review · Secure · Diagnose · then Create. Send /help for help.[/]" + panel_width = _welcome_panel_width() + content_width = max(1, panel_width - _WELCOME_PANEL_CHROME_WIDTH) + + head = Text.from_markup("[bold]Welcome to Pythinker — think first, then code.[/]") + strapline = Text.from_markup( + f"[{_t.muted}]Review · Secure · Diagnose · Build with confidence.[/]" ) + help_text = Text.from_markup(f"[{_t.muted}]Type /help for commands.[/]") help_text.highlight_regex(r"/help\b", f"bold {_t.warning}") - # Use Table for precise width control - logo = Text.from_markup(_LOGO) - table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False) - table.add_column(justify="left") - if banner is not None: - # Chip at the top, head/help at the bottom, blank padding in between. - logo_lines = _LOGO.count("\n") + 1 # 5 for the current robot logo - pad = max(0, logo_lines - 3) # chip(1) + head(1) + help(1) = 3 fixed - right_cell: RenderableType = Group(banner, *([Text("")] * pad), head, help_text) - table.add_column(justify="left", vertical="top") + rows: list[RenderableType] = [] + if content_width >= 68: + # Logo on the left; the 3-line text block bottom-aligns against the 5-line + # robot so the antenna floats above and the lines sit beside the body. + logo = Text.from_markup(_LOGO) + table = Table.grid(padding=(0, 1)) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="left", vertical="bottom", no_wrap=True) + table.add_row(logo, Group(head, strapline, help_text)) + rows.append(table) else: - right_cell = Group(head, help_text) - table.add_column(justify="left", vertical="bottom") - table.add_row(logo, right_cell) - - rows: list[RenderableType] = [table] + rows.extend([head, strapline, help_text]) facts = [item for item in info_items if item.name.strip() != "Tip"] tips = [item for item in info_items if item.name.strip() == "Tip"] if facts: rows.append(Text("")) # empty line - info_table = Table( - show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False + label_width = min(_WELCOME_LABEL_WIDTH, max(4, content_width // 3)) + value_width = max(4, content_width - label_width - 2) + info_table = Table.grid(padding=(0, 1)) + info_table.add_column( + justify="right", + style=tui_rich_style("muted"), + no_wrap=True, + width=label_width, ) - info_table.add_column(justify="right", style=tui_rich_style("muted")) - info_table.add_column(justify="center", style=tui_rich_style("dim"), no_wrap=True) - info_table.add_column(justify="left") + info_table.add_column(justify="left", no_wrap=True, width=value_width) for item in facts: value_style = _value_style_for_label(item.name, item.level) - info_table.add_row(item.name, "│", Text(item.value, style=value_style)) + value = _welcome_value(item.name, item.value, value_width) + info_table.add_row(item.name, Text(value, style=value_style, no_wrap=True)) rows.append(info_table) if tips: rows.append(Text("")) # empty line rows.append(Text("Tips", style=tui_rich_style("muted"))) - # 2-col table → wrapped tip lines hang-indent under the text column, - # not under the bullet. - tips_table = Table( - show_header=False, show_edge=False, box=None, padding=(0, 0), expand=False - ) + tip_width = max(4, content_width - 4) + tips_table = Table.grid(padding=(0, 0)) tips_table.add_column(style=tui_rich_style("muted"), no_wrap=True, width=4) - tips_table.add_column(justify="left", overflow="fold") + tips_table.add_column(justify="left", no_wrap=True, width=tip_width) for item in tips: - tip_text = Text(item.value, style=item.level.value) - tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold") - tips_table.add_row(" • ", tip_text) + for index, line in enumerate(_welcome_tip_lines(item.value, tip_width)): + tip_text = Text(line, style=item.level.value, no_wrap=True) + tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold") + tips_table.add_row(" • " if index == 0 else " ", tip_text) rows.append(tips_table) version_title = Text.assemble( - ("Pythinker Code", _PYTHINKER_BORDER), - (f" v{get_version()}", "grey50"), + ("Pythinker Code", tui_rich_style("muted")), + (f" v{get_version()}", tui_rich_style("dim")), ) console.print( @@ -1998,9 +2080,12 @@ def _print_welcome_info( Group(*rows), title=version_title, title_align="left", - border_style=_PYTHINKER_BORDER, + subtitle=banner, + subtitle_align="right", + border_style=tui_rich_style("border"), box=box.ROUNDED, expand=False, + width=panel_width, padding=(1, 2), ) ) diff --git a/src/pythinker_code/ui/shell/components/bash_execution.py b/src/pythinker_code/ui/shell/components/bash_execution.py index 1cf1e34f..d823fb5d 100644 --- a/src/pythinker_code/ui/shell/components/bash_execution.py +++ b/src/pythinker_code/ui/shell/components/bash_execution.py @@ -22,7 +22,7 @@ sanitize_ansi, truncate_middle_to_visual_lines, ) -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import tui_rich_style @@ -147,7 +147,7 @@ def _status_header(state: BashExecutionState) -> Text: header = Text("✘ ", style=tui_rich_style("error")) header.append("Ran", style=tui_rich_style("error") + RichStyle(bold=True)) elif state.status == "cancelled": - header = Text("● ", style=tui_rich_style("warning")) + header = Text(f"{TRANSCRIPT_ACTIVE_MARKER} ", style=tui_rich_style("warning")) header.append("Cancelled", style=tui_rich_style("warning") + RichStyle(bold=True)) else: header = Text(f"{TRANSCRIPT_ASSISTANT_MARKER} ", style=tui_rich_style("success")) diff --git a/src/pythinker_code/ui/shell/components/bordered_loader.py b/src/pythinker_code/ui/shell/components/bordered_loader.py index 862c5a47..3ea7d18e 100644 --- a/src/pythinker_code/ui/shell/components/bordered_loader.py +++ b/src/pythinker_code/ui/shell/components/bordered_loader.py @@ -16,6 +16,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.dynamic_border import DynamicBorder +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.keymap import key_text from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import tui_rich_style @@ -47,7 +48,11 @@ def render_bordered_loader(state: BorderedLoaderState) -> RenderableType: """Build the bordered loader renderable for *state*.""" muted = tui_rich_style("muted") - glyph = "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + glyph = ( + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " + ) loading = Text(f"{glyph} ", style=muted) loading.append(state.message, style=muted) diff --git a/src/pythinker_code/ui/shell/components/tool_execution.py b/src/pythinker_code/ui/shell/components/tool_execution.py index ea324415..3d4edaf0 100644 --- a/src/pythinker_code/ui/shell/components/tool_execution.py +++ b/src/pythinker_code/ui/shell/components/tool_execution.py @@ -29,7 +29,7 @@ from pythinker_code.ui.shell.components.key_hints import key_hint from pythinker_code.ui.shell.components.render_utils import render_message_response -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import TINTED_CARD_PADDING from pythinker_code.ui.shell.tool_renderers import ( @@ -260,7 +260,7 @@ def _call_fallback(self) -> RenderableType: glyph = "✘ " glyph_style = tui_rich_style("error") + Style(bold=True) elif self._status == ToolExecutionStatus.CANCELLED: - glyph = "● " + glyph = f"{TRANSCRIPT_ACTIVE_MARKER} " glyph_style = tui_rich_style("warning") + Style(bold=True) else: active = reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 diff --git a/src/pythinker_code/ui/shell/design_system.py b/src/pythinker_code/ui/shell/design_system.py index d348d5d9..9e3f187b 100644 --- a/src/pythinker_code/ui/shell/design_system.py +++ b/src/pythinker_code/ui/shell/design_system.py @@ -10,6 +10,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.theme import tui_rich_style @@ -46,9 +47,9 @@ class ShellTone(StrEnum): } _STATUS: dict[StatusName, tuple[str, ShellTone]] = { - "running": ("●", ShellTone.ACCENT), + "running": (TRANSCRIPT_ACTIVE_MARKER, ShellTone.MUTED), "completed": ("✓", ShellTone.SUCCESS), - "failed": ("!", ShellTone.ERROR), + "failed": ("✘", ShellTone.ERROR), "denied": ("×", ShellTone.WARNING), "interrupted": ("■", ShellTone.MUTED), "waiting": ("○", ShellTone.MUTED), diff --git a/src/pythinker_code/ui/shell/glyphs.py b/src/pythinker_code/ui/shell/glyphs.py index 157040e8..781e1697 100644 --- a/src/pythinker_code/ui/shell/glyphs.py +++ b/src/pythinker_code/ui/shell/glyphs.py @@ -9,18 +9,29 @@ from typing import Final -#: Braille dotted spinner frames, rendered left-to-right per tick. -SPINNER_FRAMES: Final = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") -#: Solid dot pulse for the Composing / Thinking activity lines instead of the -#: dotted braille spinner. The blank frame keeps the label column stable while -#: making the dot appear and disappear. -SHAPE_FRAMES: Final = ("●", " ") +from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled + +_ASCII_GLYPHS = ascii_glyphs_enabled() + +#: Braille dotted spinner frames, rendered left-to-right per tick. ASCII mode is +#: available for legacy Windows code pages, ``TERM=dumb``, and users who set +#: ``PYTHINKER_TUI_GLYPHS=ascii``. +SPINNER_FRAMES: Final = ( + ("-", "\\", "|", "/") if _ASCII_GLYPHS else ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") +) +#: Text-safe pulse for the Composing / Thinking activity lines instead of the +#: dotted braille spinner. Use the filled text circle so active transcript, +#: tool, and subagent markers read at the same weight as the reference CLI. +#: ASCII mode keeps legacy Windows code pages, ``TERM=dumb``, and explicit safe +#: glyph requests on a plain star. The blank frame keeps the label column stable +#: while making the marker appear and disappear. +SHAPE_FRAMES: Final = ("*", " ") if _ASCII_GLYPHS else ("●", " ") #: Seconds each braille spinner frame stays on screen. SPINNER_FRAME_INTERVAL_S: Final = 0.08 -#: Seconds each solid-dot pulse frame stays on screen. +#: Seconds each text-safe pulse frame stays on screen. SHAPE_FRAME_INTERVAL_S: Final = 0.45 #: Static stand-in used when motion is disabled. -REDUCED_MOTION_GLYPH: Final = "●" +REDUCED_MOTION_GLYPH: Final = "*" if _ASCII_GLYPHS else "●" #: Braille dotted frames for the active task/status marker beside pinned todos. ACTIVE_MARKER_FRAMES: Final = SPINNER_FRAMES #: Seconds each active-marker braille frame stays on screen. @@ -29,16 +40,21 @@ STAR_SPINNER_FRAMES: Final = ACTIVE_MARKER_FRAMES STAR_SPINNER_FRAME_INTERVAL_S: Final = ACTIVE_MARKER_FRAME_INTERVAL_S -#: Transcript row marker for assistant/tool-call lines. -TRANSCRIPT_ASSISTANT_MARKER: Final = "⏺" +#: Transcript row marker for assistant/tool-call lines. U+23FA (record button) +#: renders as a blue emoji tile on some Windows terminals; keep this as a +#: monochrome text circle. +TRANSCRIPT_ASSISTANT_MARKER: Final = "*" if _ASCII_GLYPHS else "●" #: Transcript prompt marker for submitted user input. -TRANSCRIPT_PROMPT_MARKER: Final = "❯" +TRANSCRIPT_PROMPT_MARKER: Final = ">" if _ASCII_GLYPHS else "❯" #: Transcript marker for completed thinking/status timing rows. -TRANSCRIPT_STATUS_MARKER: Final = "✻" +TRANSCRIPT_STATUS_MARKER: Final = "*" if _ASCII_GLYPHS else "✻" #: Transcript marker for active task/status rows when motion is disabled. TRANSCRIPT_ACTIVE_MARKER: Final = REDUCED_MOTION_GLYPH #: Transcript gutter marker for tool results. -TRANSCRIPT_TOOL_GUTTER: Final = "⎿" +TRANSCRIPT_TOOL_GUTTER: Final = "|" if _ASCII_GLYPHS else "⎿" +#: List/detail bullet (U+2022) used in status panels; falls back to an asterisk +#: under ASCII mode so legacy code pages and ``TERM=dumb`` stay clean. +LIST_BULLET: Final = "*" if _ASCII_GLYPHS else "•" __all__ = [ "SPINNER_FRAMES", @@ -55,4 +71,5 @@ "TRANSCRIPT_STATUS_MARKER", "TRANSCRIPT_ACTIVE_MARKER", "TRANSCRIPT_TOOL_GUTTER", + "LIST_BULLET", ] diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index 25c09ba0..636d0f52 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -8,6 +8,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.glyphs import LIST_BULLET, TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot @@ -55,7 +56,7 @@ def mcp_startup_header(snapshot: MCPStatusSnapshot) -> str | None: def render_mcp_startup_text(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> Text: """Render the animated MCP startup status used by live prompt/status areas.""" t = time.monotonic() if now is None else now - glyph = "●" if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append( mcp_startup_header(snapshot) or "Starting MCP servers", @@ -77,14 +78,18 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: servers = sorted(snapshot.servers, key=lambda server: server.name) if not servers: - renderables.append(Text(" • No MCP servers configured.", style=Style(italic=True))) + renderables.append( + Text(f" {LIST_BULLET} No MCP servers configured.", style=Style(italic=True)) + ) renderables.append( Text(" See the MCP docs to configure them.", style=tui_rich_style("muted")) ) return Group(*renderables) if snapshot.tools == 0: - renderables.append(Text(" • No MCP tools available.", style=Style(italic=True))) + renderables.append( + Text(f" {LIST_BULLET} No MCP tools available.", style=Style(italic=True)) + ) renderables.append(Text("")) for server in servers: @@ -96,7 +101,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: def render_mcp_inventory_loading(*, now: float | None = None) -> RenderableType: t = time.monotonic() if now is None else now - glyph = "●" if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append("Loading MCP inventory", style=tui_rich_style("tool_title") + Style(bold=True)) line.append("…", style=tui_rich_style("muted")) @@ -107,22 +112,22 @@ def _server_inventory_lines(server: MCPServerSnapshot) -> list[RenderableType]: status = _safe_text(server.status) status_style = _status_color(server.status) server_name = _safe_text(server.name) - lines: list[RenderableType] = [Text.assemble(" • ", (server_name, status_style))] - lines.append(Text.assemble(" • Status: ", (status, status_style))) + lines: list[RenderableType] = [Text.assemble(f" {LIST_BULLET} ", (server_name, status_style))] + lines.append(Text.assemble(f" {LIST_BULLET} Status: ", (status, status_style))) if server.status == "unauthorized": lines.append( Text( - f" • Auth: Not authorized - run: pythinker mcp auth {server_name}", + f" {LIST_BULLET} Auth: Not authorized - run: pythinker mcp auth {server_name}", style=tui_rich_style("muted"), ) ) tool_names = sorted(_safe_text(tool_name) for tool_name in server.tools) if tool_names: - lines.append(Text.assemble(" • Tools: ", ", ".join(tool_names))) + lines.append(Text.assemble(f" {LIST_BULLET} Tools: ", ", ".join(tool_names))) else: - lines.append(Text(" • Tools: (none)")) + lines.append(Text(f" {LIST_BULLET} Tools: (none)")) return lines @@ -134,7 +139,8 @@ def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) colors = get_mcp_prompt_colors() t = time.monotonic() if now is None else now - prefix = f"{'●' if int(t / 0.8) % 2 == 0 else ' '} " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + prefix = f"{glyph} " return FormattedText([(colors.text, f"{prefix}{header}"), ("", "\n")]) diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index 1757dab1..c77df97f 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from dataclasses import dataclass from typing import Literal @@ -23,6 +22,7 @@ SPINNER_FRAMES, TRANSCRIPT_ACTIVE_MARKER, ) +from pythinker_code.ui.terminal_capabilities import colors_disabled, motion_disabled from pythinker_code.ui.theme import tui_rich_style from pythinker_code.utils.datetime import format_elapsed @@ -32,6 +32,8 @@ def verb_spinner_style() -> Style: """Muted yellow style for the active verb spinner word.""" + if colors_disabled(): + return Style() return Style(color=Color.parse("#E6B450")) # brand-exception: muted yellow verb shimmer @@ -48,6 +50,8 @@ def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> Reduced motion pins to the base muted yellow so the word stays calm. """ + if colors_disabled(): + return Style() if reduced_motion or reduced_motion_enabled(): return Style(color=Color.parse(_SHIMMER_BASE)) palette = (_SHIMMER_BASE, _SHIMMER_MID, _SHIMMER_HIGHLIGHT, _SHIMMER_MID) @@ -65,6 +69,8 @@ def _shimmer_segments( """ if not label: return [] + if colors_disabled(): + return [(None, label)] if reduced_motion or reduced_motion_enabled(): return [(_SHIMMER_BASE, label)] @@ -133,12 +139,7 @@ class ActivitySnapshot: def reduced_motion_enabled() -> bool: - return os.environ.get("PYTHINKER_REDUCED_MOTION", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } + return motion_disabled() def spinner_frame_at( @@ -197,7 +198,7 @@ def activity_status_line(snapshot: ActivitySnapshot, *, width: int | None = None glyph_style = thinking_style else: # The dotted braille spinner is a marker; keep it silver while the verb shimmers. - glyph_style = _SPINNER_SILVER_STYLE + glyph_style = Style() if colors_disabled() else _SPINNER_SILVER_STYLE if snapshot.label_style is not None: label_style = snapshot.label_style elif snapshot.spinner == "shape": diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index d0b63e47..5e943a01 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -60,7 +60,11 @@ from pythinker_code.tools.display import TodoDisplayItem from pythinker_code.ui.shell import placeholders as prompt_placeholders from pythinker_code.ui.shell.console import console -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_PROMPT_MARKER, TRANSCRIPT_TOOL_GUTTER +from pythinker_code.ui.shell.glyphs import ( + TRANSCRIPT_ACTIVE_MARKER, + TRANSCRIPT_PROMPT_MARKER, + TRANSCRIPT_TOOL_GUTTER, +) from pythinker_code.ui.shell.motion import shimmer_prompt_fragments from pythinker_code.ui.shell.placeholders import ( PromptPlaceholderManager, @@ -2610,7 +2614,7 @@ def _render_background_working_status( if total <= 0: return FormattedText([]) now = time.monotonic() - frame = "●" if int(now / 0.8) % 2 == 0 else " " + frame = TRANSCRIPT_ACTIVE_MARKER if int(now / 0.8) % 2 == 0 else " " noun = "process" if total == 1 else "processes" detail = f"{total} background {noun}" if counts.agent and counts.bash: @@ -3012,7 +3016,7 @@ def _render_bottom_toolbar(self) -> FormattedText: secondary_style = f"fg:{tokens.muted}" mode = str(self._mode) if self._mode == PromptMode.AGENT and self._model_name: - thinking_dot = "●" if self._thinking else "○" + thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" mode_full = f"{mode} ({self._model_name} {thinking_dot})" mode_mid = f"{mode} {thinking_dot}" if _display_width(mode_full) <= remaining - 2: @@ -3182,7 +3186,7 @@ def _append_right(style: str, text: str) -> None: ) _append_right(secondary_style, ctx_compact) if self._model_name: - thinking_dot = "●" if self._thinking else "○" + thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" mode = str(self._mode) _append_right(mode_style, f"{mode} {self._model_name} {thinking_dot}") right_text = " ".join(right_parts) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index dadd7082..f06e0a00 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -564,14 +564,20 @@ def _feedback_destination(soul: PythinkerSoul) -> tuple[str, dict[str, str]] | N return None provider = soul.runtime.config.providers.get(managed_provider_key(PYTHINKER_CODE_PLATFORM_ID)) - if provider is not None: - if provider.custom_headers: - headers.update(provider.custom_headers) - api_key = provider.api_key.get_secret_value() - if provider.oauth is not None: - api_key = soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) - if api_key: - headers["Authorization"] = f"Bearer {api_key}" + if provider is None: + return None + if provider.custom_headers: + headers.update(provider.custom_headers) + provider_api_key = getattr(provider, "api_key", None) + if provider_api_key is None: + return None + api_key = provider_api_key.get_secret_value() + if provider.oauth is not None: + api_key = soul.runtime.oauth.resolve_api_key(provider_api_key, provider.oauth) + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + if not any(key.lower() == "authorization" and value for key, value in headers.items()): + return None return f"{pythinker_platform.base_url.rstrip('/')}/feedback", headers @@ -1622,8 +1628,14 @@ async def update_command(app: Shell, args: str): """Check for and optionally install the latest Pythinker version.""" _ = args, app from pythinker_code.ui.shell.update import UpdateResult, run_update_prompt + from pythinker_code.ui.shell.update_orchestrator import run_update_job + + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: + return await run_update_job( + print_output=print_output, check_only=check_only, source="slash" + ) - result = await run_update_prompt() + result = await run_update_prompt(update_runner=_runner) if result is UpdateResult.UPDATED: console.print("Updated — restart Pythinker to use the new version.") diff --git a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py index 6298c5f6..b32eefb0 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py +++ b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py @@ -70,6 +70,10 @@ def tool_title(label: str) -> Text: return Text(label, style=base + RichStyle(bold=True)) +def _status_marker(style_token: str) -> str: + return "✘" if style_token == "error" else TRANSCRIPT_ASSISTANT_MARKER + + def tool_call_header( name: str, summary: str | Text | None = None, @@ -79,11 +83,12 @@ def tool_call_header( ) -> Text: """Return the Blackbox/Claude-style tool-use row. - Shape: ``• Tool(summary)``. The surrounding ``ToolExecutionComponent`` - owns result gutters; individual renderers should keep this row compact. + Shape: ``● Tool(summary)`` for completed rows, ``✘ Tool(summary)`` for + failed rows. The surrounding ``ToolExecutionComponent`` owns result + gutters; individual renderers should keep this row compact. """ header = Text() - header.append(f"{TRANSCRIPT_ASSISTANT_MARKER} ", style=tui_rich_style(style_token)) + header.append(f"{_status_marker(style_token)} ", style=tui_rich_style(style_token)) header.append_text(tool_title(name)) if summary is not None: paren_style = tui_rich_style(paren_style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index 8d4ccf86..f7c2e2ff 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -60,7 +60,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: if resume: summary.append_text(fg(secondary_token, f" · resume {resume[:8]}")) - style_token = "error" if ctx.is_error else "success" if ctx.has_result else secondary_token + style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header( "Agent", summary, style_token=style_token, paren_style_token=secondary_token ) @@ -78,7 +78,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token=secondary_token, + marker_style_token="muted", ) rendered = Group(header, *missing) if missing else header @@ -86,7 +86,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token=secondary_token, + marker_style_token="muted", ) @@ -154,7 +154,7 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: header, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token="thinking_text", + marker_style_token="muted", ) else: summary_text.append_text(fg("border_accent", _plural(len(agent_summaries), "agent"))) @@ -191,7 +191,7 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token="thinking_text", + marker_style_token="muted", ) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 74d23641..3493cdcb 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -9,8 +9,9 @@ import subprocess import sys import tarfile +import threading import time -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from enum import Enum, auto from pathlib import Path from shutil import which @@ -54,6 +55,7 @@ AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0 WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 +UPGRADE_COMMAND_TIMEOUT_SECONDS = 30 * 60 _UPDATE_LOCK = asyncio.Lock() _skipped_version_this_session: str | None = None @@ -76,6 +78,9 @@ class UpdatePromptSelection(Enum): EXIT = auto() +type UpdateRunner = Callable[..., Awaitable[UpdateResult]] + + def semver_tuple(version: str) -> tuple[int, int, int]: v = version.strip() if v.startswith("v"): @@ -280,7 +285,7 @@ def _mark_auto_update_check_attempt() -> None: logger.exception("Failed to write last update-check timestamp:") -async def prompt_pre_start_update() -> None: +async def prompt_pre_start_update(update_runner: UpdateRunner | None = None) -> None: """pythinker-x-style blocking update prompt for the interactive shell. Runs once at startup, before the agent loop. When a newer native release @@ -314,7 +319,10 @@ async def prompt_pre_start_update() -> None: _skip_version_this_session(latest_version) return - result = await do_update(print=True) + if update_runner is None: + result = await do_update(print_output=True) + else: + result = await update_runner(print_output=True, check_only=False) if result is UpdateResult.UPDATED: # do_update() already printed "Updated successfully!" + the relaunch # hint. Wait for the user to acknowledge before exiting so the message @@ -474,7 +482,7 @@ async def _refresh_update_cache(*, force: bool) -> UpdateResult | None: if not force and not _should_auto_check_for_updates(): return None try: - result = await do_update(print=False, check_only=True) + result = await do_update(print_output=False, check_only=True) except Exception: logger.exception("Update cache refresh failed:") return None @@ -536,7 +544,7 @@ def pending_update_notice() -> str | None: return f"Update available: {current_version} → {cached}. Run /update to install." -async def run_update_prompt() -> UpdateResult | None: +async def run_update_prompt(update_runner: UpdateRunner | None = None) -> UpdateResult | None: """Interactive ``/update`` flow: refresh, show the 3-choice modal, install. In-shell safe — unlike ``prompt_pre_start_update`` it does not block on raw @@ -546,7 +554,10 @@ async def run_update_prompt() -> UpdateResult | None: """ from pythinker_code.constant import VERSION as current_version - refresh_result = await do_update(print=True, check_only=True) + if update_runner is None: + refresh_result = await do_update(print_output=True, check_only=True) + else: + refresh_result = await update_runner(print_output=True, check_only=True) if refresh_result is UpdateResult.UP_TO_DATE: return UpdateResult.UP_TO_DATE if refresh_result is UpdateResult.FAILED: @@ -567,7 +578,9 @@ async def run_update_prompt() -> UpdateResult | None: if selection is not UpdatePromptSelection.UPDATE_NOW: _skip_version_this_session(latest_version) return None - return await do_update(print=True) + if update_runner is None: + return await do_update(print_output=True) + return await update_runner(print_output=True, check_only=False) async def _prompt_update_selection( @@ -1104,18 +1117,80 @@ async def _maybe_run_native_update(latest_version: str, channel: str = "latest") shutil.rmtree(tmpdir, ignore_errors=True) -async def do_update(*, print: bool = True, check_only: bool = False) -> UpdateResult: +def _run_upgrade_command( + command: list[str], + *, + print_output: bool, + output_callback: Callable[[str], None] | None, +) -> int: + def _emit(text: str) -> None: + if output_callback is not None: + output_callback(text) + if print_output: + console.print(text, markup=False) + + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=get_clean_env(), + bufsize=1, + ) + + def _drain_stdout() -> None: + if proc.stdout is None: + return + for line in proc.stdout: + _emit(line.rstrip("\n")) + + output_thread = threading.Thread(target=_drain_stdout, daemon=True) + output_thread.start() + try: + return proc.wait(timeout=UPGRADE_COMMAND_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + _emit(f"Upgrade command timed out after {UPGRADE_COMMAND_TIMEOUT_SECONDS} seconds.") + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return 124 + finally: + output_thread.join(timeout=5) + + +async def do_update( + *, + print_output: bool = True, + check_only: bool = False, + output_callback: Callable[[str], None] | None = None, +) -> UpdateResult: async with _UPDATE_LOCK: - return await _do_update(print=print, check_only=check_only) + return await _do_update( + print_output=print_output, + check_only=check_only, + output_callback=output_callback, + ) -async def _do_update(*, print: bool, check_only: bool) -> UpdateResult: +async def _do_update( + *, + print_output: bool, + check_only: bool, + output_callback: Callable[[str], None] | None, +) -> UpdateResult: from pythinker_code.constant import VERSION as current_version _t = _get_tui_tokens() def _print(message: str) -> None: - if print: + if output_callback is not None: + output_callback(message) + if print_output: console.print(message) timeout = aiohttp.ClientTimeout(total=15, sock_connect=5, sock_read=10) @@ -1218,14 +1293,18 @@ def _print(message: str) -> None: sys.exit(0) try: - result = subprocess.run(upgrade_command) + returncode = _run_upgrade_command( + upgrade_command, + print_output=print_output, + output_callback=output_callback, + ) except OSError as e: logger.exception("Upgrade failed:") _print(f"[{_t.error}]Upgrade failed:[/] {e}") _print(f"Please run manually: {upgrade_command_text}") return UpdateResult.FAILED - if result.returncode == 0: + if returncode == 0: _print(f"[{_t.success}]Updated successfully![/]") _print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]") return UpdateResult.UPDATED diff --git a/src/pythinker_code/ui/shell/update_orchestrator.py b/src/pythinker_code/ui/shell/update_orchestrator.py new file mode 100644 index 00000000..cbf928fc --- /dev/null +++ b/src/pythinker_code/ui/shell/update_orchestrator.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +import time +import uuid +from dataclasses import asdict, dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from pythinker_code.constant import VERSION as CURRENT_VERSION +from pythinker_code.native import is_native_build +from pythinker_code.share import get_share_dir +from pythinker_code.ui.shell.console import console +from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens +from pythinker_code.utils.logging import logger +from pythinker_code.utils.subprocess_env import get_clean_env + +if TYPE_CHECKING: + from pythinker_code.ui.shell.update import UpdateResult + +UPDATE_STATUS_FILE = get_share_dir() / "update_status.json" +UPDATE_LOG_FILE = get_share_dir() / "update.log" +UPDATE_LOCK_FILE = get_share_dir() / "update.lock" +UPDATE_LAST_SUCCESS_FILE = get_share_dir() / "update_last_success.json" + +_LOCK_STALE_AFTER_SECONDS = 2 * 60 * 60 +_LOCK_MALFORMED_GRACE_SECONDS = 60 +_SMOKE_CHECK_TIMEOUT_SECONDS = 10 + + +class UpdateJobState(StrEnum): + IDLE = "idle" + CHECKING = "checking" + AVAILABLE = "available" + RUNNING = "running" + UPDATED = "updated" + UP_TO_DATE = "up_to_date" + FAILED = "failed" + UNSUPPORTED = "unsupported" + BLOCKED = "blocked" + + +@dataclass(slots=True) +class UpdateJobStatus: + job_id: str + state: UpdateJobState + started_at: float | None + finished_at: float | None + current_version: str | None + target_version: str | None + result: str | None + message: str | None + log_path: str + pid: int | None + source: str = "unknown" + + +@dataclass(slots=True) +class UpdateLock: + path: Path + + def release(self) -> None: + with contextlib.suppress(OSError): + self.path.unlink() + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _chmod_owner_only(path: Path) -> None: + with contextlib.suppress(OSError): + os.chmod(path, 0o600) + + +def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + _ensure_parent(path) + tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + fd: int | None = None + try: + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fd = None + json.dump(payload, fh, indent=2, sort_keys=True) + os.replace(tmp, path) + _chmod_owner_only(path) + except BaseException: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + with contextlib.suppress(OSError): + tmp.unlink() + raise + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + payload: object = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return cast(dict[str, Any], payload) if isinstance(payload, dict) else None + + +def _pid_exists(pid: int) -> bool: + if pid <= 0: + return False + if pid == os.getpid(): + return True + if os.name == "nt": + # Windows has no POSIX signal-0 probe; avoid os.kill() because it can + # deliver a real signal. Let lock age decide staleness on Windows. + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + # Be conservative on platforms where signal-0 probing is limited. + return True + return True + + +def _lock_payload_is_stale(payload: dict[str, Any] | None, now: float | None = None) -> bool: + now = time.time() if now is None else now + if payload is None: + return False + started_at = _optional_float(payload.get("started_at")) + age = now - started_at if started_at is not None else _LOCK_STALE_AFTER_SECONDS + 1 + if age > _LOCK_STALE_AFTER_SECONDS: + return True + parsed_pid = _optional_int(payload.get("pid")) + if parsed_pid is None: + return age > 60 + return not _pid_exists(parsed_pid) + + +def _lock_file_age_seconds(*, now: float | None = None) -> float | None: + now = time.time() if now is None else now + try: + return now - UPDATE_LOCK_FILE.stat().st_mtime + except OSError: + return None + + +def clear_stale_update_lock() -> bool: + if not UPDATE_LOCK_FILE.exists(): + return False + payload = _read_json(UPDATE_LOCK_FILE) + if payload is None: + age = _lock_file_age_seconds() + if age is None or age <= _LOCK_MALFORMED_GRACE_SECONDS: + return False + elif not _lock_payload_is_stale(payload): + return False + with contextlib.suppress(OSError): + UPDATE_LOCK_FILE.unlink() + return True + return False + + +def acquire_update_lock(*, source: str = "unknown") -> UpdateLock | None: + _ensure_parent(UPDATE_LOCK_FILE) + payload = { + "pid": os.getpid(), + "source": source, + "started_at": time.time(), + } + encoded = json.dumps(payload, sort_keys=True).encode("utf-8") + for _ in range(2): + try: + fd = os.open(UPDATE_LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + if clear_stale_update_lock(): + continue + return None + try: + with os.fdopen(fd, "wb") as fh: + fh.write(encoded) + except OSError: + with contextlib.suppress(OSError): + os.close(fd) + UPDATE_LOCK_FILE.unlink() + raise + return UpdateLock(UPDATE_LOCK_FILE) + return None + + +def write_update_status(status: UpdateJobStatus) -> None: + payload = asdict(status) + payload["state"] = status.state.value + _atomic_write_json(UPDATE_STATUS_FILE, payload) + + +def read_update_status() -> UpdateJobStatus | None: + payload = _read_json(UPDATE_STATUS_FILE) + if payload is None: + return None + try: + state = UpdateJobState(str(payload.get("state") or UpdateJobState.IDLE.value)) + except ValueError: + state = UpdateJobState.FAILED + return UpdateJobStatus( + job_id=str(payload.get("job_id") or ""), + state=state, + started_at=_optional_float(payload.get("started_at")), + finished_at=_optional_float(payload.get("finished_at")), + current_version=_optional_str(payload.get("current_version")), + target_version=_optional_str(payload.get("target_version")), + result=_optional_str(payload.get("result")), + message=_optional_str(payload.get("message")), + log_path=str(payload.get("log_path") or UPDATE_LOG_FILE), + pid=_optional_int(payload.get("pid")), + source=str(payload.get("source") or "unknown"), + ) + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _optional_float(value: object) -> float | None: + if not isinstance(value, int | float | str): + return None + try: + return float(value) + except ValueError: + return None + + +def _optional_int(value: object) -> int | None: + if not isinstance(value, int | float | str): + return None + try: + return int(value) + except ValueError: + return None + + +def append_update_log(message: str) -> None: + _ensure_parent(UPDATE_LOG_FILE) + try: + from pythinker_code.feedback import redact_text + + fd: int | None = os.open( + UPDATE_LOG_FILE, + os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o600, + ) + try: + with os.fdopen(fd, "a", encoding="utf-8") as fh: + fd = None + fh.write(redact_text(message).rstrip("\n") + "\n") + finally: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + _chmod_owner_only(UPDATE_LOG_FILE) + except OSError: + logger.exception("Failed to append update log:") + + +def read_update_log_tail(max_lines: int = 80) -> list[str]: + if max_lines <= 0: + return [] + try: + lines = UPDATE_LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + return lines[-max_lines:] + + +def _result_state(result: object) -> UpdateJobState: + from pythinker_code.ui.shell.update import UpdateResult + + if result is UpdateResult.UPDATE_AVAILABLE: + return UpdateJobState.AVAILABLE + if result is UpdateResult.UPDATED: + return UpdateJobState.UPDATED + if result is UpdateResult.UP_TO_DATE: + return UpdateJobState.UP_TO_DATE + if result is UpdateResult.UNSUPPORTED: + return UpdateJobState.UNSUPPORTED + return UpdateJobState.FAILED + + +def _read_target_version() -> str | None: + from pythinker_code.ui.shell.update import LATEST_VERSION_FILE + + try: + return LATEST_VERSION_FILE.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def _new_status( + *, + job_id: str, + state: UpdateJobState, + source: str, + started_at: float | None, + finished_at: float | None = None, + result: str | None = None, + message: str | None = None, +) -> UpdateJobStatus: + return UpdateJobStatus( + job_id=job_id, + state=state, + started_at=started_at, + finished_at=finished_at, + current_version=CURRENT_VERSION, + target_version=_read_target_version(), + result=result, + message=message, + log_path=str(UPDATE_LOG_FILE), + pid=os.getpid(), + source=source, + ) + + +async def run_update_job( + *, + print_output: bool = True, + check_only: bool = False, + source: str = "cli", +) -> UpdateResult: + from pythinker_code.ui.shell.update import UpdateResult, do_update + + lock = acquire_update_lock(source=source) + if lock is None: + message = "Another Pythinker update is already running." + job_id = f"blocked-{uuid.uuid4().hex}" + append_update_log(f"{job_id}: {message}") + if print_output: + console.print(f"[{_get_tui_tokens().warning}]{message}[/]") + return UpdateResult.FAILED + + job_id = uuid.uuid4().hex + started_at = time.time() + state = UpdateJobState.CHECKING if check_only else UpdateJobState.RUNNING + append_update_log(f"\n=== pythinker update {job_id} started ({source}) ===") + write_update_status( + _new_status(job_id=job_id, state=state, source=source, started_at=started_at) + ) + + try: + try: + result = await do_update( + print_output=print_output, + check_only=check_only, + output_callback=append_update_log, + ) + except SystemExit: + message = "Update helper was launched; Pythinker is exiting to finish the update." + append_update_log(message) + write_update_status( + _new_status( + job_id=job_id, + state=UpdateJobState.RUNNING, + source=source, + started_at=started_at, + finished_at=None, + message=message, + ) + ) + raise + + reported_result = result + final_state = _result_state(result) + message = result.name.replace("_", " ").lower() + if result is UpdateResult.UPDATED and not check_only: + smoke_ok, smoke_message = run_post_install_smoke_check() + append_update_log(smoke_message) + if smoke_ok: + message = smoke_message + _write_last_success(job_id=job_id, message=message) + else: + message = f"Updated, but smoke check did not pass: {smoke_message}" + + write_update_status( + _new_status( + job_id=job_id, + state=final_state, + source=source, + started_at=started_at, + finished_at=time.time(), + result=reported_result.name, + message=message, + ) + ) + return reported_result + except Exception as exc: + message = f"Update failed: {exc}" + append_update_log(message) + write_update_status( + _new_status( + job_id=job_id, + state=UpdateJobState.FAILED, + source=source, + started_at=started_at, + finished_at=time.time(), + result=UpdateResult.FAILED.name, + message=message, + ) + ) + raise + finally: + lock.release() + + +def _write_last_success(*, job_id: str, message: str) -> None: + try: + _atomic_write_json( + UPDATE_LAST_SUCCESS_FILE, + { + "job_id": job_id, + "finished_at": time.time(), + "message": message, + }, + ) + except OSError: + logger.exception("Failed to write last successful update marker:") + + +def _smoke_check_command() -> list[str]: + if is_native_build(): + return [sys.executable, "--version"] + return [sys.executable, "-P", "-m", "pythinker_code", "--version"] + + +def _smoke_check_cwd() -> Path: + try: + return Path(sys.executable).resolve().parent + except OSError: + return Path.home() + + +def _smoke_check_env() -> dict[str, str]: + env = get_clean_env() + env["PYTHONSAFEPATH"] = "1" + env.pop("PYTHONPATH", None) + return env + + +def run_post_install_smoke_check() -> tuple[bool, str]: + command = _smoke_check_command() + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_SMOKE_CHECK_TIMEOUT_SECONDS, + env=_smoke_check_env(), + cwd=_smoke_check_cwd(), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Smoke check could not run: {exc}" + + output = ((result.stdout or "") + (result.stderr or "")).strip() + if result.returncode != 0: + detail = output.splitlines()[0] if output else f"exit code {result.returncode}" + return False, f"Smoke check failed: {detail}" + if not output or not any(ch.isdigit() for ch in output): + return False, "Smoke check did not report a version." + return True, f"Smoke check passed: {output.splitlines()[0]}" + + +async def prompt_pre_start_update_job() -> None: + from pythinker_code.ui.shell.update import prompt_pre_start_update + + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: + return await run_update_job( + print_output=print_output, check_only=check_only, source="startup" + ) + + await prompt_pre_start_update(update_runner=_runner) diff --git a/src/pythinker_code/ui/shell/visualize/_activity_tree.py b/src/pythinker_code/ui/shell/visualize/_activity_tree.py index 39e7ad7a..72d0744c 100644 --- a/src/pythinker_code/ui/shell/visualize/_activity_tree.py +++ b/src/pythinker_code/ui/shell/visualize/_activity_tree.py @@ -11,7 +11,7 @@ from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width from pythinker_code.ui.shell.design_system import ShellTone, shell_style, status_icon -from pythinker_code.ui.shell.motion import shimmer_text +from pythinker_code.ui.shell.motion import reduced_motion_enabled, shimmer_text ActivityState = Literal["running", "completed", "failed", "waiting", "denied", "interrupted"] @@ -24,9 +24,19 @@ class ActivityRow: identity: str | None = None +def _row_marker(state: ActivityState, now: float) -> Text: + """Status marker for an activity row, pulsing the running dot in step with + the rest of the shell (`loading_marker`/`_worklog`) on a 0.8s cadence.""" + icon = status_icon(state) + if state == "running" and not reduced_motion_enabled() and int(now / 0.8) % 2 != 0: + return Text(" ", style=icon.style) + return icon + + def render_activity_tree( - rows: list[ActivityRow], *, width: int, max_rows: int = 4 + rows: list[ActivityRow], *, width: int, max_rows: int = 4, now: float | None = None ) -> RenderableType: + clock = time.monotonic() if now is None else now rendered: list[RenderableType] = [] visible = rows[-max_rows:] hidden = max(0, len(rows) - len(visible)) @@ -36,12 +46,12 @@ def render_activity_tree( prefix = f"{branch} {label} " available = max(1, width - cell_width(prefix) - 4) text = Text() - text.append_text(status_icon(row.state)) + text.append_text(_row_marker(row.state, clock)) text.append(" ") text.append(prefix, style=shell_style(ShellTone.MUTED)) detail = truncate_to_width(row.detail, available) if row.state == "running": - text.append_text(shimmer_text(detail, time.monotonic())) + text.append_text(shimmer_text(detail, clock)) else: text.append(detail, style=shell_style(ShellTone.MUTED)) rendered.append(text) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 3fac8375..314708eb 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -10,7 +10,7 @@ import json import random import time -from collections import deque +from collections import Counter, deque from typing import Any, NamedTuple, cast import streamingjson # type: ignore[reportMissingTypeStubs] @@ -20,6 +20,7 @@ from pythinker_code.soul import format_context_status, format_token_count from pythinker_code.tools import extract_key_argument +from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.components import ToolExecutionComponent from pythinker_code.ui.shell.components.markdown import ( PythinkerMarkdown as Markdown, @@ -54,6 +55,8 @@ from pythinker_code.utils.datetime import format_elapsed from pythinker_code.utils.rich.columns import BulletColumns from pythinker_code.wire.types import ( + HookResolved, + HookTriggered, MCPStatusSnapshot, Notification, ProgressNote, @@ -71,12 +74,24 @@ MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4 _MAX_RUNNING_ROWS = 2 _MAX_SUB_OUTPUT_CHARS = 200 +_MAX_SUBAGENT_ROLLUP_TOOLS = 6 +_MAX_SUBAGENT_CHANGED_FILES = 5 # Background-agent statuses that mean "still running" — the tool call result # has arrived but the spawned agent has not yet finished. Blocks with this # status must stay in the Live area so their spinner keeps animating. _AGENT_ACTIVE_STATUSES = frozenset({"created", "starting", "running", "awaiting_approval"}) _TODO_TOOL_NAMES = frozenset({"SetTodoList", "TodoWrite"}) +_MUTATING_TOOL_NAMES = frozenset( + { + "applypatch", + "edit", + "replace", + "strreplacefile", + "write", + "writefile", + } +) def _is_active_background_agent(tool_name: str, result_text: str) -> bool: @@ -371,6 +386,9 @@ def __init__(self, tool_call: ToolCall): self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {} self._last_subagent_tool_call: ToolCall | None = None self._n_finished_subagent_tool_calls = 0 + self._finished_subagent_tool_counts: Counter[str] = Counter() + self._subagent_changed_files: list[str] = [] + self._subagent_changed_file_set: set[str] = set() self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall]( maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW ) @@ -499,6 +517,7 @@ def finish_sub_tool_call(self, tool_result: ToolResult): self._subagent_output_parts.pop(tool_result.tool_call_id, None) self._subagent_output_had_stderr.pop(tool_result.tool_call_id, None) self._subagent_execution_started.discard(tool_result.tool_call_id) + self._record_finished_subagent_call(sub_tool_call, tool_result.return_value) self._finished_subagent_tool_calls.append( _ToolCallBlock.FinishedSubCall( @@ -540,16 +559,89 @@ def append_sub_output_part( self._subagent_output_parts[tool_call_id] = [combined[-_MAX_SUB_OUTPUT_CHARS:]] self._renderable = self._compose() - def _subagent_activity_children(self, style_label: str) -> list[RenderableType]: + def _record_finished_subagent_call( + self, sub_tool_call: ToolCall, result: ToolReturnValue + ) -> None: + self._finished_subagent_tool_counts[sub_tool_call.function.name] += 1 + for path in self._changed_paths_from_sub_call(sub_tool_call, result): + if path in self._subagent_changed_file_set: + continue + self._subagent_changed_file_set.add(path) + self._subagent_changed_files.append(path) + + def _changed_paths_from_sub_call( + self, sub_tool_call: ToolCall, result: ToolReturnValue + ) -> list[str]: + paths: list[str] = [ + block.path + for block in getattr(result, "display", []) or [] + if isinstance(block, DiffDisplayBlock) + ] + if paths: + return paths + + if sub_tool_call.function.name.lower() not in _MUTATING_TOOL_NAMES: + return [] + try: + args = json.loads(sub_tool_call.function.arguments or "{}", strict=False) + except json.JSONDecodeError: + return [] + if not isinstance(args, dict): + return [] + parsed_args = cast(dict[str, Any], args) + raw_path = parsed_args.get("path") or parsed_args.get("file_path") + return [str(raw_path)] if raw_path else [] + + def _subagent_rollup_children(self) -> list[RenderableType]: + children: list[RenderableType] = [] + if self._finished_subagent_tool_counts: + parts: list[str] = [] + for tool_name, count in self._finished_subagent_tool_counts.most_common( + _MAX_SUBAGENT_ROLLUP_TOOLS + ): + label = tool_style(tool_name).label + parts.append(f"{label} ×{count}" if count > 1 else label) + hidden = len(self._finished_subagent_tool_counts) - len(parts) + if hidden > 0: + parts.append(f"+{hidden} more") + children.append( + BulletColumns( + Text("tools: " + ", ".join(parts), style=tui_rich_style("muted")), + bullet_style=tui_rich_style("muted"), + ) + ) + if self._subagent_changed_files: + shown = self._subagent_changed_files[:_MAX_SUBAGENT_CHANGED_FILES] + suffix = "" + hidden = len(self._subagent_changed_files) - len(shown) + if hidden > 0: + suffix = f", +{hidden} more" + children.append( + BulletColumns( + Text( + "changed: " + ", ".join(shown) + suffix, + style=tui_rich_style("muted"), + ), + bullet_style=tui_rich_style("muted"), + ) + ) + return children + + def _subagent_activity_children( + self, style_label: str, *, include_completed_subagent: bool = False + ) -> list[RenderableType]: children: list[RenderableType] = [] - if not (style_label == "Subagent" and self._result is not None): + should_show_activity = include_completed_subagent or not ( + style_label == "Subagent" and self._result is not None + ) + if should_show_activity: # Finished sub-tool call rows rows: list[ActivityRow] = [] for sub_call, sub_result in self._finished_subagent_tool_calls: argument = extract_key_argument( sub_call.function.arguments or "", sub_call.function.name ) - detail = sub_call.function.name + detail = tool_style(sub_call.function.name).label if argument: detail = f"{detail} {argument}" rows.append( @@ -567,7 +659,7 @@ def _subagent_activity_children(self, style_label: str) -> list[RenderableType]: running_rows: list[ActivityRow] = [] for call in visible_running: argument = extract_key_argument(call.function.arguments or "", call.function.name) - detail = call.function.name + detail = tool_style(call.function.name).label if argument: detail = f"{detail} {argument}" state = "running" if call.id in self._subagent_execution_started else "waiting" @@ -642,6 +734,7 @@ def _compose(self) -> RenderableType: style=tui_rich_style("muted"), ) children.append(BulletColumns(summary, bullet_style=tui_rich_style("muted"))) + children.extend(self._subagent_rollup_children()) elif self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW: n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW children.append( @@ -653,7 +746,12 @@ def _compose(self) -> RenderableType: bullet_style=tui_rich_style("muted"), ) ) - children.extend(self._subagent_activity_children(style.label)) + children.extend( + self._subagent_activity_children( + style.label, + include_completed_subagent=style.label == "Subagent" and self._result is not None, + ) + ) if self._result is None: streamed_output = self._streamed_output_text() @@ -750,7 +848,16 @@ def _compose_card(self) -> RenderableType | None: is_partial=True, ) card_rendered = self._tui_card.render() - activity_children = self._subagent_activity_children(tool_style(self._tool_name).label) + style_label = tool_style(self._tool_name).label + activity_children: list[RenderableType] = [] + if style_label == "Subagent" and self._result is not None: + activity_children.extend(self._subagent_rollup_children()) + activity_children.extend( + self._subagent_activity_children( + style_label, + include_completed_subagent=style_label == "Subagent" and self._result is not None, + ) + ) if activity_children: return Group(card_rendered, *activity_children) return card_rendered @@ -861,6 +968,48 @@ def compose(self) -> RenderableType: return BulletColumns(Group(*lines), bullet_style=style) +class _HookBlock: + """Compact lifecycle row for configured hooks around prompts and tools.""" + + def __init__(self, triggered: HookTriggered) -> None: + self.event = triggered.event + self.target = triggered.target + self.hook_count = triggered.hook_count + self.resolved: HookResolved | None = None + + def resolve(self, resolved: HookResolved) -> None: + self.resolved = resolved + self.event = resolved.event + self.target = resolved.target + + @property + def finished(self) -> bool: + return self.resolved is not None + + def compose(self) -> RenderableType: + target = self.event if not self.target else f"{self.event} {self.target}" + detail_parts: list[str] = [] + if self.hook_count > 1: + detail_parts.append(f"{self.hook_count} hooks") + if self.resolved is None: + state = WorkLogState.RUNNING + elif self.resolved.action == "block": + state = WorkLogState.FAILED + reason = " ".join(self.resolved.reason.split()) + if reason: + detail_parts.append(reason[:120] + ("…" if len(reason) > 120 else "")) + else: + state = WorkLogState.COMPLETED + if self.resolved is not None and self.resolved.duration_ms: + detail_parts.append(f"{self.resolved.duration_ms}ms") + return render_worklog_entry( + label="Hook", + target=target, + state=state, + detail=" · ".join(detail_parts) if detail_parts else None, + ) + + class _QuestionAnsweredBlock: """Compact transcript row for answers returned from AskUserQuestion.""" diff --git a/src/pythinker_code/ui/shell/visualize/_btw_panel.py b/src/pythinker_code/ui/shell/visualize/_btw_panel.py index 1fbdf2d7..6115a942 100644 --- a/src/pythinker_code/ui/shell/visualize/_btw_panel.py +++ b/src/pythinker_code/ui/shell/visualize/_btw_panel.py @@ -24,6 +24,7 @@ from rich.text import Text from pythinker_code.ui.shell.console import render_to_ansi +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import DIALOG_PANEL_PADDING, blank_row from pythinker_code.ui.shell.visualize._blocks import Markdown @@ -101,7 +102,11 @@ def set_result(self, response: str | None, error: str | None) -> None: # -- Title --------------------------------------------------------------- def _loading_marker(self) -> Text: - glyph = "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + glyph = ( + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " + ) return Text(glyph, style=tui_rich_style("muted")) def _build_title(self) -> str: diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 58856aa8..2300204b 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -30,7 +30,7 @@ from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.echo import render_user_echo -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_TOOL_GUTTER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_TOOL_GUTTER from pythinker_code.ui.shell.keyboard import KeyboardListener, KeyEvent from pythinker_code.ui.shell.mcp_status import render_mcp_startup_text from pythinker_code.ui.shell.motion import ( @@ -50,6 +50,7 @@ Markdown, _CompactionBlock, _ContentBlock, + _HookBlock, _NotificationBlock, _ProgressNoteBlock, _QuestionAnsweredBlock, @@ -75,6 +76,8 @@ CompactionBegin, CompactionEnd, ContentPart, + HookResolved, + HookTriggered, MCPLoadingBegin, MCPLoadingEnd, Notification, @@ -211,6 +214,7 @@ def __init__( self._current_question_panel: QuestionRequestPanel | None = None self._notification_blocks = deque[_NotificationBlock]() self._live_notification_blocks = deque[_NotificationBlock](maxlen=MAX_LIVE_NOTIFICATIONS) + self._hook_blocks: dict[tuple[str, str], _HookBlock] = {} self._status_block = _StatusBlock(initial_status) self._need_recompose = False @@ -517,6 +521,8 @@ def compose_agent_output( # it too, so a still-running agent is separated from a finished # one already committed to scrollback. _append_action_block(blocks, tool_call.compose(), leading=True) + for hook_block in getattr(self, "_hook_blocks", {}).values(): + _append_action_block(blocks, hook_block.compose(), leading=True) if include_working_indicator and self._active_turn_depth > 0: # Keep a stable activity indicator visible even while content or # tool cards are already on-screen. This makes long-running @@ -764,7 +770,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: if self._latest_mcp_status is not None: self._mcp_loading_spinner = render_mcp_startup_text(self._latest_mcp_status) else: - line = Text("● ", style=tui_rich_style("muted")) + line = Text(f"{TRANSCRIPT_ACTIVE_MARKER} ", style=tui_rich_style("muted")) line.append("Starting MCP servers", style=tui_rich_style("muted")) self._mcp_loading_spinner = line self.refresh_soon() @@ -775,7 +781,9 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: truncated = (question[:40] + "...") if len(question) > 40 else question self._btw_question = question glyph = ( - "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " ) line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append(f"Side question... {truncated}", style=tui_rich_style("muted")) @@ -823,6 +831,10 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: self.display_question_answered(msg) case ProgressNote(): self.display_progress_note(msg) + case HookTriggered(): + self.append_hook_triggered(msg) + case HookResolved(): + self.append_hook_resolved(msg) case ContentPart(): self.append_content(msg) case ToolCall(): @@ -1004,6 +1016,7 @@ def cleanup(self, is_interrupt: bool) -> None: self._compaction_block = None self._mcp_loading_spinner = None self._btw_spinner = None + self._hook_blocks.clear() self._current_step_retry = None if is_interrupt: @@ -1162,6 +1175,26 @@ def append_notification(self, notification: Notification) -> None: self._live_notification_blocks.append(block) self.refresh_soon() + def append_hook_triggered(self, event: HookTriggered) -> None: + self._hook_blocks[(event.event, event.target)] = _HookBlock(event) + self.refresh_soon() + + def append_hook_resolved(self, event: HookResolved) -> None: + key = (event.event, event.target) + block = self._hook_blocks.pop(key, None) + if block is None: + block = _HookBlock( + HookTriggered( + event=event.event, + target=event.target, + hook_count=1, + ) + ) + block.resolve(event) + console.print() + console.print(block.compose()) + self.refresh_soon() + def display_question_answered(self, event: QuestionAnswered) -> None: self.flush_content() block = _QuestionAnsweredBlock(event) diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index 8314e4df..55b0a59f 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -18,6 +18,7 @@ ) from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown as Markdown from pythinker_code.ui.shell.design_system import ShellTone, StatusName, shell_style, status_icon +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import WORKLOG_PANEL_PADDING from pythinker_code.ui.theme import get_tui_tokens, tui_rich_style @@ -51,18 +52,26 @@ class ToolStyle: "Glob": ToolStyle("Find", "*", "info"), "Edit": ToolStyle("Edit", "<-", "accent"), "Replace": ToolStyle("Edit", "<-", "accent"), + "StrReplaceFile": ToolStyle("Edit", "<-", "accent"), "Write": ToolStyle("Write", "<-", "accent"), "WriteFile": ToolStyle("Write", "<-", "accent"), "ApplyPatch": ToolStyle("Patch", "◆", "accent"), "Bash": ToolStyle("Shell", "$", "success"), "Shell": ToolStyle("Shell", "$", "success"), + "SetTodoList": ToolStyle("Todo", "☑", "warning"), "TodoWrite": ToolStyle("Todo", "☑", "warning"), - "Agent": ToolStyle("Subagent", "●", "muted"), - "Task": ToolStyle("Subagent", "●", "muted"), + "Agent": ToolStyle("Subagent", TRANSCRIPT_ACTIVE_MARKER, "muted"), + "RunAgents": ToolStyle("Subagents", TRANSCRIPT_ACTIVE_MARKER, "muted"), + "Task": ToolStyle("Subagent", TRANSCRIPT_ACTIVE_MARKER, "muted"), "AskUser": ToolStyle("Ask", "?", "warning"), + "AskUserQuestion": ToolStyle("Ask", "?", "warning"), "FetchURL": ToolStyle("Fetch", "%", "info"), "WebFetch": ToolStyle("Fetch", "%", "info"), "WebSearch": ToolStyle("Search", "◈", "info"), + "SearchWeb": ToolStyle("Search", "◈", "info"), + "TaskList": ToolStyle("Tasks", "☷", "info"), + "TaskOutput": ToolStyle("TaskOutput", "☷", "info"), + "TaskStop": ToolStyle("TaskStop", "■", "warning"), "Skill": ToolStyle("Skill", "◇", "info"), } diff --git a/src/pythinker_code/ui/terminal_capabilities.py b/src/pythinker_code/ui/terminal_capabilities.py new file mode 100644 index 00000000..8169f1ba --- /dev/null +++ b/src/pythinker_code/ui/terminal_capabilities.py @@ -0,0 +1,90 @@ +"""Terminal capability helpers for portable shell UI rendering. + +The shell UI has to work in modern terminals, PowerShell, CI logs, SSH panes, +and deliberately minimal terminals. Keep environment-based decisions here so +color, glyph, and motion fallbacks stay consistent across Rich and +prompt_toolkit renderers. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping +from typing import TextIO + +_TRUE_VALUES = frozenset({"1", "true", "yes", "on", "always"}) +_FALSE_VALUES = frozenset({"0", "false", "no", "off", "never"}) + + +def _env(environ: Mapping[str, str] | None = None) -> Mapping[str, str]: + return os.environ if environ is None else environ + + +def _clean(value: str | None) -> str: + return "" if value is None else value.strip().lower() + + +def env_flag(name: str, *, environ: Mapping[str, str] | None = None) -> bool: + """Return true when *name* is set to a conventional truthy value.""" + return _clean(_env(environ).get(name)) in _TRUE_VALUES + + +def colors_disabled(environ: Mapping[str, str] | None = None) -> bool: + """Return whether terminal color should be suppressed. + + Follows the widely-used CLI conventions from clig.dev and no-color.org: + ``NO_COLOR`` wins when non-empty, ``TERM=dumb`` means no formatting, and + ``CLICOLOR=0`` disables color for tools that support the BSD convention. + ``PYTHINKER_NO_COLOR`` is the app-specific override for users who want one. + """ + env = _env(environ) + if env.get("NO_COLOR"): + return True + if env_flag("PYTHINKER_NO_COLOR", environ=env): + return True + if _clean(env.get("TERM")) == "dumb": + return True + return _clean(env.get("CLICOLOR")) == "0" + + +def ascii_glyphs_enabled( + environ: Mapping[str, str] | None = None, stdout: TextIO | None = None +) -> bool: + """Return whether the TUI should use ASCII-only glyphs. + + Modern Windows Terminal and PowerShell are Unicode-capable, so Windows alone + is not a reason to degrade. We fall back for explicit user requests, + ``TERM=dumb``, or non-UTF stdout encodings such as legacy Windows code pages. + """ + env = _env(environ) + stdout = sys.stdout if stdout is None else stdout + mode = _clean(env.get("PYTHINKER_TUI_GLYPHS")) + if mode in {"ascii", "safe"}: + return True + if mode in {"unicode", "rich"}: + return False + if env_flag("PYTHINKER_ASCII_UI", environ=env) or env_flag( + "PYTHINKER_SAFE_GLYPHS", environ=env + ): + return True + if _clean(env.get("TERM")) == "dumb": + return True + + encoding = _clean(getattr(stdout, "encoding", None)) + return bool(encoding and "utf" not in encoding and "65001" not in encoding) + + +def motion_disabled(environ: Mapping[str, str] | None = None) -> bool: + """Return whether animated terminal affordances should collapse to static.""" + env = _env(environ) + if _clean(env.get("TERM")) == "dumb": + return True + return any( + env_flag(name, environ=env) + for name in ( + "PYTHINKER_REDUCED_MOTION", + "PYTHINKER_NO_ANIMATION", + "PYTHINKER_STATIC_OUTPUT", + ) + ) diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index 9d459894..29256fd2 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -6,15 +6,46 @@ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import Literal +import re +from dataclasses import dataclass, fields, replace +from typing import Any, Literal, cast from prompt_toolkit.styles import Style as PTKStyle from rich.style import Style as RichStyle +from pythinker_code.ui.terminal_capabilities import colors_disabled + type ThemeName = Literal["dark", "light"] +# Intentionally strips only hex prompt_toolkit color tokens (for example +# ``#RRGGBB``, ``fg:#RRGGBB``, and ``bg:#RRGGBB``). Named/ANSI tokens such as +# ``fg:red`` or ``bg:ansired`` are preserved; if those need no-color support, +# extend this regex and keep ``_strip_ptk_colors`` in sync. +_PTK_COLOR_TOKEN_RE = re.compile(r"^(?:fg:|bg:)?#[0-9A-Fa-f]{6}$") + + +def _strip_ptk_colors(style: str) -> str: + """Remove prompt_toolkit color directives while preserving weight/style.""" + if not style: + return style + return " ".join(part for part in style.split() if not _PTK_COLOR_TOKEN_RE.match(part)) + + +def _strip_ptk_style_map(values: dict[str, str]) -> dict[str, str]: + return {key: _strip_ptk_colors(value) for key, value in values.items()} + + +def _strip_color_dataclass[T](value: T) -> T: + updates: dict[str, Any] = {} + for field in fields(cast(Any, value)): + current = getattr(value, field.name) + if isinstance(current, str): + updates[field.name] = _strip_ptk_colors(current) + result: T = replace(cast(Any, value), **updates) + return result + + # --------------------------------------------------------------------------- # Diff colors (used by utils/rich/diff_render.py) # --------------------------------------------------------------------------- @@ -42,6 +73,13 @@ class DiffColors: del_hl=RichStyle(bgcolor="#ffc1c0"), ) +_DIFF_PLAIN = DiffColors( + add_bg=RichStyle(), + del_bg=RichStyle(), + add_hl=RichStyle(), + del_hl=RichStyle(), +) + # --------------------------------------------------------------------------- # Task browser colors (used by ui/shell/task_browser.py) @@ -49,51 +87,53 @@ class DiffColors: def _task_browser_style_dark() -> PTKStyle: - return PTKStyle.from_dict( - { - "header": "bg:#1f2937 #e5e7eb", - "header.title": "bg:#1f2937 #F4F4F5 bold", - "header.meta": "bg:#1f2937 #A3A3A3", - "status.running": "bg:#1f2937 #7BC97F bold", - "status.success": "bg:#1f2937 #7BC97F", - "status.warning": "bg:#1f2937 #E6B450", - "status.error": "bg:#1f2937 #EF5E62", - "status.info": "bg:#1f2937 #AFE3F1", - "task-list": "bg:#111827 #d1d5db", - "task-list.checked": "bg:#164e63 #ecfeff bold", - "frame.border": "#3A506D", - "frame.label": "bg:#17182a #F4F4F5 bold", - "footer": "bg:#17182a #A3A3A3", - "footer.key": "bg:#17182a #AFE3F1 bold", - "footer.text": "bg:#17182a #A3A3A3", - "footer.warning": "bg:#4a3315 #E6B450 bold", - "footer.meta": "bg:#17182a #5F6B7E", - } - ) + styles = { + "header": "bg:#1f2937 #e5e7eb", + "header.title": "bg:#1f2937 #F4F4F5 bold", + "header.meta": "bg:#1f2937 #A3A3A3", + "status.running": "bg:#1f2937 #7BC97F bold", + "status.success": "bg:#1f2937 #7BC97F", + "status.warning": "bg:#1f2937 #E6B450", + "status.error": "bg:#1f2937 #EF5E62", + "status.info": "bg:#1f2937 #AFE3F1", + "task-list": "bg:#111827 #d1d5db", + "task-list.checked": "bg:#164e63 #ecfeff bold", + "frame.border": "#3A506D", + "frame.label": "bg:#17182a #F4F4F5 bold", + "footer": "bg:#17182a #A3A3A3", + "footer.key": "bg:#17182a #AFE3F1 bold", + "footer.text": "bg:#17182a #A3A3A3", + "footer.warning": "bg:#4a3315 #E6B450 bold", + "footer.meta": "bg:#17182a #5F6B7E", + } + if colors_disabled(): + styles = _strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) def _task_browser_style_light() -> PTKStyle: - return PTKStyle.from_dict( - { - "header": "bg:#e5e7eb #1f2937", - "header.title": "bg:#e5e7eb #213853 bold", - "header.meta": "bg:#e5e7eb #666666", - "status.running": "bg:#e5e7eb #2C7A39 bold", - "status.success": "bg:#e5e7eb #2C7A39", - "status.warning": "bg:#e5e7eb #9A6B18", - "status.error": "bg:#e5e7eb #C0392B", - "status.info": "bg:#e5e7eb #176B7E", - "task-list": "bg:#f9fafb #374151", - "task-list.checked": "bg:#cffafe #164e63 bold", - "frame.border": "#495F7C", - "frame.label": "bg:#f1f5f9 #213853 bold", - "footer": "bg:#f1f5f9 #475569", - "footer.key": "bg:#f1f5f9 #176B7E bold", - "footer.text": "bg:#f1f5f9 #475569", - "footer.warning": "bg:#fee2e2 #C0392B bold", - "footer.meta": "bg:#f1f5f9 #64748b", - } - ) + styles = { + "header": "bg:#e5e7eb #1f2937", + "header.title": "bg:#e5e7eb #213853 bold", + "header.meta": "bg:#e5e7eb #666666", + "status.running": "bg:#e5e7eb #2C7A39 bold", + "status.success": "bg:#e5e7eb #2C7A39", + "status.warning": "bg:#e5e7eb #9A6B18", + "status.error": "bg:#e5e7eb #C0392B", + "status.info": "bg:#e5e7eb #176B7E", + "task-list": "bg:#f9fafb #374151", + "task-list.checked": "bg:#cffafe #164e63 bold", + "frame.border": "#495F7C", + "frame.label": "bg:#f1f5f9 #213853 bold", + "footer": "bg:#f1f5f9 #475569", + "footer.key": "bg:#f1f5f9 #176B7E bold", + "footer.text": "bg:#f1f5f9 #475569", + "footer.warning": "bg:#fee2e2 #C0392B bold", + "footer.meta": "bg:#f1f5f9 #64748b", + } + if colors_disabled(): + styles = _strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) # --------------------------------------------------------------------------- @@ -262,8 +302,11 @@ def markdown_rich_style(token: str, *, theme: ThemeName | None = None) -> RichSt """Resolve a MarkdownColors field name to a Rich Style. Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; - everything else produces a style with ``color``. + everything else produces a style with ``color``. Color is suppressed when + the terminal environment requests plain output. """ + if colors_disabled(): + return RichStyle() colors = get_markdown_colors(theme) value = getattr(colors, token) if not value: @@ -324,6 +367,8 @@ def get_active_theme() -> ThemeName: def get_diff_colors() -> DiffColors: + if colors_disabled(): + return _DIFF_PLAIN return _DIFF_LIGHT if _active_theme == "light" else _DIFF_DARK @@ -333,15 +378,19 @@ def get_task_browser_style() -> PTKStyle: def get_prompt_style() -> PTKStyle: d = _PROMPT_STYLE_LIGHT if _active_theme == "light" else _PROMPT_STYLE_DARK + if colors_disabled(): + d = _strip_ptk_style_map(d) return PTKStyle.from_dict(d) def get_toolbar_colors() -> ToolbarColors: - return _TOOLBAR_LIGHT if _active_theme == "light" else _TOOLBAR_DARK + colors = _TOOLBAR_LIGHT if _active_theme == "light" else _TOOLBAR_DARK + return _strip_color_dataclass(colors) if colors_disabled() else colors def get_mcp_prompt_colors() -> MCPPromptColors: - return _MCP_PROMPT_LIGHT if _active_theme == "light" else _MCP_PROMPT_DARK + colors = _MCP_PROMPT_LIGHT if _active_theme == "light" else _MCP_PROMPT_DARK + return _strip_color_dataclass(colors) if colors_disabled() else colors # --------------------------------------------------------------------------- @@ -483,6 +532,7 @@ def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; everything else produces a style with ``color``. Empty hex values (``""``) yield an empty style — Rich falls back to terminal defaults. + Color is suppressed when the terminal environment requests plain output. Raises: ValueError: If *token* is not a known TuiTokens field. @@ -490,6 +540,8 @@ def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: if token not in TUI_TOKEN_NAMES: known = ", ".join(sorted(TUI_TOKEN_NAMES)) raise ValueError(f"Unknown TUI token {token!r}. Known tokens: {known}") + if colors_disabled(): + return RichStyle() tokens = get_tui_tokens(theme) value = getattr(tokens, token) if not value: diff --git a/tests/auth/test_openai_auth.py b/tests/auth/test_openai_auth.py index 467d8005..858933d7 100644 --- a/tests/auth/test_openai_auth.py +++ b/tests/auth/test_openai_auth.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import base64 +import json from urllib.parse import parse_qs, urlsplit import aiohttp @@ -18,6 +20,7 @@ OPENAI_BROWSER_PORT, OPENAI_BROWSER_REDIRECT_PATH, OPENAI_CHATGPT_BASE_URL, + OPENAI_CHATGPT_MODELS_URL, OPENAI_CHATGPT_OAUTH_KEY, OPENAI_CLIENT_ID, OPENAI_DEVICE_REDIRECT_URI, @@ -25,10 +28,12 @@ PkceCodes, _apply_openai_config, _build_authorize_url, - _discover_chatgpt_models, + _callback_html, _exchange_id_token_for_api_key, _select_default_openai_model, + _token_from_openai_response, _wait_for_browser_code, + discover_chatgpt_models, login_openai_api_key, login_openai_browser, ) @@ -52,6 +57,20 @@ def _model(model_id: str, *, reasoning: bool = False, image: bool = False) -> Mo ) +def _jwt_with_chatgpt_account(account_id: str) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode("utf-8").rstrip("=") + payload = ( + base64.urlsafe_b64encode( + json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).encode( + "utf-8" + ) + ) + .decode("utf-8") + .rstrip("=") + ) + return f"{header}.{payload}.signature" + + def _request_info(url: str) -> aiohttp.RequestInfo: return aiohttp.RequestInfo( url=URL(url), @@ -72,6 +91,23 @@ def test_openai_auth_constants_match_codex_compatible_values(): assert OPENAI_CHATGPT_OAUTH_KEY == "oauth/openai-chatgpt" +def test_openai_callback_html_uses_pythinker_branding(): + page = _callback_html(ok=True, message=None) + + assert "Pythinker logged in" in page + assert "You're logged in to Pythinker" in page + assert "OpenAI login complete" not in page + assert "data:image/svg+xml" in page + + +def test_openai_callback_html_escapes_error_message(): + page = _callback_html(ok=False, message='') + + assert "Pythinker login failed" in page + assert "<script>alert("x")</script>" in page + assert '' not in page + + def test_build_authorize_url_uses_codex_parameters(): url = _build_authorize_url( redirect_uri="http://localhost:1455/auth/callback", @@ -130,6 +166,20 @@ def post(self, url, *, data): assert "requested_token_type" not in captured["data"] +def test_token_from_openai_response_extracts_chatgpt_account_id_from_access_token(): + token = _token_from_openai_response( + { + "access_token": _jwt_with_chatgpt_account("acc_access"), + "refresh_token": "refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "openid profile email offline_access", + } + ) + + assert token.account_id == "acc_access" + + @pytest.mark.asyncio async def test_wait_for_browser_code_accepts_localhost_callback(monkeypatch): monkeypatch.setattr( @@ -349,38 +399,145 @@ async def fake_list_models(platform, api_key): @pytest.mark.asyncio async def test_discover_chatgpt_models_reraises_401(monkeypatch): - async def fake_list_models(platform, api_key): - assert platform.id == OPENAI_CHATGPT_PLATFORM_ID - assert api_key == "bad-token" - request_info = _request_info("https://chatgpt.com/backend-api/codex/models") - raise aiohttp.ClientResponseError(request_info, (), status=401, message="Unauthorized") + class FakeSession: + async def __aenter__(self): + return self - monkeypatch.setattr("pythinker_code.auth.openai.list_models", fake_list_models) + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + assert url == OPENAI_CHATGPT_MODELS_URL + assert headers["Authorization"] == "Bearer bad-token" + request_info = _request_info(url) + raise aiohttp.ClientResponseError(request_info, (), status=401, message="Unauthorized") + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) with pytest.raises(aiohttp.ClientResponseError) as exc_info: - await _discover_chatgpt_models("bad-token") + await discover_chatgpt_models("bad-token") assert exc_info.value.status == 401 @pytest.mark.asyncio -async def test_discover_chatgpt_models_fallback_uses_current_supported_models(monkeypatch): - async def fake_list_models(platform, api_key): - raise RuntimeError("models endpoint unavailable") +async def test_discover_chatgpt_models_uses_account_scoped_codex_catalog(monkeypatch): + captured = {} + payload = { + "models": [ + { + "slug": "gpt-5.3-codex", + "display_name": "GPT-5.3 Codex", + "context_window": 272_000, + "priority": 20, + "supported_in_api": False, + }, + { + "slug": "hidden-model", + "visibility": "hidden", + "context_window": 272_000, + "priority": 1, + }, + { + "slug": "gpt-5.3-codex-spark", + "name": "GPT-5.3 Codex Spark", + "context_window": 128_000, + "priority": 10, + "supported_in_api": False, + }, + ] + } - monkeypatch.setattr("pythinker_code.auth.openai.list_models", fake_list_models) + class FakeResponse: + async def __aenter__(self): + return self - models = await _discover_chatgpt_models("access-token") + async def __aexit__(self, exc_type, exc, tb): + return None - model_ids = [model.id for model in models] - assert model_ids[:6] == [ - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2", - ] - assert "gpt-5.1-codex" not in model_ids + async def json(self, content_type=None): + return payload + + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + captured["url"] = url + captured["headers"] = headers + captured["raise_for_status"] = raise_for_status + return FakeResponse() + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + models = await discover_chatgpt_models("access-token", account_id="acc_123") + + assert captured["url"] == OPENAI_CHATGPT_MODELS_URL + assert captured["headers"]["Authorization"] == "Bearer access-token" + assert captured["headers"]["ChatGPT-Account-ID"] == "acc_123" + assert captured["headers"]["originator"] == "codex_cli_rs" + assert captured["raise_for_status"] is True + assert [model.id for model in models] == ["gpt-5.3-codex-spark", "gpt-5.3-codex"] + assert models[0].context_length == 128_000 + assert models[0].supports_image_in is False + assert models[1].display_name == "GPT-5.3 Codex" + + +@pytest.mark.asyncio +async def test_discover_chatgpt_models_uses_custom_base_url(monkeypatch): + captured = {} + payload = {"models": [{"slug": "gpt-5.3-codex", "supported_in_api": False}]} + + class FakeResponse: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def json(self, content_type=None): + return payload + + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + captured["url"] = url + return FakeResponse() + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + models = await discover_chatgpt_models( + "access-token", + base_url="https://proxy.example/backend-api/codex/", + ) + + assert captured["url"] == "https://proxy.example/backend-api/codex/models?client_version=1.0.0" + assert [model.id for model in models] == ["gpt-5.3-codex"] + + +@pytest.mark.asyncio +async def test_discover_chatgpt_models_does_not_fallback_when_catalog_unavailable(monkeypatch): + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, *args, **kwargs): + raise RuntimeError("models endpoint unavailable") + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + with pytest.raises(RuntimeError, match="models endpoint unavailable"): + await discover_chatgpt_models("access-token") @pytest.mark.asyncio @@ -409,16 +566,17 @@ async def fake_list_models(platform, api_key): @pytest.mark.asyncio -async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_fallback( +async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_live_catalog( monkeypatch, tmp_path ): monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) provider_key = managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID) stale_model_key = managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.1-codex") config = Config(is_from_default_location=True) + chatgpt_base_url = "https://proxy.example/backend-api/codex" config.providers[provider_key] = LLMProvider( type="openai_codex", - base_url=OPENAI_CHATGPT_BASE_URL, + base_url=chatgpt_base_url, api_key=SecretStr("access-token"), oauth=OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY), ) @@ -430,17 +588,22 @@ async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_fa ) config.default_model = stale_model_key - async def fake_list_models(platform, api_key): - raise RuntimeError("models endpoint unavailable") + async def fake_discover_chatgpt_models(api_key, *, account_id=None, base_url=None): + assert api_key == "access-token" + assert account_id is None + assert base_url == chatgpt_base_url + return [_model("gpt-5.5", reasoning=True)] - monkeypatch.setattr("pythinker_code.auth.platforms.list_models", fake_list_models) + monkeypatch.setattr( + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models + ) changed = await refresh_managed_models(config) assert changed is True assert config.default_model == managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.5") assert stale_model_key not in config.models - assert managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.3-codex") in config.models + assert managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.5") in config.models @pytest.mark.asyncio @@ -466,6 +629,7 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): assert redirect_uri == "https://auth.openai.com/deviceauth/callback" return { "access_token": "access-token", + "id_token": _jwt_with_chatgpt_account("acc_headless"), "refresh_token": "refresh-token", "expires_in": 3600, "token_type": "Bearer", @@ -473,10 +637,12 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): } async def fake_exchange_id_token_for_api_key(id_token): + assert id_token == _jwt_with_chatgpt_account("acc_headless") return "" - async def fake_discover_chatgpt_models(api_key): + async def fake_discover_chatgpt_models(api_key, *, account_id=None): assert api_key == "access-token" + assert account_id == "acc_headless" return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr("pythinker_code.auth.openai._request_device_code", fake_request_device_code) @@ -489,7 +655,7 @@ async def fake_discover_chatgpt_models(api_key): fake_exchange_id_token_for_api_key, ) monkeypatch.setattr( - "pythinker_code.auth.openai._discover_chatgpt_models", + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models, ) @@ -501,6 +667,7 @@ async def fake_discover_chatgpt_models(api_key): token = load_tokens(OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY)) assert token is not None assert token.access_token == "access-token" + assert token.account_id == "acc_headless" provider = config.providers[managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID)] assert provider.type == "openai_codex" assert provider.oauth == OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY) @@ -522,13 +689,19 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): assert redirect_uri.startswith("http://localhost:") return { "access_token": "access-token", + "id_token": _jwt_with_chatgpt_account("acc_browser"), "refresh_token": "refresh-token", "expires_in": 3600, "token_type": "Bearer", "scope": "openid profile email offline_access", } - async def fake_discover_chatgpt_models(api_key): + async def fake_exchange_id_token_for_api_key(id_token): + assert id_token == _jwt_with_chatgpt_account("acc_browser") + return "" + + async def fake_discover_chatgpt_models(api_key, *, account_id=None): + assert account_id == "acc_browser" return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr( @@ -538,7 +711,11 @@ async def fake_discover_chatgpt_models(api_key): "pythinker_code.auth.openai._exchange_code_for_tokens", fake_exchange_code_for_tokens ) monkeypatch.setattr( - "pythinker_code.auth.openai._discover_chatgpt_models", fake_discover_chatgpt_models + "pythinker_code.auth.openai._exchange_id_token_for_api_key", + fake_exchange_id_token_for_api_key, + ) + monkeypatch.setattr( + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models ) events = [event async for event in login_openai_browser(config, open_browser=False)] diff --git a/tests/cli/test_review_wrapper.py b/tests/cli/test_review_wrapper.py index cb6e2fa4..325bb033 100644 --- a/tests/cli/test_review_wrapper.py +++ b/tests/cli/test_review_wrapper.py @@ -1,12 +1,25 @@ +import os import subprocess import pytest +def _cli_env() -> dict[str, str]: + env = os.environ.copy() + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" + env["COLUMNS"] = "120" + return env + + @pytest.mark.parametrize("cmd", ["review", "secscan", "security-scan", "debug"]) def test_top_level_help_lists_command(cmd: str) -> None: proc = subprocess.run( - ["uv", "run", "pythinker", "--help"], check=True, capture_output=True, text=True + ["uv", "run", "pythinker", "--help"], + check=True, + capture_output=True, + text=True, + env=_cli_env(), ) assert cmd in proc.stdout @@ -17,6 +30,7 @@ def test_review_diff_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--with-security" in proc.stdout assert "--mode" in proc.stdout @@ -43,6 +57,7 @@ def test_review_artifact_help_works(command: str) -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--budget-chars" in proc.stdout assert "--timeout-s" in proc.stdout @@ -62,6 +77,7 @@ def test_review_local_parity_help_works(command: str, expected: str) -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert expected in proc.stdout @@ -72,6 +88,7 @@ def test_review_help_docs_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--docs-path" in proc.stdout assert "--root-readme" in proc.stdout @@ -83,6 +100,7 @@ def test_review_compliance_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--checklist" in proc.stdout assert "--ticket-file" in proc.stdout @@ -94,6 +112,7 @@ def test_security_scan_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "scan" in proc.stdout assert "process" in proc.stdout @@ -112,6 +131,7 @@ def test_standalone_security_scan_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "scan" in proc.stdout assert "process" in proc.stdout @@ -123,5 +143,6 @@ def test_debug_failure_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--command" in proc.stdout diff --git a/tests/cli/test_secscan_wrapper.py b/tests/cli/test_secscan_wrapper.py index eda444cf..9cfa0675 100644 --- a/tests/cli/test_secscan_wrapper.py +++ b/tests/cli/test_secscan_wrapper.py @@ -1,11 +1,21 @@ +import os import subprocess +def _cli_env() -> dict[str, str]: + env = os.environ.copy() + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" + env["COLUMNS"] = "120" + return env + + def test_secscan_diff_help_works() -> None: proc = subprocess.run( ["uv", "run", "pythinker", "secscan", "diff", "--help"], check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--fail-on" in proc.stdout diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py new file mode 100644 index 00000000..4a77905f --- /dev/null +++ b/tests/cli/test_update_cli.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os + +from typer.testing import CliRunner + +from pythinker_code.cli import cli +from pythinker_code.ui.shell import update_orchestrator as orchestrator + + +def _isolate_update_files(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") + monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") + monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") + monkeypatch.setattr( + orchestrator, + "UPDATE_LAST_SUCCESS_FILE", + tmp_path / "update_last_success.json", + ) + + +def test_update_status_command_renders_recorded_status(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.write_update_status( + orchestrator.UpdateJobStatus( + job_id="job-1", + state=orchestrator.UpdateJobState.UPDATED, + started_at=1.0, + finished_at=2.0, + current_version="1.0.0", + target_version="1.1.0", + result="UPDATED", + message="Smoke check passed: pythinker, version 1.1.0", + log_path=str(orchestrator.UPDATE_LOG_FILE), + pid=os.getpid(), + source="test", + ) + ) + + result = CliRunner().invoke(cli, ["update", "status"]) + + assert result.exit_code == 0, result.output + assert "State: updated" in result.output + assert "Result: UPDATED" in result.output + assert "Current version: 1.0.0" in result.output + assert "Target version: 1.1.0" in result.output + assert "Message: Smoke check passed" in result.output + assert f"Log: {orchestrator.UPDATE_LOG_FILE}" in result.output + + +def test_update_log_command_respects_line_count(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + for idx in range(4): + orchestrator.append_update_log(f"line {idx}") + + result = CliRunner().invoke(cli, ["update", "log", "--lines", "2"]) + + assert result.exit_code == 0, result.output + assert result.output.splitlines() == ["line 2", "line 3"] diff --git a/tests/conftest.py b/tests/conftest.py index 70a70560..ee7d2eb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Test configuration and fixtures.""" +# ruff: noqa: E402 -- terminal-capability env must be pinned before pythinker imports from __future__ import annotations @@ -9,6 +10,31 @@ from contextlib import contextmanager from pathlib import Path +# Pin a capable terminal for the whole test session BEFORE any pythinker import +# runs, because ``ui.shell.glyphs`` freezes its glyph constants from +# ``terminal_capabilities`` at module load. CI runs with ``NO_COLOR=1`` and +# ``TERM=dumb`` to keep build logs clean; left untouched those would degrade +# every renderer to ASCII glyphs and stripped color, breaking the UI-contract +# tests that assert the rich appearance. The degraded fallbacks themselves are +# covered explicitly by tests/ui_and_conv/test_terminal_capabilities.py, which +# sets these vars per-test and reloads. (Don't set PYTHINKER_TUI_GLYPHS=unicode +# here: that mode short-circuits the TERM=dumb check and would break the +# fallback tests; popping it and relying on TERM != dumb + UTF-8 stdout is +# enough.) +for _capability_var in ( + "NO_COLOR", + "PYTHINKER_NO_COLOR", + "CLICOLOR", + "PYTHINKER_TUI_GLYPHS", + "PYTHINKER_ASCII_UI", + "PYTHINKER_SAFE_GLYPHS", + "PYTHINKER_REDUCED_MOTION", + "PYTHINKER_NO_ANIMATION", + "PYTHINKER_STATIC_OUTPUT", +): + os.environ.pop(_capability_var, None) +os.environ["TERM"] = "xterm-256color" + import pytest from pydantic import SecretStr from pythinker_core.chat_provider.mock import MockChatProvider diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1aeb7c84..55d56cf4 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -96,6 +96,13 @@ def test_load_config_text_json(): assert config == get_default_config() +def test_load_config_migrates_legacy_feedback_repo_default(): + old_owner = "mohamed-elkholy95" + config = load_config_from_string(f'[feedback]\ngithub_repo = "{old_owner}/Pythinker-Code"\n') + + assert config.feedback.github_repo == "TechMatrix-labs/pythinker-code" + + def test_agent_execution_profile_autonomous_sets_autonomy_defaults(): config = load_config_from_string('agent_execution_profile = "autonomous_coding"') diff --git a/tests/core/test_export_cli.py b/tests/core/test_export_cli.py index 1d2f3495..754abb3f 100644 --- a/tests/core/test_export_cli.py +++ b/tests/core/test_export_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re import zipfile from pathlib import Path @@ -15,6 +16,8 @@ from pythinker_code.session import Session from pythinker_code.wire.types import TextPart, TurnBegin +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + @pytest.fixture def isolated_share_dir(monkeypatch, tmp_path: Path) -> Path: @@ -199,8 +202,9 @@ def test_export_previous_session_errors_when_missing( def test_export_help_is_leaf_command() -> None: - result = CliRunner().invoke(cli, ["export", "--help"]) + result = CliRunner().invoke(cli, ["export", "--help"], color=False) assert result.exit_code == 0, result.output - assert "Usage: root export [OPTIONS] [SESSION_ID]" in result.output - assert "COMMAND [ARGS]..." not in result.output + output = _ANSI_RE.sub("", result.output) + assert "Usage: root export [OPTIONS] [SESSION_ID]" in output + assert "COMMAND [ARGS]..." not in output diff --git a/tests/core/test_openai_provider.py b/tests/core/test_openai_provider.py index 275d5853..85738f9e 100644 --- a/tests/core/test_openai_provider.py +++ b/tests/core/test_openai_provider.py @@ -54,6 +54,19 @@ def with_thinking(self, effort): "pythinker_core.contrib.chat_provider.openai_responses.OpenAIResponses", FakeOpenAIResponses ) config = _openai_chatgpt_config() + ref = OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY) + save_tokens( + ref, + OAuthToken( + access_token="access-token", + refresh_token="refresh-token", + expires_at=time.time() + 3600, + scope="openid", + token_type="Bearer", + expires_in=3600, + account_id="acc_test", + ), + ) provider = next(iter(config.providers.values())) model = next(iter(config.models.values())) @@ -62,8 +75,10 @@ def with_thinking(self, effort): assert llm is not None assert captured["model"] == "gpt-5.1-codex" assert captured["base_url"] == "https://chatgpt.com/backend-api/codex" - assert captured["api_key"] == "" + assert captured["api_key"] == "access-token" assert captured["system_prompt_as_instructions"] is True + assert captured["default_headers"]["ChatGPT-Account-ID"] == "acc_test" + assert captured["default_headers"]["originator"] == "codex_cli_rs" assert captured["thinking"] == "high" diff --git a/tests/core/test_shell_mcp_status.py b/tests/core/test_shell_mcp_status.py index 4273cba9..2a101628 100644 --- a/tests/core/test_shell_mcp_status.py +++ b/tests/core/test_shell_mcp_status.py @@ -2,6 +2,7 @@ from rich.console import Console +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.mcp_status import ( mcp_startup_header, render_mcp_console, @@ -42,12 +43,29 @@ def test_render_mcp_servers_shows_pythinker_x_loading_design() -> None: prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.0)) prompt_text_off = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) assert prompt_text != prompt_text_off - assert prompt_text.startswith("● ") + assert prompt_text.startswith(f"{TRANSCRIPT_ACTIVE_MARKER} ") assert "Starting MCP servers (0/2): chrome-devtools, context7" in prompt_text assert "resolve-library-id" not in prompt_text startup_text = render_mcp_startup_text(snapshot, now=0.0).plain - assert startup_text == "● Starting MCP servers (0/2): chrome-devtools, context7" + assert startup_text == ( + f"{TRANSCRIPT_ACTIVE_MARKER} Starting MCP servers (0/2): chrome-devtools, context7" + ) + + +def test_render_mcp_prompt_respects_reduced_motion(monkeypatch) -> None: + snapshot = MCPStatusSnapshot( + loading=True, + connected=0, + total=1, + tools=0, + servers=(MCPServerSnapshot(name="slow-test", status="pending", tools=()),), + ) + monkeypatch.setattr("pythinker_code.ui.shell.mcp_status.reduced_motion_enabled", lambda: True) + + prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) + + assert prompt_text.startswith(f"{TRANSCRIPT_ACTIVE_MARKER} ") def test_render_mcp_startup_header_for_single_server() -> None: diff --git a/tests/e2e/test_cli_error_output.py b/tests/e2e/test_cli_error_output.py index e6e79c22..d0efd85d 100644 --- a/tests/e2e/test_cli_error_output.py +++ b/tests/e2e/test_cli_error_output.py @@ -18,6 +18,8 @@ def _run_pythinker(args: list[str], *, share_dir: Path) -> subprocess.CompletedP env = os.environ.copy() env["PYTHINKER_SHARE_DIR"] = str(share_dir) # Stabilize rich/Click formatting across environments for snapshot tests. + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" env["COLUMNS"] = "120" env["LINES"] = "40" # Run via `python -m` to avoid `uv run pythinker` build/progress output interfering with snapshots. diff --git a/tests/ui_and_conv/test_activity_tree.py b/tests/ui_and_conv/test_activity_tree.py index 15f7ccae..ec1b7e5d 100644 --- a/tests/ui_and_conv/test_activity_tree.py +++ b/tests/ui_and_conv/test_activity_tree.py @@ -2,8 +2,11 @@ from rich.console import Console +from pythinker_code.ui.shell.design_system import status_icon from pythinker_code.ui.shell.visualize._activity_tree import ActivityRow, render_activity_tree +_RUNNING_GLYPH = status_icon("running").plain + def _plain(renderable, *, width: int = 80) -> str: console = Console(record=True, width=width, color_system=None) @@ -35,3 +38,35 @@ def test_activity_tree_truncates_long_detail(): width=40, ) assert all(len(row) <= 41 for row in output.splitlines() if row) + + +def test_running_row_marker_pulses_off_phase(monkeypatch): + for flag in ("PYTHINKER_REDUCED_MOTION", "PYTHINKER_NO_ANIMATION", "PYTHINKER_STATIC_OUTPUT"): + monkeypatch.delenv(flag, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + rows = [ActivityRow(label="explore", detail="Read _live_view.py", state="running")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert _RUNNING_GLYPH in on_phase + assert _RUNNING_GLYPH not in off_phase + + +def test_running_row_marker_static_under_reduced_motion(monkeypatch): + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + rows = [ActivityRow(label="explore", detail="Read _live_view.py", state="running")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert _RUNNING_GLYPH in on_phase + assert _RUNNING_GLYPH in off_phase + + +def test_non_running_row_marker_does_not_pulse(monkeypatch): + for flag in ("PYTHINKER_REDUCED_MOTION", "PYTHINKER_NO_ANIMATION", "PYTHINKER_STATIC_OUTPUT"): + monkeypatch.delenv(flag, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + completed_glyph = status_icon("completed").plain + rows = [ActivityRow(label="review", detail="Finished audit", state="completed")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert completed_glyph in on_phase + assert completed_glyph in off_phase diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index 75a3347e..1339114f 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -9,7 +9,13 @@ from pythinker_code.ui.shell.keyboard import KeyEvent from pythinker_code.ui.shell.visualize import _live_view as live_view_module from pythinker_code.ui.shell.visualize import _LiveView, _PromptLiveView -from pythinker_code.wire.types import Notification, StatusUpdate, TurnBegin +from pythinker_code.wire.types import ( + HookResolved, + HookTriggered, + Notification, + StatusUpdate, + TurnBegin, +) def _render(renderable) -> str: @@ -45,6 +51,42 @@ def test_live_view_renders_notification_block(): assert "..." in rendered +def test_live_view_renders_running_hook_block(): + view = _LiveView(StatusUpdate()) + + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + + rendered = _render(view.compose_agent_output(include_working_indicator=False)) + assert "Hook" in rendered + assert "PreToolUse Shell" in rendered + assert "running" in rendered.lower() + + +def test_live_view_prints_resolved_blocking_hook(monkeypatch): + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + printed = [] + monkeypatch.setattr(shell_console, "print", lambda *args, **kwargs: printed.extend(args)) + + view.dispatch_wire_message( + HookResolved( + event="PreToolUse", + target="Shell", + action="block", + reason="blocked by policy", + duration_ms=12, + ) + ) + + assert view._hook_blocks == {} + rendered = "\n".join(_render(item) for item in printed) + assert "Hook" in rendered + assert "PreToolUse Shell" in rendered + assert "failed" in rendered.lower() + assert "blocked by policy" in rendered + assert "12ms" in rendered + + def test_working_indicator_uses_turn_elapsed_time(monkeypatch): now = 1000.0 monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) diff --git a/tests/ui_and_conv/test_native_update_parity.py b/tests/ui_and_conv/test_native_update_parity.py index 2382fafb..a924abe2 100644 --- a/tests/ui_and_conv/test_native_update_parity.py +++ b/tests/ui_and_conv/test_native_update_parity.py @@ -203,8 +203,8 @@ async def test_run_update_prompt_reports_up_to_date(monkeypatch): monkeypatch.setattr(constant, "VERSION", "2.0.0") calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool): - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool): + calls.append((print_output, check_only)) return update.UpdateResult.UP_TO_DATE monkeypatch.setattr(update, "do_update", fake_do_update) @@ -216,8 +216,8 @@ async def fake_do_update(*, print: bool, check_only: bool): async def test_run_update_prompt_skip_returns_none(monkeypatch): monkeypatch.setattr(constant, "VERSION", "1.0.0") - async def fake_do_update(*, print: bool, check_only: bool): - assert (print, check_only) == (True, True) + async def fake_do_update(*, print_output: bool, check_only: bool): + assert (print_output, check_only) == (True, True) return update.UpdateResult.UPDATE_AVAILABLE async def fake_prompt(current, latest): diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 50eea03d..c82db142 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -400,7 +400,7 @@ def test_background_task_summary_uses_single_task_command() -> None: def test_bottom_toolbar_does_not_render_mcp_startup_status(monkeypatch: Any) -> None: prompt_session = _make_toolbar_session(tips=[]) prompt_session._status_block_provider = lambda _columns: [ - ("", "● Booting MCP server: context7") + ("", "• Booting MCP server: context7") ] fragments = _render_toolbar_fragments(prompt_session, 80, monkeypatch) diff --git a/tests/ui_and_conv/test_render_matrix.py b/tests/ui_and_conv/test_render_matrix.py index d586f6bd..31509277 100644 --- a/tests/ui_and_conv/test_render_matrix.py +++ b/tests/ui_and_conv/test_render_matrix.py @@ -69,7 +69,7 @@ def test_markdown_code_fences_use_aligned_panel_frame(width: int) -> None: def test_activity_line_reduced_motion_uses_static_glyph(width: int) -> None: snap = ActivitySnapshot(label="Working", elapsed_s=3.0, reduced_motion=True) out = _render(activity_status_line(snap, width=width), width=width, no_color=True) - assert "●" in out # static dot, not an animated braille frame + assert "●" in out # static text bullet, not an animated braille frame assert "Working" in out diff --git a/tests/ui_and_conv/test_shell_design_system.py b/tests/ui_and_conv/test_shell_design_system.py index 5dbd6d48..7b8ef35a 100644 --- a/tests/ui_and_conv/test_shell_design_system.py +++ b/tests/ui_and_conv/test_shell_design_system.py @@ -33,7 +33,7 @@ def _color_hex(style: Style) -> str: def test_status_icon_names_are_stable(): assert status_icon("running").plain == "●" assert status_icon("completed").plain == "✓" - assert status_icon("failed").plain == "!" + assert status_icon("failed").plain == "✘" assert status_icon("denied").plain == "×" assert status_icon("interrupted").plain == "■" assert status_icon("waiting").plain == "○" @@ -41,6 +41,12 @@ def test_status_icon_names_are_stable(): assert status_icon("approval").plain == "?" +def test_running_and_failed_status_icons_use_expected_tones(): + assert status_icon("running").style == shell_style(ShellTone.MUTED) + assert status_icon("completed").style == shell_style(ShellTone.SUCCESS) + assert status_icon("failed").style == shell_style(ShellTone.ERROR) + + def test_keyboard_hint_uses_key_and_label(): output = _plain(keyboard_hint("esc", "interrupt")) assert "esc" in output diff --git a/tests/ui_and_conv/test_shell_feedback_slash.py b/tests/ui_and_conv/test_shell_feedback_slash.py index 681502c5..1433a609 100644 --- a/tests/ui_and_conv/test_shell_feedback_slash.py +++ b/tests/ui_and_conv/test_shell_feedback_slash.py @@ -91,6 +91,50 @@ async def test_invalid_args_without_soul_still_offer_github_fallback(self, monke assert "Opening GitHub feedback" in output +class TestFeedbackDestination: + def test_builtin_endpoint_without_pythinker_auth_falls_back(self, monkeypatch) -> None: + from pythinker_code.config import Config + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + + assert shell_slash._feedback_destination(soul) is None + + def test_configured_endpoint_is_used_without_pythinker_auth(self, monkeypatch) -> None: + from pythinker_code.config import Config + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + soul.runtime.config.feedback.endpoint_url = "https://feedback.example/submit" + + assert shell_slash._feedback_destination(soul) == ("https://feedback.example/submit", {}) + + def test_builtin_endpoint_uses_pythinker_auth(self, monkeypatch) -> None: + from pydantic import SecretStr + + from pythinker_code.auth import PYTHINKER_CODE_PLATFORM_ID + from pythinker_code.auth.platforms import managed_provider_key + from pythinker_code.config import Config, LLMProvider + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + soul.runtime.config.providers[managed_provider_key(PYTHINKER_CODE_PLATFORM_ID)] = ( + LLMProvider( + type="pythinker", + base_url="https://api.pythinker.com/coding/v1", + api_key=SecretStr("token"), + ) + ) + + feedback_url, headers = shell_slash._feedback_destination(soul) or ("", {}) + + assert feedback_url.endswith("/feedback") + assert headers["Authorization"] == "Bearer token" + + class TestFeedbackSubmission: async def test_submits_structured_payload(self, tmp_path: Path, monkeypatch) -> None: from pythinker_code.soul.pythinkersoul import PythinkerSoul @@ -252,6 +296,15 @@ def post(self, *_args: object, **_kwargs: object) -> FakeResponse: assert submission.number is None assert submission.html_url is None + def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None: + payload = {"type": "other", "content": "hi"} + old_owner = "mohamed-elkholy95" + + url = build_feedback_issue_url(payload, old_owner + "/Pythinker-Code") + + assert "github.com/TechMatrix-labs/pythinker-code/issues/new" in url + assert old_owner not in url + def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None: payload = { "type": "bug", @@ -309,6 +362,20 @@ def test_redacts_common_secrets_and_home_path(self) -> None: assert str(Path.home()) not in redacted assert " None: + text = ( + "PIP_INDEX_URL=https://user:pass@example.test/simple " + "HTTPS_PROXY=http://proxy-user:proxy-pass@proxy.test:8080 " + "Authorization: Basic dXNlcjpwYXNzd29yZA==" + ) + + redacted = redact_text(text) + + assert "user:pass" not in redacted + assert "proxy-user:proxy-pass" not in redacted + assert "dXNlcjpwYXNzd29yZA" not in redacted + assert redacted.count("= 3 + def test_redacts_common_bare_token_formats(self) -> None: text = " ".join( [ diff --git a/tests/ui_and_conv/test_shell_motion.py b/tests/ui_and_conv/test_shell_motion.py index 846cbbf5..18cddf4a 100644 --- a/tests/ui_and_conv/test_shell_motion.py +++ b/tests/ui_and_conv/test_shell_motion.py @@ -63,6 +63,14 @@ def test_spinner_frame_changes_with_time(): assert spinner_frame_at(0.0) != spinner_frame_at(0.2) +def test_active_glyphs_use_text_safe_solid_circle(): + from pythinker_code.ui.shell.glyphs import SHAPE_FRAMES, TRANSCRIPT_ASSISTANT_MARKER + + active_glyphs = {TRANSCRIPT_ASSISTANT_MARKER, SHAPE_FRAMES[0], REDUCED_MOTION_GLYPH} + assert active_glyphs == {"●"} + assert "⏺" not in active_glyphs + + def test_reduced_motion_uses_static_glyph(): assert spinner_frame_at(0.2, reduced_motion=True) == "●" diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index c016ac84..77ba083a 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -24,8 +24,8 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.UPDATE_NOW - async def fake_do_update(*, print: bool) -> update.UpdateResult: - assert print is True + async def fake_do_update(*, print_output: bool) -> update.UpdateResult: + assert print_output is True calls.append("update") return update.UpdateResult.UPDATED @@ -113,7 +113,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.EXIT - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("exit must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -139,7 +139,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.SKIP - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("declining must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -165,7 +165,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.DISMISS_VERSION - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("dismissing must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -268,8 +268,8 @@ async def test_resolve_latest_version_fetches_when_due(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("2.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -291,8 +291,8 @@ async def test_resolve_latest_version_fetches_when_cache_missing(monkeypatch, tm last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("2.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -311,7 +311,7 @@ async def test_resolve_latest_version_uses_cache_when_not_due(monkeypatch, tmp_p latest_file = tmp_path / "latest.txt" latest_file.write_text("3.1.0", encoding="utf-8") - async def fail_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: raise AssertionError("must not hit the network when the throttle is not due") monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file) @@ -328,8 +328,8 @@ async def test_resolve_latest_version_revalidates_stale_cache_when_not_due(monke last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("999.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -378,7 +378,7 @@ async def test_refresh_cache_does_not_throttle_on_failure(monkeypatch, tmp_path) latest_file = tmp_path / "latest.txt" last_check_file = tmp_path / "last_update_check.txt" - async def failing_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def failing_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: return update.UpdateResult.FAILED monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file) @@ -398,7 +398,7 @@ async def failing_do_update(*, print: bool, check_only: bool) -> update.UpdateRe async def test_refresh_cache_does_not_throttle_on_exception(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" - async def raising_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def raising_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: raise RuntimeError("boom") monkeypatch.setattr(update, "LAST_UPDATE_CHECK_FILE", last_check_file) @@ -554,7 +554,7 @@ async def fake_unavailable(session, latest_version: str, upgrade_command: list[s monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) monkeypatch.setattr(update, "new_client_session", lambda timeout: _FakeSessionContext(object())) - result = await update.do_update(print=False, check_only=True) + result = await update.do_update(print_output=False, check_only=True) assert result is update.UpdateResult.FAILED assert not latest_file.exists() @@ -645,8 +645,8 @@ async def test_resolve_latest_version_can_force_refresh(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("3.2.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -692,12 +692,77 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update.subprocess, "run", fake_run) with pytest.raises(SystemExit) as excinfo: - await update.do_update(print=False, check_only=False) + await update.do_update(print_output=False, check_only=False) assert excinfo.value.code == 0 assert spawned and "pythinker-code" in spawned[0] +def test_run_upgrade_command_streams_subprocess_output(monkeypatch): + messages: list[str] = [] + launched: list[list[str]] = [] + + class FakeProc: + stdout = ["first line\n", "second line\n"] + + def wait(self, *, timeout: float) -> int: + assert timeout == update.UPGRADE_COMMAND_TIMEOUT_SECONDS + return 0 + + def fake_popen(command, **kwargs): + launched.append(command) + assert kwargs["stdout"] is update.subprocess.PIPE + assert kwargs["stderr"] is update.subprocess.STDOUT + assert kwargs["text"] is True + return FakeProc() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + returncode = update._run_upgrade_command( + ["uv", "tool", "upgrade", "pythinker-code"], + print_output=False, + output_callback=messages.append, + ) + + assert returncode == 0 + assert launched == [["uv", "tool", "upgrade", "pythinker-code"]] + assert messages == ["first line", "second line"] + + +@pytest.mark.asyncio +async def test_do_update_reports_non_native_upgrade_failure_to_callback(monkeypatch, tmp_path): + messages: list[str] = [] + + async def fake_get_latest(session): + return "999.0.0" + + async def fake_unavailable(session, latest_version: str, upgrade_command: list[str]): + return None + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + assert command == ["uv", "tool", "upgrade", "pythinker-code"] + assert print_output is False + assert output_callback is not None + output_callback("installer said no") + return 2 + + monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt") + monkeypatch.setattr(update, "_get_latest_version", fake_get_latest) + monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) + monkeypatch.setattr( + update, + "_detect_upgrade_command", + lambda: ["uv", "tool", "upgrade", "pythinker-code"], + ) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert "installer said no" in messages + assert any("Upgrade failed" in message for message in messages) + + @pytest.mark.asyncio async def test_do_update_uses_native_installer_marker(monkeypatch, tmp_path): native_versions: list[str] = [] @@ -722,7 +787,9 @@ async def fake_unavailable(session, latest_version: str, upgrade_command: list[s monkeypatch.setattr(update, "_maybe_run_native_update", fake_native_update) monkeypatch.setattr(update.subprocess, "run", fake_run) - assert await update.do_update(print=False, check_only=False) is update.UpdateResult.UPDATED + assert ( + await update.do_update(print_output=False, check_only=False) is update.UpdateResult.UPDATED + ) assert native_versions == ["999.0.0"] diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index ffd2ab70..0cb1d529 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -69,3 +69,114 @@ def test_welcome_banner_no_chip_unchanged(monkeypatch): # Both paths produce the same output when banner is None. assert out_without == out_with assert "Welcome to Pythinker" in out_without + + +def test_welcome_chip_renders_in_footer_not_header(monkeypatch): + console = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + + chip = Text("✦ What's new in v9.9.9 · /changelog") + shell_module._print_welcome_info("Pythinker Code", [], banner=chip) + + lines = [ln for ln in console.export_text().splitlines() if ln.strip()] + # Chip sits on the bottom border (footer), not in the header. + assert "changelog" in lines[-1] + assert all("changelog" not in ln for ln in lines[:3]) + + +def test_welcome_info_grid_has_no_pipe_separator(monkeypatch): + from pythinker_code.ui.shell import WelcomeInfoItem + + console = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + + items = [WelcomeInfoItem(name="Directory", value="/tmp/proj")] + shell_module._print_welcome_info("Pythinker Code", items) + + out = console.export_text() + dir_line = next(ln for ln in out.splitlines() if "Directory" in ln) + # Only the two panel-edge pipes remain; the separator column is gone. + assert dir_line.count("│") == 2 + assert "/tmp/proj" in dir_line + + +def test_welcome_strapline_and_help_on_separate_lines(monkeypatch): + console = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + + shell_module._print_welcome_info("Pythinker Code", []) + + out = console.export_text() + assert "Build with confidence." in out + assert "Type /help for commands." in out + # The strapline and the help line must not share one rendered line. + assert not any("Build with confidence." in ln and "Type /help" in ln for ln in out.splitlines()) + + +def test_welcome_banner_layout_width_matrix(monkeypatch): + from pythinker_code.ui.shell import WelcomeInfoItem + from pythinker_code.ui.shell.components.render_utils import cell_width + + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + items = [ + WelcomeInfoItem(name="Directory", value="/home/ai/Projects/pythinker-code-main"), + WelcomeInfoItem(name="Model", value="gpt-5.1-codex"), + WelcomeInfoItem(name="Branch", value="feat/welcome-banner-redesign"), + WelcomeInfoItem( + name="Tip", + value="Use /update after release promotion completes and /help for commands.", + ), + ] + + for width in (60, 80, 120): + console = Console(record=True, width=width, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + + shell_module._print_welcome_info( + "Pythinker Code", + items, + banner=Text("↑ Update available — v9.9.10 · /update"), + ) + + output = console.export_text() + lines = [line.rstrip() for line in output.splitlines() if line.strip()] + max_panel_width = min(width, shell_module._WELCOME_MAX_WIDTH) + assert all(cell_width(line) <= max_panel_width for line in lines) + assert "Pythinker Code v9.9.9" in lines[0] + assert "Welcome to Pythinker" in output + assert "Directory" in output + assert "gpt-5.1-codex" in output + assert "Tips" in output + assert "/update" in lines[-1] + assert "/help" in output + if width == 60: + assert "▛" not in output + else: + assert "▛" in output + + +def test_welcome_auto_save_path_is_middle_truncated_not_wrapped(monkeypatch): + from pythinker_code.ui.shell import WelcomeInfoItem + + console = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + + items = [ + WelcomeInfoItem( + name="Auto-save", + value=( + "~/.pythinker/sessions/91ce869d5afa3e6547c32cb5b58fa943/" + "6b76c556-cae9-47e2-8233-38ecf986624e/context.json" + ), + ) + ] + shell_module._print_welcome_info("Pythinker Code", items) + + lines = [ln for ln in console.export_text().splitlines() if "Auto-save" in ln] + assert len(lines) == 1 + assert "…" in lines[0] + assert "context.json" in lines[0] diff --git a/tests/ui_and_conv/test_terminal_capabilities.py b/tests/ui_and_conv/test_terminal_capabilities.py new file mode 100644 index 00000000..1e5ef9e1 --- /dev/null +++ b/tests/ui_and_conv/test_terminal_capabilities.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from typing import TextIO, cast + +from pythinker_code.ui.terminal_capabilities import ( + ascii_glyphs_enabled, + colors_disabled, + motion_disabled, +) + + +def test_color_capability_honors_standard_env_vars() -> None: + assert colors_disabled({"NO_COLOR": "1"}) + assert colors_disabled({"TERM": "dumb"}) + assert colors_disabled({"CLICOLOR": "0"}) + assert colors_disabled({"PYTHINKER_NO_COLOR": "true"}) + assert not colors_disabled({"TERM": "xterm-256color"}) + + +def test_ascii_glyphs_are_opt_in_or_minimal_terminal() -> None: + assert ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "ascii"}) + assert ascii_glyphs_enabled({"PYTHINKER_ASCII_UI": "1"}) + assert ascii_glyphs_enabled({"TERM": "dumb"}) + legacy_stdout = cast(TextIO, SimpleNamespace(encoding="cp1252")) + assert ascii_glyphs_enabled({"TERM": "xterm"}, stdout=legacy_stdout) + assert not ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "unicode", "TERM": "dumb"}) + + +def test_motion_capability_honors_static_output_env_vars() -> None: + assert motion_disabled({"TERM": "dumb"}) + assert motion_disabled({"PYTHINKER_REDUCED_MOTION": "1"}) + assert motion_disabled({"PYTHINKER_NO_ANIMATION": "true"}) + assert motion_disabled({"PYTHINKER_STATIC_OUTPUT": "yes"}) + assert not motion_disabled({"TERM": "xterm-256color"}) + + +def test_theme_resolvers_strip_colors_when_no_color_is_set(monkeypatch) -> None: + from pythinker_code.ui.theme import ( + _strip_ptk_colors, + get_diff_colors, + get_toolbar_colors, + markdown_rich_style, + tui_rich_style, + ) + + monkeypatch.setenv("NO_COLOR", "1") + + assert tui_rich_style("accent").color is None + assert tui_rich_style("tool_error_bg").bgcolor is None + assert markdown_rich_style("link").color is None + assert get_diff_colors().add_bg.bgcolor is None + assert get_toolbar_colors().tip_key == "bold" + assert _strip_ptk_colors("bg:#112233 fg:#abcdef bold italic") == "bold italic" + + +def test_ascii_glyph_mode_uses_plain_fallbacks(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("PYTHINKER_TUI_GLYPHS", "ascii") + ascii_glyphs = importlib.reload(glyphs) + try: + assert ascii_glyphs.SPINNER_FRAMES == ("-", "\\", "|", "/") + assert ascii_glyphs.REDUCED_MOTION_GLYPH == "*" + assert ascii_glyphs.TRANSCRIPT_PROMPT_MARKER == ">" + assert ascii_glyphs.TRANSCRIPT_TOOL_GUTTER == "|" + finally: + monkeypatch.delenv("PYTHINKER_TUI_GLYPHS", raising=False) + importlib.reload(glyphs) + + +def test_term_dumb_glyph_mode_uses_plain_fallbacks(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("TERM", "dumb") + plain_glyphs = importlib.reload(glyphs) + try: + assert plain_glyphs.SPINNER_FRAMES == ("-", "\\", "|", "/") + assert plain_glyphs.TRANSCRIPT_PROMPT_MARKER == ">" + assert plain_glyphs.TRANSCRIPT_TOOL_GUTTER == "|" + finally: + monkeypatch.delenv("TERM", raising=False) + importlib.reload(glyphs) + + +def test_explicit_unicode_glyph_mode_overrides_term_dumb(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("TERM", "dumb") + monkeypatch.setenv("PYTHINKER_TUI_GLYPHS", "unicode") + unicode_glyphs = importlib.reload(glyphs) + try: + assert unicode_glyphs.SPINNER_FRAMES[0] == "⠋" + assert unicode_glyphs.REDUCED_MOTION_GLYPH == "●" + assert unicode_glyphs.TRANSCRIPT_PROMPT_MARKER == "❯" + assert unicode_glyphs.TRANSCRIPT_TOOL_GUTTER == "⎿" + finally: + monkeypatch.delenv("TERM", raising=False) + monkeypatch.delenv("PYTHINKER_TUI_GLYPHS", raising=False) + importlib.reload(glyphs) + + +def test_reduced_motion_env_pins_runtime_activity_marker(monkeypatch) -> None: + from pythinker_code.ui.shell.glyphs import REDUCED_MOTION_GLYPH + from pythinker_code.ui.shell.motion import ( + ActivitySnapshot, + active_marker_frame, + activity_status_line, + ) + + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + + assert active_marker_frame(0.0) == REDUCED_MOTION_GLYPH + assert active_marker_frame(10.0) == REDUCED_MOTION_GLYPH + line = activity_status_line(ActivitySnapshot(label="Thinking", elapsed_s=10.0)) + assert line.plain.startswith(f"{REDUCED_MOTION_GLYPH} Thinking") diff --git a/tests/ui_and_conv/test_tool_call_block.py b/tests/ui_and_conv/test_tool_call_block.py index 47ce29e8..e9d95e68 100644 --- a/tests/ui_and_conv/test_tool_call_block.py +++ b/tests/ui_and_conv/test_tool_call_block.py @@ -73,7 +73,7 @@ def test_tool_call_block_renders_running_worklog_entry(): assert "running" in output.lower() -def test_tool_call_block_renders_running_subagent_with_solid_circle(monkeypatch): +def test_tool_call_block_renders_running_subagent_with_text_safe_solid_circle(monkeypatch): monkeypatch.setattr(_worklog.time, "monotonic", lambda: 0.0) block = _ToolCallBlock(_tool_call("Agent", '{"description":"Audit UI"}')) output = _plain(block.compose()) @@ -168,9 +168,34 @@ def test_completed_subagent_renders_compact_summary(): assert "Subagent" in output assert "completed" in output.lower() assert "7 tool calls" in output + assert "tools: Read ×7" in output assert output.count("ReadFile") <= 4 +def test_completed_subagent_summarizes_changed_files_and_tool_counts(): + block = _ToolCallBlock(_tool_call("Agent", '{"description":"Implement UI"}')) + calls = [ + _tool_call_with_id("read-1", "ReadFile", json.dumps({"path": "src/app.py"})), + _tool_call_with_id("read-2", "ReadFile", json.dumps({"path": "src/ui.py"})), + _tool_call_with_id("write-1", "WriteFile", json.dumps({"path": "src/new.py"})), + _tool_call_with_id("edit-1", "StrReplaceFile", json.dumps({"path": "src/existing.py"})), + _tool_call_with_id("shell-1", "Shell", json.dumps({"command": "pytest"})), + ] + for call in calls: + block.append_sub_tool_call(call) + block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) + + block.finish(ToolOk(output="done")) + output = _plain(block.compose()) + + assert "tools:" in output + assert "Read ×2" in output + assert "Write" in output + assert "Edit" in output + assert "Shell" in output + assert "changed: src/new.py, src/existing.py" in output + + def test_append_sub_output_part_accumulates_text(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) call = _tool_call_with_id("sub-1", "Bash", '{"command":"ls"}') diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index dda8d629..38bf4766 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -185,7 +185,7 @@ def test_card_style_running_subagent_uses_solid_circle(_force_card_style, monkey assert "Agent(" in rendered assert "Audit UI" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) block.finish(_ok_result("done")) @@ -210,7 +210,7 @@ def test_card_style_finished_subagent_shows_compact_result(_force_card_style, mo block.finish(_ok_result("done")) rendered = render_plain(block.compose(), width=80) - assert "⏺ Agent(coder · Audit UI)" in rendered + assert "● Agent(coder · Audit UI)" in rendered assert "⎿ done" in rendered assert "Agent finished" not in rendered @@ -234,7 +234,7 @@ def test_card_style_running_task_output_uses_solid_circle(_force_card_style, mon assert "TaskOutput(" in rendered assert "agent-123" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) @@ -258,9 +258,9 @@ def test_card_style_running_subagent_marker_pulses(_force_card_style, monkeypatc assert first != second assert "Agent(" in first - assert "⏺" in first + assert "●" in first assert "Agent(" in second - assert "⏺" not in second + assert "●" not in second def test_card_style_background_subagent_result_keeps_solid_circle(_force_card_style, monkeypatch): @@ -291,7 +291,7 @@ def test_card_style_background_subagent_result_keeps_solid_circle(_force_card_st assert "background subagent working" in rendered assert "background audit" in rendered assert "status: running" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) @@ -326,7 +326,7 @@ def test_card_style_background_subagent_marker_pulses(_force_card_style, monkeyp assert first != second assert "background subagent working" in first - assert "⎿ ⏺ background subagent working" in first + assert "⎿ ● background subagent working" in first assert "background subagent working" in second assert "⎿ background subagent working" in second diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 7d098077..1a56427b 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -99,11 +99,11 @@ def test_loading_marker_pulses_muted_transcript_dot_then_finishes_green(): hidden = loading_marker(now=0.9) done = loading_marker(done=True) - assert visible.plain == "⏺ " + assert visible.plain == "● " assert visible.style == tui_rich_style("muted") assert hidden.plain == " " assert hidden.style == tui_rich_style("muted") - assert done.plain == "⏺ " + assert done.plain == "● " assert done.style == tui_rich_style("success") @@ -118,7 +118,7 @@ def test_read_renders_path_and_range(): {"path": "/repo/src/foo.py", "line_offset": 10, "n_lines": 30}, output="line1\nline2", ) - assert "⏺ Read(" in rendered + assert "● Read(" in rendered assert "src/foo.py" in rendered assert ":10-39" in rendered assert "Read 1 file (ctrl+o to expand)" in rendered @@ -206,7 +206,7 @@ def test_write_shows_path_and_content_preview(): {"path": "/repo/new.py", "content": "def f():\n return 1\n"}, output="Successfully wrote", ) - assert "⏺ Write(new.py)" in rendered + assert "● Write(new.py)" in rendered assert "Wrote 2 lines to new.py" in rendered assert "1 def f():" in rendered @@ -317,7 +317,7 @@ def test_grep_renders_pattern_and_path(): {"pattern": "def\\s+", "path": "/repo/src", "glob": "*.py"}, output="src/foo.py:10: def hello():", ) - assert "⏺ Search(" in rendered + assert "● Search(" in rendered assert "/def\\s+/" in rendered assert "src" in rendered assert "*.py" in rendered @@ -343,7 +343,7 @@ def test_invalid_empty_grep_call_names_missing_pattern(): ), is_error=True, ) - assert "⏺ Search( in .)" in rendered + assert "✘ Search( in .)" in rendered assert "Error searching files" in rendered assert "Search(... in .)" not in rendered @@ -359,7 +359,7 @@ def test_glob_renders_pattern_and_directory(): {"pattern": "**/*.py", "directory": "/repo/src"}, output="src/a.py\nsrc/b.py", ) - assert "⏺ Find(" in rendered + assert "● Find(" in rendered assert "**/*.py" in rendered assert "Found 2 files" in rendered @@ -371,7 +371,7 @@ def test_glob_renders_pattern_and_directory(): def test_shell_renders_command_and_output_under_response_gutter(): rendered = _render("Shell", {"command": "ls -la", "timeout": 60}, output="total 0") - assert "⏺ Bash(ls -la)" in rendered + assert "● Bash(ls -la)" in rendered assert "total 0" in rendered assert "⎿" in rendered @@ -459,7 +459,7 @@ def test_shell_error_uses_structured_exit_code_when_available(): def test_shell_uses_comment_label_for_long_script(): command = "# build assets\n" + "\n".join(f"echo {i}" for i in range(5)) rendered = _render("Shell", {"command": command, "timeout": 60}, output="ok") - assert "⏺ Bash(build assets)" in rendered + assert "● Bash(build assets)" in rendered assert "echo 0" not in rendered @@ -517,8 +517,7 @@ def test_running_tool_headers_do_not_duplicate_status_bullets(): for tool, args, label in cases: rendered = _render_running(tool, args, width=64) assert label in rendered - assert "⏺ ⏺" not in rendered - assert "⏺ ⏺" not in rendered + assert "● ●" not in rendered def test_streaming_missing_args_use_preparing_rows_not_tool_ellipsis_placeholders(): @@ -555,7 +554,7 @@ def test_invalid_empty_shell_call_names_missing_command(): ), is_error=True, ) - assert "⏺ Bash()" in rendered + assert "✘ Bash()" in rendered assert "$ ..." not in rendered @@ -671,7 +670,7 @@ def test_agent_renders_type_and_description_without_prompt_preview(): }, output="Plan ready", ) - assert "⏺ Agent(" in rendered + assert "● Agent(" in rendered assert "code-architect" in rendered assert "design auth flow" in rendered assert "Prompt: Design the OAuth flow with PKCE" not in rendered @@ -751,7 +750,7 @@ def test_run_agents_renders_compact_professional_summary(): ), width=120, ) - assert "⏺ RunAgents(" in rendered + assert "● RunAgents(" in rendered assert "2 agents" in rendered assert "foreground" in rendered assert "code_scan" in rendered @@ -784,7 +783,7 @@ def test_ask_user_renders_question_and_options(): ] }, ) - assert "⏺ Ask(1 question)" in rendered + assert "● Ask(1 question)" in rendered assert "Which auth method?" in rendered assert "OAuth" in rendered assert "API key" in rendered @@ -811,7 +810,7 @@ def test_ask_user_renders_single_question_object_without_invalid_badge(): }, ) - assert "⏺ Ask(1 question)" in rendered + assert "● Ask(1 question)" in rendered assert "" not in rendered assert "How should independent analyses run?" in rendered assert "Run concurrently (Recommended)" in rendered @@ -824,7 +823,7 @@ def test_ask_user_renders_single_question_object_without_invalid_badge(): def test_think_renders_thought_body(): rendered = _render("Think", {"thought": "First, check the file layout.\nThen draft a fix."}) - assert "⏺ Think" in rendered + assert "● Think" in rendered assert "First, check the file layout." in rendered @@ -877,7 +876,7 @@ def test_todo_infers_nested_items_from_leading_spaces(): def test_fetch_renders_url(): rendered = _render("FetchURL", {"url": "https://example.com/page"}, output="...") - assert "⏺ Fetch(" in rendered + assert "● Fetch(" in rendered assert "example.com" in rendered assert "Received 9 bytes" in rendered @@ -888,7 +887,7 @@ def test_search_renders_query_and_extras(): {"query": "python typing", "limit": 10, "include_content": True}, output="result 1", ) - assert "⏺ WebSearch(" in rendered + assert "● WebSearch(" in rendered assert "python typing" in rendered assert "limit 10" in rendered assert "with content" in rendered @@ -952,7 +951,7 @@ def test_search_all_results_filtered_reports_zero(): def test_task_list_renders_active_flag(): rendered = _render("TaskList", {"active_only": True}, output="task-1: running") - assert "⏺ Tasks(active)" in rendered + assert "● Tasks(active)" in rendered def test_task_output_renders_id_and_block_flag(): @@ -961,14 +960,14 @@ def test_task_output_renders_id_and_block_flag(): {"task_id": "abc-123", "block": True, "timeout": 60}, output="logs...", ) - assert "⏺ TaskOutput(" in rendered + assert "● TaskOutput(" in rendered assert "abc-123" in rendered assert "block" in rendered def test_task_stop_renders_id(): rendered = _render("TaskStop", {"task_id": "abc-123", "reason": "user requested"}) - assert "⏺ TaskStop(" in rendered + assert "● TaskStop(" in rendered assert "abc-123" in rendered @@ -979,7 +978,7 @@ def test_task_stop_renders_id(): def test_enter_plan_mode_renders(): rendered = _render("EnterPlanMode", {}) - assert "⏺ Plan(entering)" in rendered + assert "● Plan(entering)" in rendered def test_exit_plan_mode_renders_options(): @@ -992,7 +991,7 @@ def test_exit_plan_mode_renders_options(): ] }, ) - assert "⏺ Plan(exiting)" in rendered + assert "● Plan(exiting)" in rendered assert "Refactor first" in rendered assert "Add tests first" in rendered @@ -1006,7 +1005,7 @@ def test_card_renders_compact_without_outer_padding(): """Compact tool cards should start at the title and avoid extra outer padding.""" rendered = _render("Glob", {"pattern": "*.py", "directory": "/repo"}, output="foo.py") lines = [line.strip() for line in rendered.splitlines()] - assert lines[0] == "⏺ Find(*.py in /repo)" + assert lines[0] == "● Find(*.py in /repo)" assert lines[-1] == "⎿ Found 1 file ctrl+o expand" diff --git a/tests/ui_and_conv/test_tui_components.py b/tests/ui_and_conv/test_tui_components.py index a68cb4ec..15353082 100644 --- a/tests/ui_and_conv/test_tui_components.py +++ b/tests/ui_and_conv/test_tui_components.py @@ -251,7 +251,7 @@ def test_bash_execution_uses_codex_style_compact_layout(): width=80, ) - assert "⏺ Ran $ printf hello" in out + assert "● Ran $ printf hello" in out assert "⎿ hello" in out assert " world" in out assert "─" not in out @@ -279,8 +279,8 @@ def test_bash_execution_ignores_shebang_when_extracting_comment_label(): width=80, ) - assert "⏺ Ran $ usr/bin/env bash" not in shebang_only - assert "⏺ Ran $ Build docs" in with_label + assert "● Ran $ usr/bin/env bash" not in shebang_only + assert "● Ran $ Build docs" in with_label assert "echo ok" not in with_label @@ -358,5 +358,5 @@ def test_bash_execution_running_marker_pulses(monkeypatch): ) assert first != second - assert "⏺ Running $ sleep 1" in first + assert "● Running $ sleep 1" in first assert "Running $ sleep 1" in second diff --git a/tests/ui_and_conv/test_update_orchestrator.py b/tests/ui_and_conv/test_update_orchestrator.py new file mode 100644 index 00000000..5ea3da7f --- /dev/null +++ b/tests/ui_and_conv/test_update_orchestrator.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import json +import os +import time +from types import SimpleNamespace + +import pytest + +from pythinker_code.ui.shell import update +from pythinker_code.ui.shell import update_orchestrator as orchestrator + + +def _isolate_update_files(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") + monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") + monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") + monkeypatch.setattr( + orchestrator, + "UPDATE_LAST_SUCCESS_FILE", + tmp_path / "update_last_success.json", + ) + + +@pytest.mark.asyncio +async def test_update_job_records_status_and_log(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + assert print_output is False + assert check_only is True + assert output_callback is not None + output_callback("checked release channel") + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + monkeypatch.setattr(orchestrator, "_read_target_version", lambda: "1.2.3") + + result = await orchestrator.run_update_job(print_output=False, check_only=True, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.UP_TO_DATE + assert status.result == "UP_TO_DATE" + assert status.target_version == "1.2.3" + assert "checked release channel" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_blocks_when_another_process_holds_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text( + json.dumps({"pid": os.getpid(), "started_at": time.time()}), + encoding="utf-8", + ) + + async def fail_do_update(**_kwargs): + raise AssertionError("locked update must not call do_update") + + monkeypatch.setattr(update, "do_update", fail_do_update) + + orchestrator.write_update_status( + orchestrator.UpdateJobStatus( + job_id="running-job", + state=orchestrator.UpdateJobState.RUNNING, + started_at=time.time(), + finished_at=None, + current_version="1.0.0", + target_version=None, + result=None, + message="still running", + log_path=str(orchestrator.UPDATE_LOG_FILE), + pid=os.getpid(), + ) + ) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.FAILED + status = orchestrator.read_update_status() + assert status is not None + assert status.job_id == "running-job" + assert status.state is orchestrator.UpdateJobState.RUNNING + assert "already running" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_blocks_on_fresh_malformed_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text("", encoding="utf-8") + + async def fail_do_update(**_kwargs): + raise AssertionError("fresh malformed lock must not call do_update") + + monkeypatch.setattr(update, "do_update", fail_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.FAILED + assert orchestrator.UPDATE_LOCK_FILE.exists() + assert orchestrator.read_update_status() is None + assert "already running" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_replaces_old_malformed_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text("", encoding="utf-8") + old_time = time.time() - orchestrator._LOCK_MALFORMED_GRACE_SECONDS - 1 + os.utime(orchestrator.UPDATE_LOCK_FILE, (old_time, old_time)) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + + +@pytest.mark.asyncio +async def test_update_job_replaces_stale_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text( + json.dumps({"pid": 123456789, "started_at": time.time()}), + encoding="utf-8", + ) + monkeypatch.setattr(orchestrator, "_pid_exists", lambda _pid: False) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.UP_TO_DATE + + +@pytest.mark.asyncio +async def test_update_job_skips_success_marker_when_post_install_smoke_check_fails( + monkeypatch, tmp_path +): + _isolate_update_files(monkeypatch, tmp_path) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UPDATED + + monkeypatch.setattr(update, "do_update", fake_do_update) + monkeypatch.setattr( + orchestrator, + "run_post_install_smoke_check", + lambda: (False, "Smoke check failed: broken"), + ) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.UPDATED + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.UPDATED + assert status.result == "UPDATED" + assert "smoke check did not pass" in (status.message or "").lower() + assert not orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() + + +@pytest.mark.asyncio +async def test_run_update_prompt_routes_check_through_runner(monkeypatch): + calls: list[bool] = [] + + async def fail_do_update(**_kwargs): + raise AssertionError("orchestrated /update check must not call do_update directly") + + async def fake_runner(*, print_output: bool, check_only: bool): + assert print_output is True + calls.append(check_only) + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fail_do_update) + + result = await update.run_update_prompt(update_runner=fake_runner) + + assert result is update.UpdateResult.UP_TO_DATE + assert calls == [True] + + +def test_update_log_tail_returns_recent_lines(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + for idx in range(5): + orchestrator.append_update_log(f"line {idx}") + + assert orchestrator.read_update_log_tail(2) == ["line 3", "line 4"] + + +def test_update_log_redacts_sensitive_output_and_is_owner_only(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + orchestrator.append_update_log( + "Authorization: Bearer secret-token-12345 " + "PIP_INDEX_URL=https://user:pass@example.test/simple" + ) + + log_text = orchestrator.UPDATE_LOG_FILE.read_text(encoding="utf-8") + assert "secret-token-12345" not in log_text + assert "user:pass" not in log_text + assert " None: + raise AssertionError("Windows PID checks must not call os.kill") + + monkeypatch.setattr(orchestrator.os, "kill", fail_kill) + + assert orchestrator._pid_exists(os.getpid() + 1) is True + + +def test_python_smoke_check_uses_safe_import_path(monkeypatch): + monkeypatch.setattr(orchestrator, "is_native_build", lambda: False) + monkeypatch.setattr(orchestrator.sys, "executable", "/tmp/venv/bin/python") + + assert orchestrator._smoke_check_command() == [ + "/tmp/venv/bin/python", + "-P", + "-m", + "pythinker_code", + "--version", + ] + + +def test_native_smoke_check_does_not_use_python_module_import(monkeypatch): + monkeypatch.setattr(orchestrator, "is_native_build", lambda: True) + monkeypatch.setattr(orchestrator.sys, "executable", "/opt/pythinker/pythinker") + + assert orchestrator._smoke_check_command() == ["/opt/pythinker/pythinker", "--version"] + + +@pytest.mark.asyncio +async def test_do_update_mirrors_messages_to_output_callback(monkeypatch, tmp_path): + messages: list[str] = [] + + async def fake_get_latest(session): + return "999.0.0" + + async def fake_unavailable(session, latest_version: str, upgrade_command: list[str]): + return None + + async def fake_native_update(latest_version: str) -> update.UpdateResult: + return update.UpdateResult.UPDATED + + monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt") + monkeypatch.setattr(update, "_get_latest_version", fake_get_latest) + monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) + monkeypatch.setattr(update, "_detect_upgrade_command", lambda: [update.NATIVE_INSTALLER_MARKER]) + monkeypatch.setattr(update, "_maybe_run_native_update", fake_native_update) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.UPDATED + assert any("Checking for updates" in message for message in messages) + assert any("Updating pythinker-code" in message for message in messages) + + +def test_smoke_check_reports_success(monkeypatch): + monkeypatch.setattr(orchestrator, "_smoke_check_command", lambda: ["pythinker", "--version"]) + monkeypatch.setenv("PYTHONPATH", "/tmp/untrusted") + + def fake_run(command, **kwargs): + assert command == ["pythinker", "--version"] + assert kwargs["timeout"] == orchestrator._SMOKE_CHECK_TIMEOUT_SECONDS + assert kwargs["env"]["PYTHONSAFEPATH"] == "1" + assert "PYTHONPATH" not in kwargs["env"] + assert kwargs["cwd"] == orchestrator._smoke_check_cwd() + return SimpleNamespace(returncode=0, stdout="pythinker, version 1.2.3\n", stderr="") + + monkeypatch.setattr(orchestrator.subprocess, "run", fake_run) + + ok, message = orchestrator.run_post_install_smoke_check() + + assert ok is True + assert "1.2.3" in message + + +def test_smoke_check_reports_failure(monkeypatch): + monkeypatch.setattr(orchestrator, "_smoke_check_command", lambda: ["pythinker", "--version"]) + + def fake_run(command, **kwargs): + return SimpleNamespace(returncode=1, stdout="", stderr="broken\n") + + monkeypatch.setattr(orchestrator.subprocess, "run", fake_run) + + ok, message = orchestrator.run_post_install_smoke_check() + + assert ok is False + assert "broken" in message diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 7d32e3fc..77b23e31 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -224,7 +224,7 @@ def test_prompt_status_block_renders_above_agent_input_preamble() -> None: from prompt_toolkit.formatted_text import FormattedText def _status_block(_columns: int) -> FormattedText: - return FormattedText([("", "● Booting MCP server: context7")]) + return FormattedText([("", "• Booting MCP server: context7")]) session = object.__new__(CustomPromptSession) session._running_prompt_delegate = None @@ -234,7 +234,7 @@ def _status_block(_columns: int) -> FormattedText: rendered = CustomPromptSession._render_agent_status(session, 80) text = "".join(item[1] for item in rendered) - assert text.startswith("● Booting MCP server: context7") + assert text.startswith("• Booting MCP server: context7") def test_background_status_splits_verb_and_count_styles(monkeypatch) -> None: diff --git a/tests/ui_and_conv/test_worklog_render.py b/tests/ui_and_conv/test_worklog_render.py index 2c75e2ea..a07cd070 100644 --- a/tests/ui_and_conv/test_worklog_render.py +++ b/tests/ui_and_conv/test_worklog_render.py @@ -28,10 +28,13 @@ def test_tool_style_maps_common_tools_to_professional_labels(): assert tool_style("ReadFile").label == "Read" assert tool_style("Grep").label == "Search" assert tool_style("Edit").label == "Edit" + assert tool_style("StrReplaceFile").label == "Edit" assert tool_style("ApplyPatch").label == "Patch" assert tool_style("Bash").label == "Shell" + assert tool_style("SetTodoList").label == "Todo" assert tool_style("TodoWrite").label == "Todo" assert tool_style("Agent").label == "Subagent" + assert tool_style("RunAgents").label == "Subagents" assert tool_style("Agent").icon == "●" from pythinker_code.ui.theme import get_tui_tokens, set_active_theme diff --git a/web/src/components/ai-elements/subagent-steps.tsx b/web/src/components/ai-elements/subagent-steps.tsx index 18b66999..6446c29c 100644 --- a/web/src/components/ai-elements/subagent-steps.tsx +++ b/web/src/components/ai-elements/subagent-steps.tsx @@ -10,12 +10,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Shimmer } from "./shimmer"; -import { - CheckIcon, - ChevronRightIcon, - Loader2Icon, - XIcon, -} from "lucide-react"; +import { ChevronRightIcon, XIcon } from "lucide-react"; // --------------------------------------------------------------------------- // SubagentActivity — top-level wrapper rendered inside Tool's ToolContent area @@ -56,16 +51,20 @@ export const SubagentActivity = memo( {...props} > - + {hasError && !isRunning ? ( + + ) : ( + )} - /> + {isRunning ? ( <> @@ -78,6 +77,12 @@ export const SubagentActivity = memo( ... + ) : hasError ? ( + toolCallCount > 0 ? ( + `${agentLabel} failed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}` + ) : ( + `${agentLabel} failed` + ) ) : toolCallCount > 0 ? ( `${agentLabel} completed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}` ) : ( @@ -157,11 +162,21 @@ const getPrimaryParam = (input: unknown): string | null => { const getSubToolStatusIcon = (status: string) => { switch (status) { case "success": - return ; + return ( + + + + ); case "error": return ; default: - return ; + // Running: pulse a grey solid circle; keep the footprint aligned with + // the finished green dot and failed red X states. + return ( + + + + ); } };