diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml index 82d7d883..a3637e4a 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -9,7 +9,7 @@ body: value: | Thank you for submitting a bug report! It helps make Pythinker Code better for everyone. - If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-code/discussions](https://github.com/Pythoughts-labs/pythinker-code/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. + If you need help or support using Pythinker Code, and are not reporting a bug, please post on [pythinker-cli/discussions](https://github.com/Pythoughts-labs/pythinker-cli/discussions), where you can ask questions or engage with others on ideas for how to improve Pythinker Code. Make sure you are running the latest version of Pythinker Code (`uv tool upgrade pythinker-code` to upgrade). The bug you are experiencing may already have been fixed. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e6451805..ef3acd5d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Questions and General Discussion - url: https://github.com/Pythoughts-labs/pythinker-code/discussions + url: https://github.com/Pythoughts-labs/pythinker-cli/discussions about: Have questions? Welcome to open a discussion! diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9beee5e5..019355ea 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,7 +3,7 @@ Thank you for your contribution to Pythinker Code! Please make sure you already discussed the feature or bugfix you are proposing in an issue with the maintainers. Please understand that if you have not gotten confirmation from the maintainers, your pull request may be closed or ignored without further review due to limited bandwidth. -See https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md for more. +See https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md for more. --> ## Related Issue @@ -18,7 +18,7 @@ Resolve #(issue_number) ## Checklist -- [ ] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. +- [ ] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md) document. - [ ] I have linked the related issue, if any. - [ ] I have added tests that prove my fix is effective or that my feature works. - [ ] I have run `make gen-changelog` to update the changelog. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8d6a4428..2c223598 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -21,7 +21,7 @@ concurrency: jobs: build: # Only run on the original repository, not on forks - if: github.repository == 'Pythoughts-labs/pythinker-code' + if: github.repository == 'Pythoughts-labs/pythinker-cli' runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 8697477d..82cd8217 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -158,14 +158,14 @@ jobs: cat > README.md < README.md <<'EOF' # scoop-pythinker - Scoop bucket for [Pythinker Code](https://github.com/Pythoughts-labs/pythinker-code). + Scoop bucket for [Pythinker Code](https://github.com/Pythoughts-labs/pythinker-cli). ```pwsh scoop bucket add pythinker https://github.com/Pythoughts-labs/scoop-pythinker @@ -137,7 +137,7 @@ jobs: ``` This bucket is auto-updated by the - [scoop-bucket.yml](https://github.com/Pythoughts-labs/pythinker-code/blob/main/.github/workflows/scoop-bucket.yml) + [scoop-bucket.yml](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/.github/workflows/scoop-bucket.yml) workflow on every semver release tag. Do not hand-edit `bucket/*` — your edits will be overwritten on the next release. EOF diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 49420d0a..3e372523 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -45,7 +45,7 @@ jobs: if (-not $env:WINGET_TOKEN) { throw "WINGET_SUBMIT_TOKEN is not configured" } - $installerUrl = "https://github.com/Pythoughts-labs/pythinker-code/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" + $installerUrl = "https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" $wingetCreateSha256 = "8BD738851B524885410112678E3771B341C5C716DE60FBBECB88AB0A363ED85D" Invoke-WebRequest -Uri $wingetCreateUrl -OutFile wingetcreate.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b582da..6a000f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Migrate saved `feedback.github_repo` values of `Pythoughts-labs/pythinker-code` (the pre-rename bundled default) to `Pythoughts-labs/pythinker-cli` on config load, and point the built-in help skill's documentation URLs at the canonical `pythinker-cli` GitHub Pages path. +- Add `scripts/uninstall.ps1`, a Windows uninstaller served at `https://pythinker.com/uninstall.ps1` (`irm https://pythinker.com/uninstall.ps1 | iex`): runs a trusted Inno uninstaller (non-elevated, user-scope, known directory only), then sweeps PATH entries (user + system, kind-preserved), Start Menu shortcuts, uninstall registry keys (native 32/64-bit views), and owned stale installer temp dirs, and verifies final state with fail-closed semantics. Recursive deletion is snapshot-based and limited to known install dirs — registry-selected custom installs are never swept or executed automatically (they are reported for manual action) — registry-provided paths are canonicalized and rejected on roots/UNC/device/drive-relative/reparse-point components, processes are killed per-PID only from validated dirs with StartTime/path revalidation, and the script never self-elevates or calls `exit`, so piping through `iex` cannot close the host. User data under `%USERPROFILE%\.pythinker` is kept unless `PYTHINKER_PURGE_DATA=1` is set. +- Update all repository URLs and repo-slug constants to `Pythoughts-labs/pythinker-cli` after the GitHub repository rename (install scripts, updater, feedback default repo, docs, workflows, packaging). - Retry the post-update smoke check briefly before recording `VERIFICATION_FAILED`, absorbing the Homebrew launcher-relink race that falsely failed successful upgrades, and print the verification failure on screen instead of leaving "Updated successfully!" as the last word when verification genuinely fails. ## 0.62.0 (2026-07-22) @@ -88,7 +91,7 @@ GitHub Releases page; `0.8.0` is the new starting line. (`true` → `download`, `false` → `notify`); `pythinker info` now reports the mode string, and `/update auto` accepts the new mode names. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.59.0 (2026-07-17) @@ -100,7 +103,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, - **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. - **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.58.0 (2026-07-11) @@ -147,7 +150,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, recorded as failed (never blurred to not-applicable), and request-assembly telemetry no longer emits unbounded per-request token values as metric attributes. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.57.0 (2026-07-05) @@ -173,7 +176,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, later turns through a compact reminder until stopped by name or "normal mode". -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.56.0 (2026-07-02) @@ -192,7 +195,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, an older Homebrew install no longer leaves a permanent "Restart to apply" banner — the `/update` command surfaces again as expected. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.55.0 (2026-06-30) @@ -208,7 +211,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, regardless of whether each call reports success — catching loops where a tool falsely reports success on a call that never made progress. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.54.0 (2026-06-30) @@ -220,7 +223,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, structured-schema output per agent call, enforce an optional token budget, and require a single up-front approval rather than one per spawned subagent. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.53.0 (2026-06-23) @@ -326,7 +329,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, so brew can't delete the in-use Cellar version mid-session; the new build is staged side-by-side and goes live on restart. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.50.0 (2026-06-20) @@ -512,7 +515,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, version tracking for `didChange`, open-document state cleared on server restart, empty diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.47.0 (2026-06-16) @@ -602,7 +605,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, receive the goal, scope, expected output contract, and verification criteria; the Haiku-style tool-use summary from the upstream reference was deliberately not ported. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.46.0 (2026-06-14) @@ -610,7 +613,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, - **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected. - **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.45.0 (2026-06-14) @@ -699,7 +702,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, to `loop_control.max_truncation_recoveries` times per turn (default 3; `0` disables). pythinker-core now surfaces the provider's truncation signal so the loop can detect it. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.44.0 (2026-06-13) @@ -707,7 +710,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, - **Windows in-app update no longer shows a spurious "could not close the program" error.** The native installer now waits for the launching `pythinker.exe` to fully exit (its PID is passed via `/PID`) before its Restart Manager scan runs, so the scan no longer races the launcher's teardown into a false "close the program and retry" dialog. The update already succeeded in that case; now it completes cleanly without the alarming prompt. - **Simplified the Windows pip/uv/pipx update path.** Now that every shipped Windows install updates through the native installer, the Windows-only detached-spawn upgrade helper is removed; the remaining pip/uv/pipx path (a Windows source checkout, or any macOS/Linux install) runs the upgrade inline like POSIX, surfacing real command output and errors instead of a fire-and-forget process. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.43.0 (2026-06-13) @@ -725,7 +728,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, - **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. - **Error telemetry is emitted at ERROR severity.** `error`/`crash`/`api_error` events were forwarded to OpenTelemetry logs at INFO, so severity filters and the error saved-views never matched them; they now map to ERROR (`session_load_failed` to WARN) with canonical `error.*` attributes, and a startup crash before the telemetry sink attaches is still flushed. Opt-out telemetry behavior is unchanged. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.42.0 (2026-06-12) @@ -746,7 +749,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, - **Inline `/command` references get acted on, not just explained away.** When a message mentions a slash command mid-sentence (e.g. "your `/goal` today is to `/plan` and build the page"), the command doesn't auto-run — but the agent no longer leads its reply by reporting it as failed. The per-turn reminder and the system prompt now steer the agent to act on the intent: call the real `EnterPlanMode` tool for `/plan` (clarified as a genuine, callable tool so models stop doubting it exists), pursue the described objective for `/goal`, load `/skill:` via `ReadSkill`, and apply equivalent guidance for other commands — only surfacing how to invoke the literal command when genuinely needed. - **The "thinking" shimmer no longer runs while a foreground command does.** When the agent started a long-running foreground process — a dev server via `npm`/`docker`, a watch task — the shimmering verb spinner ("Working…/Thinking…") kept animating for the whole turn, implying the agent was busy when it was really just awaiting the subprocess. The spinner is now suppressed while any foreground tool is mid-execution; the tool card's own animated running marker (and its streaming output) carries the liveness, so the shimmer means "the agent is thinking" again and reappears the moment the command returns. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.41.0 (2026-06-11) @@ -765,14 +768,14 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, - **TUI polish.** No more transient red `` flash while tool-call arguments stream (incomplete `None`-valued keys are dropped before rendering); flicker-free streaming on terminals with synchronized output (DEC mode 2026, capability-gated, `PYTHINKER_NO_SYNC_OUTPUT=1` kill switch); slash commands ghost-complete inline with Tab to accept; finished tool-call rows are monotonic (a late or duplicated wire event can't flip a failed row to successful); shell error briefs show the trailing output of a failed command as plain text; and the cursor-position probe can no longer leave the terminal wedged in raw mode on exit. - **`compact_prompt` config override.** A new optional top-level config key replaces the built-in compaction summarization prompt for both manual and automatic compaction; a `/compact` focus argument is still appended on top, and leaving it unset preserves current behavior. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.1 (2026-06-10) - **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404. - **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.0 (2026-06-10) @@ -784,7 +787,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, - **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept. - **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.39.0 (2026-06-09) @@ -797,7 +800,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, - **The agent sets up and removes MCP servers on request instead of refusing.** Asked to add, remove, or set up an MCP server, the default agent now knows it runs in Pythinker: it configures the server with the `pythinker mcp add`/`remove` CLI (or by editing `~/.pythinker/mcp.json` / `./.pythinker/mcp.json`), verifies with `pythinker mcp list`/`test`, and tells you to restart or `/reload` to load the change — rather than refusing or citing Claude Code/Desktop config paths (`~/.claude.json`) it cannot use. The prompt now also hard-steers the agent away from writing `mcpServers` into `~/.pythinker/config.yaml` (YAML is never parsed for MCP, so such an entry is silently dropped and the server never appears in `/mcp`). As a backstop, MCP config loading now logs a warning when it finds an `mcpServers` block in a `config.yaml` (global or project), so a human or agent that misplaces it gets a diagnosable trace instead of a silent drop. - **Security: dependency vulnerability remediation.** Cleared the open Dependabot advisories across all manifests. Python: `asyncssh` 2.22.0 → 2.23.0 (path-traversal in `AuthorizedKeysFile %u`) in the `pythinker-host` pin and both lockfiles, and `starlette` 1.0.0 → 1.2.1 (Host-header path poisoning). JS: regenerated the `web`, `vis`, and `install-counter-worker` lockfiles and bumped the worker's `vitest` to `^3.2.6` (critical Vitest UI arbitrary file read/exec), clearing all critical/high/moderate advisories. The only residual is a handful of low-severity transitive `elliptic`/`bn.js` advisories in `web`'s browser crypto polyfill chain, left unforced because the fix downgrades `vite-plugin-node-polyfills` and majors `ai`, breaking the build for marginal benefit. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.38.0 (2026-06-08) @@ -806,7 +809,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, - **Live model pricing from models.dev.** Cost estimates in `/usage` and the session stats panel now pull per-model input/output pricing from the models.dev catalog (fetched once and cached for 24 hours), improving cost accuracy across providers. - **More robust ripgrep resolution.** File search now verifies that a bundled `rg` binary can actually execute on the host platform and architecture before using it, falling back to a system or freshly downloaded ripgrep when the bundled one cannot run — fixing search failures on mismatched-architecture installs. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.37.0 (2026-06-07) @@ -817,7 +820,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, - **Recon-first `planner` subagent.** A new read-only `planner` built-in agent type decomposes open-ended tasks into distinct parallel seed descriptions emitted as `` JSON, enabling structured fan-out before parallel workers start. - **Coder artifact contract.** The `coder` subagent now emits a `` JSON block at the end of every response, providing structured handoff data (`files_changed`, `test_command`, `expected_behavior`, optional `edge_cases_claimed`) that the `verifier` subagent can consume directly. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.36.0 (2026-06-05) @@ -825,7 +828,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, - **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.35.0 (2026-06-04) @@ -914,7 +917,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.29.0`, ### What changed in this release -- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-code`; existing configs that still reference the previous owner are auto-migrated to the new default. +- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-cli`; existing configs that still reference the previous owner are auto-migrated to the new default. - **Broadened distribution channels for releases.** Releases now include best-effort Docker/GHCR, Scoop, Nix, and manual WinGet distribution plumbing with channel-native update markers where the installer format supports them. - **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Node.js 24 CI with pinned, credential-checked workflows.** CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. @@ -993,7 +996,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.25.0`, - **Complete native installer `Fetch` fix.** The 0.23.0 release bundled trafilatura's data files but missed `justext`'s stoplists directory, so the `Fetch` tool still crashed with `FileNotFoundError: ./_MEIxxxx/justext/stoplists` on `.exe`, `.deb`, and `.rpm` installs. All three installer specs now bundle both `trafilatura` and `justext` data files. PyPI / `pip install` was unaffected. - **Atomic "latest" release gating.** The GitHub Release is no longer marked "latest" until every platform asset (4 archives, 1 `.exe`, 4 `.deb`/`.rpm`) is attached. A dispatch workflow polls for completeness before flipping the flag, so `/releases/latest` and the in-app updater no longer serve a partially-built release during the publish window. - **Smarter `/update` command.** `run_update_prompt` now routes through `do_update(check_only=True)` to get a fresh PyPI version before showing the update modal, and verifies that the expected binary asset for your platform exists on the GitHub Release before initiating a native upgrade. -- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-code`. +- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-cli`. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.24.0`, or use the native installer for your OS (see the README install table). @@ -1107,7 +1110,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.14.0`, ### What changed in this release - **Native installers for macOS and Linux.** `brew install Pythoughts-labs/pythinker/pythinker-code` (Homebrew tap) covers both macOS (Intel + Apple Silicon) and Linux brew installs from a single auto-generated formula. Debian/Ubuntu users get `pythinker-code_x.y.z_.deb` and Fedora/RHEL/openSUSE users get `pythinker-code-x.y.z..rpm`, both attached to every GitHub Release for `x86_64` and `aarch64`. Together with the Windows `PythinkerSetup-x.y.z.exe` shipped in 0.12.0, Pythinker now ships native installers for every supported platform — no Python, Node, or `uv` prerequisite. -- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. +- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. - **Homebrew tap auto-published on every release.** A new `.github/workflows/homebrew-tap.yml` waits for the PyPI publish to land, runs an in-tree formula generator (replaces the unmaintained `homebrew-pypi-poet` — see release notes for the why), and pushes `Formula/pythinker-code.rb` to the `homebrew-pythinker` tap repo. 132 transitive deps are enumerated automatically — no hand-curation per release. - **Tag-triggered Linux package pipeline.** A new `.github/workflows/linux-installer.yml` matrix-builds `.deb` and `.rpm` for `x86_64` and `aarch64` (the latter via QEMU on `ubuntu-latest`), then uploads all six artifacts to the GitHub Release via `softprops/action-gh-release@v2`. - **Frozen-binary data-files fix.** The PyInstaller specs for both the Windows and Linux pipelines now call `collect_data_files(pkg, include_py_files=False)` per package, so `pythinker_code/prompts/*.md`, `agents/default/*.yaml`, `tools/*/description.md`, `skills/*/SKILL.md`, and similar package resources are bundled into `_internal/`. Without this fix the frozen binary crashed the first time it tried to load `init.md` or an agent yaml. The Windows installer that shipped in 0.12.0 is affected; users on that build should upgrade to 0.13.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f76f42b..1490fe57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for being interested in contributing to Pythinker CLI! We welcome all kinds of contributions, including bug fixes, features, document improvements, typo fixes, etc. To maintain a high-quality codebase and user experience, we provide the following guidelines for contributions: -1. We only merge pull requests that align with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/Pythoughts-labs/pythinker-code/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review. +1. We only merge pull requests that align with our roadmap. For any pull request that introduces changes larger than 100 lines of code, we highly recommend discussing with us by [raising an issue](https://github.com/Pythoughts-labs/pythinker-cli/issues) or in an existing issue before you start working on it. Otherwise your pull request may be closed or ignored without review. 2. We insist on high code quality. Please ensure your code is as good as, if not better than, the code written by frontier coding agents. Changes may be requested before your pull request can be merged. ## Prek hooks diff --git a/README.md b/README.md index b1b854c9..89eb815d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# Pythinker logo Pythinker Code +# Pythinker logo Pythinker Code ### *Think first, then code. Your terminal-native review-first AI engineering agent.* @@ -11,12 +11,12 @@ [![PyPI](https://img.shields.io/pypi/v/pythinker-code?style=for-the-badge&logo=pypi&logoColor=white&color=2563eb&label=pythinker-code)](https://pypi.org/project/pythinker-code/) [![Downloads](https://img.shields.io/pepy/dt/pythinker-code?style=for-the-badge&logo=python&logoColor=white&color=f97316&label=downloads)](https://pepy.tech/projects/pythinker-code) -[![License](https://img.shields.io/badge/License-Apache--2.0-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) +[![License](https://img.shields.io/badge/License-Apache--2.0-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/LICENSE) [![Ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg?style=for-the-badge&logo=ruff&logoColor=d7ff64)](https://docs.astral.sh/ruff/) [![Homepage](https://img.shields.io/badge/home-pythinker.com-ec4899.svg?style=for-the-badge&logo=globe&logoColor=white)](https://pythinker.com) -[![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-code/ci-pythinker-cli.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/actions/workflows/ci-pythinker-cli.yml?query=branch%3Amain) -[![Python](https://img.shields.io/badge/Python-3.12%2B-3776ab?style=for-the-badge&logo=python&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/pyproject.toml) +[![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-cli/ci-pythinker-cli.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-cli/actions/workflows/ci-pythinker-cli.yml?query=branch%3Amain) +[![Python](https://img.shields.io/badge/Python-3.12%2B-3776ab?style=for-the-badge&logo=python&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/pyproject.toml)
@@ -30,7 +30,7 @@

-Pythinker Code terminal demo +Pythinker Code terminal demo
@@ -53,7 +53,7 @@ It speaks the [**Agent Client Protocol (ACP)**](https://github.com/agentclientpr - **Updates surface during long-running sessions.** Pythinker now checks periodically for newly available releases, shows each new update notice once per session, and bounds every check with a watchdog so a stalled attempt cannot prevent later retries. - **Repository-local agent workflows stay local.** This checkout now treats its root `.agents/` directory as local agent configuration instead of version-controlled project content. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.62.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.62.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). --- @@ -147,7 +147,7 @@ Run trusted local JSONL fixtures with `/benchmark:swe --dataset --trusted > Built-in shell commands such as `cd` are not yet supported in shell command mode.
-Shell command mode demo +Shell command mode demo
--- @@ -159,13 +159,13 @@ matches your OS — no Python, Node, or `uv` prerequisite. | Platform | Recommended install | Artifact source | |---|---|---| -| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.62.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | -| **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | +| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.62.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | +| **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | | **macOS — Homebrew** | `brew install Pythoughts-labs/pythinker/pythinker-code` | auto-published Homebrew tap | | **🐳 Docker** | `docker run --rm -it ghcr.io/pythoughts-labs/pythinker-code` | GHCR multi-arch image | | **🪟 Windows — Scoop** | `scoop bucket add pythinker https://github.com/Pythoughts-labs/scoop-pythinker && scoop install pythinker-code` | auto-published Scoop bucket | -| **❄️ Nix** | `nix run github:Pythoughts-labs/pythinker-code` | flake `apps.default` | -| **Linux — system package** | Download the `.deb` or `.rpm` for your distro below | [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | +| **❄️ Nix** | `nix run github:Pythoughts-labs/pythinker-cli` | flake `apps.default` | +| **Linux — system package** | Download the `.deb` or `.rpm` for your distro below | [Releases](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest) | | **🐍 Python fallback** | `pip install pythinker-code` | PyPI | Every artifact ships with a matching `.sha256` file — verify before install on @@ -212,8 +212,12 @@ installs to `%ProgramFiles%\Pythinker` and writes PATH to HKLM (requires admin). the newest installer, verifies SHA-256, and launches the Inno installer with visible progress (`/SILENT /NORESTART /CURRENTUSER /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS`). -**Uninstall:** Apps & Features → *Pythinker Code* → Uninstall reverts both -the files and the PATH edit. +**Uninstall:** `irm https://pythinker.com/uninstall.ps1 | iex` — runs the +registered uninstaller, then sweeps leftover files, PATH edits (user + system), +Start Menu shortcuts, and uninstall registry keys, and verifies the result. +Your config/sessions in `%USERPROFILE%\.pythinker` are kept unless you set +`$env:PYTHINKER_PURGE_DATA = "1"` first. Apps & Features → *Pythinker Code* → +Uninstall works too and reverts both the files and the PATH edit. > 🛡 **First-launch SmartScreen warning** — Until Authenticode secrets are > configured in CI for a release, the installer ships unsigned and Windows shows @@ -270,8 +274,8 @@ sudo apt-get install -f # only if dpkg reports missing deps sudo dpkg -i pythinker-code_0.62.0_arm64.deb # Fedora / RHEL / openSUSE (x86_64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.x86_64.rpm.sha256 # Fedora / RHEL: sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm @@ -279,8 +283,8 @@ sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm sudo zypper install ./pythinker-code-0.62.0.x86_64.rpm # Fedora / RHEL (aarch64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.aarch64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.aarch64.rpm.sha256 sudo dnf install ./pythinker-code-0.62.0.aarch64.rpm ``` @@ -373,7 +377,7 @@ pythinker login ```
-Pythinker login provider picker +Pythinker login provider picker
Z.AI exposes two independent OpenAI-compatible routes. Choose the route that owns your key; @@ -582,7 +586,7 @@ The ACP server provides: | 🔄 **Hot model swap** | Change models for a running ACP session |
-ACP IDE integration demo +ACP IDE integration demo
--- @@ -658,7 +662,7 @@ Pythinker is a small, extensible runtime — not a monolith. Build on it. ## 🏗️ Architecture
-Pythinker Code architecture diagram +Pythinker Code architecture diagram
--- @@ -738,8 +742,8 @@ are used automatically unless you turn telemetry off. ### 🏁 Prepare the workspace ```sh -git clone https://github.com/Pythoughts-labs/pythinker-code.git -cd pythinker-code +git clone https://github.com/Pythoughts-labs/pythinker-cli.git +cd pythinker-cli make prepare ``` @@ -819,9 +823,9 @@ pythinker-code/ Contributions are warmly welcome — bug reports, PRs, plugins, skills, and docs all help. -- 📖 Start with [`CONTRIBUTING.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) -- 🔐 See [`SECURITY.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/SECURITY.md) for responsible disclosure -- 📜 Skim [`AGENTS.md`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/AGENTS.md) for the agent design notes +- 📖 Start with [`CONTRIBUTING.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CONTRIBUTING.md) +- 🔐 See [`SECURITY.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/SECURITY.md) for responsible disclosure +- 📜 Skim [`AGENTS.md`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/AGENTS.md) for the agent design notes If Pythinker helps you, **a ⭐ on GitHub goes a long way.** @@ -829,7 +833,7 @@ If Pythinker helps you, **a ⭐ on GitHub goes a long way.** ## 📜 License -Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) for the full text and [`NOTICE`](https://github.com/Pythoughts-labs/pythinker-code/blob/main/NOTICE) for attributions. +Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/LICENSE) for the full text and [`NOTICE`](https://github.com/Pythoughts-labs/pythinker-cli/blob/main/NOTICE) for attributions.
@@ -839,7 +843,7 @@ Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com [🌐 pythinker.com](https://pythinker.com)  ·  [📦 PyPI](https://pypi.org/project/pythinker-code/)  ·  -[🐙 GitHub](https://github.com/Pythoughts-labs/pythinker-code)  ·  +[🐙 GitHub](https://github.com/Pythoughts-labs/pythinker-cli)  ·  [🧩 ACP](https://github.com/agentclientprotocol/agent-client-protocol)  ·  [🔌 MCP](https://modelcontextprotocol.io/) diff --git a/SECURITY.md b/SECURITY.md index 10aabe71..a4180601 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,4 +6,4 @@ Currently, Pythinker CLI only provides security support for the latest version. ## Reporting a Vulnerability -Please report a vulnerability via the [Pythoughts-labs/pythinker-code - Security](https://github.com/Pythoughts-labs/pythinker-code/security) page, or open an [issue](https://github.com/Pythoughts-labs/pythinker-code/issues) if it can be published publicly. +Please report a vulnerability via the [Pythoughts-labs/pythinker-cli - Security](https://github.com/Pythoughts-labs/pythinker-cli/security) page, or open an [issue](https://github.com/Pythoughts-labs/pythinker-cli/issues) if it can be published publicly. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 74f2bafd..a9d94a52 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -112,7 +112,7 @@ export default withMermaid(defineConfig({ outline: [2, 3], search: { provider: 'local' }, socialLinks: [ - { icon: 'github', link: 'https://github.com/Pythoughts-labs/pythinker-code' }, + { icon: 'github', link: 'https://github.com/Pythoughts-labs/pythinker-cli' }, ], }, diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index 7bfc6893..6ec99c98 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -17,7 +17,7 @@ Pythinker Code supports the following usage modes: - **[Agent integration (`pythinker acp`)](../reference/pythinker-acp.md)**: Run as a service and integrate with [IDEs](./ides.md) and other local agent clients via the [Agent Client Protocol] ::: info Tip -If you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/Pythoughts-labs/pythinker-code/issues). +If you encounter issues or have suggestions, please provide feedback on [GitHub Issues](https://github.com/Pythoughts-labs/pythinker-cli/issues). ::: [Agent Client Protocol]: https://agentclientprotocol.com/ @@ -44,7 +44,7 @@ On Windows, run the PowerShell bootstrap. It downloads the native installer, ver irm https://pythinker.com/install.ps1 | iex ``` -You can also download `PythinkerSetup-0.62.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +You can also download `PythinkerSetup-0.62.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). Verify the installation: @@ -70,6 +70,12 @@ Upgrade to the latest version: pythinker update ``` +Uninstall on Windows (native installer): + +```powershell +irm https://pythinker.com/uninstall.ps1 | iex +``` + Uninstall a curl-bash install: ```sh diff --git a/docs/en/index.md b/docs/en/index.md index c3a2273a..13c5645c 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -10,5 +10,5 @@ hero: link: /en/guides/getting-started - theme: alt text: GitHub - link: https://github.com/Pythoughts-labs/pythinker-code + link: https://github.com/Pythoughts-labs/pythinker-cli --- diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 3b669945..ef0399a7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -88,7 +88,7 @@ GitHub Releases page; `0.8.0` is the new starting line. (`true` → `download`, `false` → `notify`); `pythinker info` now reports the mode string, and `/update auto` accepts the new mode names. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.59.0 (2026-07-17) @@ -100,7 +100,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.60.0`, - **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. - **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.58.0 (2026-07-11) @@ -147,7 +147,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.59.0`, recorded as failed (never blurred to not-applicable), and request-assembly telemetry no longer emits unbounded per-request token values as metric attributes. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.57.0 (2026-07-05) @@ -173,7 +173,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, later turns through a compact reminder until stopped by name or "normal mode". -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.56.0 (2026-07-02) @@ -192,7 +192,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, an older Homebrew install no longer leaves a permanent "Restart to apply" banner — the `/update` command surfaces again as expected. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.55.0 (2026-06-30) @@ -208,7 +208,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.56.0`, regardless of whether each call reports success — catching loops where a tool falsely reports success on a call that never made progress. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.54.0 (2026-06-30) @@ -220,7 +220,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.55.0`, structured-schema output per agent call, enforce an optional token budget, and require a single up-front approval rather than one per spawned subagent. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.53.0 (2026-06-23) @@ -326,7 +326,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.54.0`, so brew can't delete the in-use Cellar version mid-session; the new build is staged side-by-side and goes live on restart. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.50.0 (2026-06-20) @@ -512,7 +512,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.51.0`, version tracking for `didChange`, open-document state cleared on server restart, empty diagnostics payloads clear stale entries, and tightened `/usage` activity-argument validation. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.47.0 (2026-06-16) @@ -602,7 +602,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.48.0`, receive the goal, scope, expected output contract, and verification criteria; the Haiku-style tool-use summary from the upstream reference was deliberately not ported. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.46.0 (2026-06-14) @@ -610,7 +610,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.47.0`, - **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected. - **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.45.0 (2026-06-14) @@ -699,7 +699,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.46.0`, to `loop_control.max_truncation_recoveries` times per turn (default 3; `0` disables). pythinker-core now surfaces the provider's truncation signal so the loop can detect it. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.44.0 (2026-06-13) @@ -707,7 +707,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.45.0`, - **Windows in-app update no longer shows a spurious "could not close the program" error.** The native installer now waits for the launching `pythinker.exe` to fully exit (its PID is passed via `/PID`) before its Restart Manager scan runs, so the scan no longer races the launcher's teardown into a false "close the program and retry" dialog. The update already succeeded in that case; now it completes cleanly without the alarming prompt. - **Simplified the Windows pip/uv/pipx update path.** Now that every shipped Windows install updates through the native installer, the Windows-only detached-spawn upgrade helper is removed; the remaining pip/uv/pipx path (a Windows source checkout, or any macOS/Linux install) runs the upgrade inline like POSIX, surfacing real command output and errors instead of a fire-and-forget process. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.43.0 (2026-06-13) @@ -725,7 +725,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.44.0`, - **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them. - **Error telemetry is emitted at ERROR severity.** `error`/`crash`/`api_error` events were forwarded to OpenTelemetry logs at INFO, so severity filters and the error saved-views never matched them; they now map to ERROR (`session_load_failed` to WARN) with canonical `error.*` attributes, and a startup crash before the telemetry sink attaches is still flushed. Opt-out telemetry behavior is unchanged. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.42.0 (2026-06-12) @@ -746,7 +746,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.43.0`, - **Inline `/command` references get acted on, not just explained away.** When a message mentions a slash command mid-sentence (e.g. "your `/goal` today is to `/plan` and build the page"), the command doesn't auto-run — but the agent no longer leads its reply by reporting it as failed. The per-turn reminder and the system prompt now steer the agent to act on the intent: call the real `EnterPlanMode` tool for `/plan` (clarified as a genuine, callable tool so models stop doubting it exists), pursue the described objective for `/goal`, load `/skill:` via `ReadSkill`, and apply equivalent guidance for other commands — only surfacing how to invoke the literal command when genuinely needed. - **The "thinking" shimmer no longer runs while a foreground command does.** When the agent started a long-running foreground process — a dev server via `npm`/`docker`, a watch task — the shimmering verb spinner ("Working…/Thinking…") kept animating for the whole turn, implying the agent was busy when it was really just awaiting the subprocess. The spinner is now suppressed while any foreground tool is mid-execution; the tool card's own animated running marker (and its streaming output) carries the liveness, so the shimmer means "the agent is thinking" again and reappears the moment the command returns. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.41.0 (2026-06-11) @@ -765,14 +765,14 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.42.0`, - **TUI polish.** No more transient red `` flash while tool-call arguments stream (incomplete `None`-valued keys are dropped before rendering); flicker-free streaming on terminals with synchronized output (DEC mode 2026, capability-gated, `PYTHINKER_NO_SYNC_OUTPUT=1` kill switch); slash commands ghost-complete inline with Tab to accept; finished tool-call rows are monotonic (a late or duplicated wire event can't flip a failed row to successful); shell error briefs show the trailing output of a failed command as plain text; and the cursor-position probe can no longer leave the terminal wedged in raw mode on exit. - **`compact_prompt` config override.** A new optional top-level config key replaces the built-in compaction summarization prompt for both manual and automatic compaction; a `/compact` focus argument is still appended on top, and leaving it unset preserves current behavior. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.41.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.1 (2026-06-10) - **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404. - **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.40.0 (2026-06-10) @@ -784,7 +784,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.1`, - **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept. - **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.39.0 (2026-06-09) @@ -797,7 +797,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.40.0`, - **The agent sets up and removes MCP servers on request instead of refusing.** Asked to add, remove, or set up an MCP server, the default agent now knows it runs in Pythinker: it configures the server with the `pythinker mcp add`/`remove` CLI (or by editing `~/.pythinker/mcp.json` / `./.pythinker/mcp.json`), verifies with `pythinker mcp list`/`test`, and tells you to restart or `/reload` to load the change — rather than refusing or citing Claude Code/Desktop config paths (`~/.claude.json`) it cannot use. The prompt now also hard-steers the agent away from writing `mcpServers` into `~/.pythinker/config.yaml` (YAML is never parsed for MCP, so such an entry is silently dropped and the server never appears in `/mcp`). As a backstop, MCP config loading now logs a warning when it finds an `mcpServers` block in a `config.yaml` (global or project), so a human or agent that misplaces it gets a diagnosable trace instead of a silent drop. - **Security: dependency vulnerability remediation.** Cleared the open Dependabot advisories across all manifests. Python: `asyncssh` 2.22.0 → 2.23.0 (path-traversal in `AuthorizedKeysFile %u`) in the `pythinker-host` pin and both lockfiles, and `starlette` 1.0.0 → 1.2.1 (Host-header path poisoning). JS: regenerated the `web`, `vis`, and `install-counter-worker` lockfiles and bumped the worker's `vitest` to `^3.2.6` (critical Vitest UI arbitrary file read/exec), clearing all critical/high/moderate advisories. The only residual is a handful of low-severity transitive `elliptic`/`bn.js` advisories in `web`'s browser crypto polyfill chain, left unforced because the fix downgrades `vite-plugin-node-polyfills` and majors `ai`, breaking the build for marginal benefit. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.38.0 (2026-06-08) @@ -806,7 +806,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.39.0`, - **Live model pricing from models.dev.** Cost estimates in `/usage` and the session stats panel now pull per-model input/output pricing from the models.dev catalog (fetched once and cached for 24 hours), improving cost accuracy across providers. - **More robust ripgrep resolution.** File search now verifies that a bundled `rg` binary can actually execute on the host platform and architecture before using it, falling back to a system or freshly downloaded ripgrep when the bundled one cannot run — fixing search failures on mismatched-architecture installs. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.37.0 (2026-06-07) @@ -817,7 +817,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.38.0`, - **Recon-first `planner` subagent.** A new read-only `planner` built-in agent type decomposes open-ended tasks into distinct parallel seed descriptions emitted as `` JSON, enabling structured fan-out before parallel workers start. - **Coder artifact contract.** The `coder` subagent now emits a `` JSON block at the end of every response, providing structured handoff data (`files_changed`, `test_command`, `expected_behavior`, optional `edge_cases_claimed`) that the `verifier` subagent can consume directly. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.36.0 (2026-06-05) @@ -825,7 +825,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.37.0`, - **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-cli/releases/latest). ## 0.35.0 (2026-06-04) @@ -914,7 +914,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.29.0`, ### What changed in this release -- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-code`; existing configs that still reference the previous owner are auto-migrated to the new default. +- **Repository moved to the Pythoughts-labs GitHub org.** All GitHub URLs, install scripts, CI configuration, and the default `/feedback` repository now point to `github.com/Pythoughts-labs/pythinker-cli`; existing configs that still reference the previous owner are auto-migrated to the new default. - **Broadened distribution channels for releases.** Releases now include best-effort Docker/GHCR, Scoop, Nix, and manual WinGet distribution plumbing with channel-native update markers where the installer format supports them. - **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - **Node.js 24 CI with pinned, credential-checked workflows.** CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. @@ -993,7 +993,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.25.0`, - **Complete native installer `Fetch` fix.** The 0.23.0 release bundled trafilatura's data files but missed `justext`'s stoplists directory, so the `Fetch` tool still crashed with `FileNotFoundError: ./_MEIxxxx/justext/stoplists` on `.exe`, `.deb`, and `.rpm` installs. All three installer specs now bundle both `trafilatura` and `justext` data files. PyPI / `pip install` was unaffected. - **Atomic "latest" release gating.** The GitHub Release is no longer marked "latest" until every platform asset (4 archives, 1 `.exe`, 4 `.deb`/`.rpm`) is attached. A dispatch workflow polls for completeness before flipping the flag, so `/releases/latest` and the in-app updater no longer serve a partially-built release during the publish window. - **Smarter `/update` command.** `run_update_prompt` now routes through `do_update(check_only=True)` to get a fresh PyPI version before showing the update modal, and verifies that the expected binary asset for your platform exists on the GitHub Release before initiating a native upgrade. -- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-code`. +- **Repository transferred to Pythoughts-labs.** All GitHub URLs, install script references, and CI configuration now point to `github.com/Pythoughts-labs/pythinker-cli`. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.24.0`, or use the native installer for your OS (see the README install table). @@ -1107,7 +1107,7 @@ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.14.0`, ### What changed in this release - **Native installers for macOS and Linux.** `brew install Pythoughts-labs/pythinker/pythinker-code` (Homebrew tap) covers both macOS (Intel + Apple Silicon) and Linux brew installs from a single auto-generated formula. Debian/Ubuntu users get `pythinker-code_x.y.z_.deb` and Fedora/RHEL/openSUSE users get `pythinker-code-x.y.z..rpm`, both attached to every GitHub Release for `x86_64` and `aarch64`. Together with the Windows `PythinkerSetup-x.y.z.exe` shipped in 0.12.0, Pythinker now ships native installers for every supported platform — no Python, Node, or `uv` prerequisite. -- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. +- **Cross-OS curl-bash native installer.** `curl -fsSL https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install-native.sh | bash` detects your OS + arch, downloads the matching PyInstaller-frozen tarball from the latest Release, verifies its SHA-256, and lands the binary at `~/.local/bin/pythinker`. Supports `linux-x86_64`, `linux-aarch64`, and `macos-arm64`. Honors `--version`, `--prefix`, and `NO_COLOR`. - **Homebrew tap auto-published on every release.** A new `.github/workflows/homebrew-tap.yml` waits for the PyPI publish to land, runs an in-tree formula generator (replaces the unmaintained `homebrew-pypi-poet` — see release notes for the why), and pushes `Formula/pythinker-code.rb` to the `homebrew-pythinker` tap repo. 132 transitive deps are enumerated automatically — no hand-curation per release. - **Tag-triggered Linux package pipeline.** A new `.github/workflows/linux-installer.yml` matrix-builds `.deb` and `.rpm` for `x86_64` and `aarch64` (the latter via QEMU on `ubuntu-latest`), then uploads all six artifacts to the GitHub Release via `softprops/action-gh-release@v2`. - **Frozen-binary data-files fix.** The PyInstaller specs for both the Windows and Linux pipelines now call `collect_data_files(pkg, include_py_files=False)` per package, so `pythinker_code/prompts/*.md`, `agents/default/*.yaml`, `tools/*/description.md`, `skills/*/SKILL.md`, and similar package resources are bundled into `_internal/`. Without this fix the frozen binary crashed the first time it tried to load `init.md` or an agent yaml. The Windows installer that shipped in 0.12.0 is affected; users on that build should upgrade to 0.13.0. diff --git a/docs/history/CHANGELOG-pre-0.8.0.md b/docs/history/CHANGELOG-pre-0.8.0.md index 23903a4c..3377be95 100644 --- a/docs/history/CHANGELOG-pre-0.8.0.md +++ b/docs/history/CHANGELOG-pre-0.8.0.md @@ -22,7 +22,7 @@ Packaging fix: pin `pythinker-core[contrib]==1.1.0` so the Kimi K2.x / DeepSeek ### Why 2.6.0 lands the same day as 2.5.0 -The runtime fix for the strict-interleaved `thinking is enabled but reasoning_content is missing in assistant tool call message at index N` rejection landed in the `pythinker-core` source tree on 2026-05-11 (released in `pythinker-code` 2.4.0 source), but **the published `pythinker-core==1.0.0` on PyPI predates that change** (uploaded 2026-05-07). `pythinker-code` 2.4.0 and 2.5.0 both pinned `pythinker-core[contrib]==1.0.0`, so PyPI users on Kimi K2.5 / K2.6 / DeepSeek through OpenCode Go (and elsewhere) kept hitting the bug even on the latest CLI. Reported in [#37](https://github.com/Pythoughts-labs/pythinker-code/issues/37). +The runtime fix for the strict-interleaved `thinking is enabled but reasoning_content is missing in assistant tool call message at index N` rejection landed in the `pythinker-core` source tree on 2026-05-11 (released in `pythinker-code` 2.4.0 source), but **the published `pythinker-core==1.0.0` on PyPI predates that change** (uploaded 2026-05-07). `pythinker-code` 2.4.0 and 2.5.0 both pinned `pythinker-core[contrib]==1.0.0`, so PyPI users on Kimi K2.5 / K2.6 / DeepSeek through OpenCode Go (and elsewhere) kept hitting the bug even on the latest CLI. Reported in [#37](https://github.com/Pythoughts-labs/pythinker-cli/issues/37). ### The fix diff --git a/docs/public/_headers b/docs/public/_headers index 5711b8e8..3f77a812 100644 --- a/docs/public/_headers +++ b/docs/public/_headers @@ -6,5 +6,9 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 +/uninstall.ps1 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 + /releases/* Cache-Control: public, max-age=31536000, immutable diff --git a/docs/public/install.ps1 b/docs/public/install.ps1 index 146ded20..a118158b 100644 --- a/docs/public/install.ps1 +++ b/docs/public/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/docs/public/install.sh b/docs/public/install.sh index 21f0c70f..f7f0944d 100755 --- a/docs/public/install.sh +++ b/docs/public/install.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' diff --git a/docs/public/uninstall.ps1 b/docs/public/uninstall.ps1 new file mode 100644 index 00000000..7644d5c3 --- /dev/null +++ b/docs/public/uninstall.ps1 @@ -0,0 +1,1563 @@ +# Pythinker Code — native Windows uninstaller. +# +# Usage: +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. + +& { + $ErrorActionPreference = "Stop" + + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- + + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true + } + } catch { + $useColor = $false + } + } + + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject + } + + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } + + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What + } + + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } + } + + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } + } + + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- + + function Test-IsAdmin { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } + } + + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = ([string]$Value).Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} + } + return $expanded.TrimEnd('\', '/') + } + + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent + } + + return $false + } + + function Get-SafeTreeSnapshot($Root) { + $files = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($fullRoot) + + while ($stack.Count -gt 0) { + $directory = $stack.Pop() + $entries = $null + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } + } + } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + function Get-SafeInstallDirectory($Candidate) { + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null + return $null + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null + return $null + } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null + } + + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing install directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null + return $null + } + + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. + return $full + } + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null + return $null + } + + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + + try { + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) + } + + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 + } + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine + } + + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + return $entries + } + + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) + } + + return $null + } + + function Get-InstallationRecords($Entries) { + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } + } + + $dir = $null + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null + } + + $trusted = $false + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir + } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) + } + + return $records + } + + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- + + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null + continue + } + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" + continue + } + + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } + + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } + + $survivorPath = Get-ProcessExecutablePath $survivor + $sameStart = $false + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError + continue + } + + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ + } + } + } + + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false + } + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false + } + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false + } + + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null + try { + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false + } + + OK "Uninstaller completed" + return $true + } catch { + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } + } + } + + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- + + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteSnapshot($Path, $Snapshot) { + if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + + return $ok + } + + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" + return + } + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" + return + } + + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + + try { + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." + } catch { + $lastError = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } + Record-Warning "could not remove $What" $hint + } + + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } + + $matched = $false + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break + } + } + if (-not $matched) { return "NotFound" } + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } + + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null + return "Failed" + } + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" + return "Removed" + } catch { + Record-Warning "could not update $label" $_ + return "Failed" + } finally { + if ($writeKey) { $writeKey.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } + try { + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + if ($targets.Count -eq 0) { return } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } + } + + function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } + } + + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" + continue + } + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" + continue + } + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" + } catch { + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is running; leaving installer temp directories untouched" + return + } + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" + continue + } + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" + } + } + + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + + function Invoke-DataPurge { + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } + + $purge = $false + if ($PurgeDataSetting -eq "1") { + $purge = $true + } elseif ($PurgeDataSetting -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { + $purge = $false + } + } + + if ($purge) { + $State.PurgeDataRequested = $true + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" + } + } + + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message + } + + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } + } catch { + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" + } + + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" + } + } + + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } + } + } + + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + + function Invoke-PythinkerUninstall { + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @() + $records = @() + $installDirs = New-Object System.Collections.Generic.List[string] + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] + + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } + + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } + + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } + + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs + + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } + } catch { + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" + } + + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +} diff --git a/examples/feedback-worker/wrangler.jsonc b/examples/feedback-worker/wrangler.jsonc index 39880062..341fb0f2 100644 --- a/examples/feedback-worker/wrangler.jsonc +++ b/examples/feedback-worker/wrangler.jsonc @@ -10,7 +10,7 @@ } ], "vars": { - "GITHUB_REPO": "Pythoughts-labs/pythinker-code", + "GITHUB_REPO": "Pythoughts-labs/pythinker-cli", "GITHUB_LABELS": "feedback,pythinker-cli", "SUPPORT_EMAIL": "support@pythinker.com", "FROM_EMAIL": "Pythinker Feedback " diff --git a/packages/homebrew-tap/generate-formula.py b/packages/homebrew-tap/generate-formula.py index 566a8547..00bcd925 100644 --- a/packages/homebrew-tap/generate-formula.py +++ b/packages/homebrew-tap/generate-formula.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" diff --git a/packages/linux-installer/README.md b/packages/linux-installer/README.md index 2370d461..8b4d1abf 100644 --- a/packages/linux-installer/README.md +++ b/packages/linux-installer/README.md @@ -8,15 +8,15 @@ End-user install from the current GitHub Release: ```sh # Debian / Ubuntu -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code_0.62.0_amd64.deb.sha256 sha256sum -c pythinker-code_0.62.0_amd64.deb.sha256 sudo dpkg -i pythinker-code_0.62.0_amd64.deb sudo apt-get install -f # only needed if dependencies fail to resolve # Fedora / RHEL / openSUSE -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v0.62.0/pythinker-code-0.62.0.x86_64.rpm.sha256 sha256sum -c pythinker-code-0.62.0.x86_64.rpm.sha256 sudo dnf install ./pythinker-code-0.62.0.x86_64.rpm # or, on openSUSE: diff --git a/packages/linux-installer/build.sh b/packages/linux-installer/build.sh index fc367263..cf44efc3 100755 --- a/packages/linux-installer/build.sh +++ b/packages/linux-installer/build.sh @@ -87,7 +87,7 @@ FPM_COMMON=( -v "$VERSION" --license "Apache-2.0" --maintainer "Pythinker " - --url "https://github.com/Pythoughts-labs/pythinker-code" + --url "https://github.com/Pythoughts-labs/pythinker-cli" --description "Pythinker Code: terminal-native review-first AI engineering agent." --vendor "Pythinker" ) diff --git a/packages/pythinker-core/CHANGELOG.md b/packages/pythinker-core/CHANGELOG.md index 70c8353b..170dfddf 100644 --- a/packages/pythinker-core/CHANGELOG.md +++ b/packages/pythinker-core/CHANGELOG.md @@ -16,7 +16,7 @@ pythinker-core 1.1.0 depends on mcp<1.17 and >=1 Strict-interleaved reasoning replay for Kimi K2.x and DeepSeek, plus the SDK 0.101 anthropic compat fix. -- **`contrib.chat_provider.openai_legacy._convert_message`** — for strict-interleaved providers (`kimi-k2*`, `deepseek*`) now **always** emits `reasoning_content` on assistant turns, even when no `ThinkPart` was captured. Falls back to the assistant text, then to `"[reasoning unavailable]"` so Kimi-style "thinking is enabled but reasoning_content is missing in assistant tool call message at index N" rejections no longer trip multi-step tool flows. Fixes pythinker-code [#37](https://github.com/Pythoughts-labs/pythinker-code/issues/37). +- **`contrib.chat_provider.openai_legacy._convert_message`** — for strict-interleaved providers (`kimi-k2*`, `deepseek*`) now **always** emits `reasoning_content` on assistant turns, even when no `ThinkPart` was captured. Falls back to the assistant text, then to `"[reasoning unavailable]"` so Kimi-style "thinking is enabled but reasoning_content is missing in assistant tool call message at index N" rejections no longer trip multi-step tool flows. Fixes pythinker-code [#37](https://github.com/Pythoughts-labs/pythinker-cli/issues/37). - **`chat_provider.pythinker`** — same `reasoning_content` replay guarantee on the native pythinker provider for parity. - **`contrib.chat_provider.anthropic`** — added `case _:` fallbacks at the prompt-cache-injection site and the streaming content-block start site so the six new tool-result block types in anthropic SDK 0.101 (`web_fetch_tool_result`, `code_execution_tool_result`, `bash_code_execution_tool_result`, `text_editor_code_execution_tool_result`, `tool_search_tool_result`, `container_upload`) don't trip `pyright`'s exhaustive-match check. - No public API changes; minor patch contract — drop-in upgrade. diff --git a/packages/pythinker-core/pyproject.toml b/packages/pythinker-core/pyproject.toml index b0126220..0ed74c7e 100644 --- a/packages/pythinker-core/pyproject.toml +++ b/packages/pythinker-core/pyproject.toml @@ -38,10 +38,10 @@ contrib = [ ] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/packages/pythinker-core/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/packages/pythinker-core/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py index d0f63f15..ead67266 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py @@ -145,7 +145,7 @@ async def generate( # Auto-enable reasoning_effort when the history contains ThinkPart but reasoning # was not explicitly configured. This prevents server validation errors from APIs # (e.g. One API) that require reasoning_effort when messages contain reasoning_content. - # See: https://github.com/Pythoughts-labs/pythinker-code/issues/1616 + # See: https://github.com/Pythoughts-labs/pythinker-cli/issues/1616 if ( self._auto_reasoning_effort and isinstance(reasoning_effort, Omit) diff --git a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py index dffeb998..c72aaee9 100644 --- a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py +++ b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py @@ -315,7 +315,7 @@ async def test_openai_legacy_auto_reasoning_effort_when_history_has_think_part() """When reasoning_effort is not set but history contains ThinkPart and reasoning_key is configured, reasoning_effort should be auto-set to avoid server validation errors. - Reproduces: https://github.com/Pythoughts-labs/pythinker-code/issues/1616 + Reproduces: https://github.com/Pythoughts-labs/pythinker-cli/issues/1616 """ with respx.mock(base_url="https://api.openai.com") as mock: mock.post("/v1/chat/completions").mock( diff --git a/packages/pythinker-host/pyproject.toml b/packages/pythinker-host/pyproject.toml index ea29114c..467ae434 100644 --- a/packages/pythinker-host/pyproject.toml +++ b/packages/pythinker-host/pyproject.toml @@ -26,10 +26,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/packages/pythinker-host/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/packages/pythinker-host/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/packages/pythinker-review/pyproject.toml b/packages/pythinker-review/pyproject.toml index 3f26c15c..cf04e68b 100644 --- a/packages/pythinker-review/pyproject.toml +++ b/packages/pythinker-review/pyproject.toml @@ -33,8 +33,8 @@ pythinker-debug = "pythinker_review.cli.debug:app" pythinker-security-scan = "pythinker_review.cli.security_scan:app" [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" [dependency-groups] dev = [ diff --git a/packages/pythinker-review/src/pythinker_review/output/sarif.py b/packages/pythinker-review/src/pythinker_review/output/sarif.py index c55bdc3c..78a83a81 100644 --- a/packages/pythinker-review/src/pythinker_review/output/sarif.py +++ b/packages/pythinker-review/src/pythinker_review/output/sarif.py @@ -68,7 +68,7 @@ def render_sarif(meta: RunMeta, findings: list[Finding]) -> str: "tool": { "driver": { "name": "pythinker-review", - "informationUri": "https://github.com/Pythoughts-labs/pythinker-code", + "informationUri": "https://github.com/Pythoughts-labs/pythinker-cli", "rules": list(rules_seen.values()), } }, diff --git a/packages/scoop-bucket/generate-manifest.py b/packages/scoop-bucket/generate-manifest.py index 3c186c56..e888278b 100644 --- a/packages/scoop-bucket/generate-manifest.py +++ b/packages/scoop-bucket/generate-manifest.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" diff --git a/packages/scoop-bucket/pythinker-code.json.tmpl b/packages/scoop-bucket/pythinker-code.json.tmpl index 0ba1e767..8861ae95 100644 --- a/packages/scoop-bucket/pythinker-code.json.tmpl +++ b/packages/scoop-bucket/pythinker-code.json.tmpl @@ -14,12 +14,12 @@ "PYTHINKER_MANAGED": "scoop" }, "checkver": { - "github": "https://github.com/Pythoughts-labs/pythinker-code" + "github": "https://github.com/Pythoughts-labs/pythinker-cli" }, "autoupdate": { "architecture": { "64bit": { - "url": "https://github.com/Pythoughts-labs/pythinker-code/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" + "url": "https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" } }, "hash": { diff --git a/packages/windows-installer/installer.iss b/packages/windows-installer/installer.iss index da4ae23e..eb71d236 100644 --- a/packages/windows-installer/installer.iss +++ b/packages/windows-installer/installer.iss @@ -11,8 +11,8 @@ AppName=Pythinker Code AppVersion={#AppVersion} AppPublisher=Pythinker AppPublisherURL=https://pythinker.com -AppSupportURL=https://github.com/Pythoughts-labs/pythinker-code/issues -AppUpdatesURL=https://github.com/Pythoughts-labs/pythinker-code/releases +AppSupportURL=https://github.com/Pythoughts-labs/pythinker-cli/issues +AppUpdatesURL=https://github.com/Pythoughts-labs/pythinker-cli/releases DefaultDirName={localappdata}\Programs\Pythinker DefaultGroupName=Pythinker DisableProgramGroupPage=yes diff --git a/pyproject.toml b/pyproject.toml index 9577af84..cbebc505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,11 +113,11 @@ pythinker = "pythinker_code.__main__:main" pythinker-code = "pythinker_code.__main__:main" [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/CHANGELOG.md" -Documentation = "https://github.com/Pythoughts-labs/pythinker-code#readme" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CHANGELOG.md" +Documentation = "https://github.com/Pythoughts-labs/pythinker-cli#readme" [tool.ruff] line-length = 100 diff --git a/scripts/install-native.sh b/scripts/install-native.sh index 21f0c70f..f7f0944d 100755 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 146ded20..a118158b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 00000000..7644d5c3 --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,1563 @@ +# Pythinker Code — native Windows uninstaller. +# +# Usage: +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. + +& { + $ErrorActionPreference = "Stop" + + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- + + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true + } + } catch { + $useColor = $false + } + } + + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject + } + + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } + + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What + } + + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } + } + + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } + } + + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- + + function Test-IsAdmin { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } + } + + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = ([string]$Value).Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} + } + return $expanded.TrimEnd('\', '/') + } + + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent + } + + return $false + } + + function Get-SafeTreeSnapshot($Root) { + $files = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($fullRoot) + + while ($stack.Count -gt 0) { + $directory = $stack.Pop() + $entries = $null + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } + } + } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + function Get-SafeInstallDirectory($Candidate) { + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null + return $null + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null + return $null + } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null + } + + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing install directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null + return $null + } + + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. + return $full + } + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null + return $null + } + + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + + try { + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) + } + + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 + } + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine + } + + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + return $entries + } + + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) + } + + return $null + } + + function Get-InstallationRecords($Entries) { + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } + } + + $dir = $null + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null + } + + $trusted = $false + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir + } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) + } + + return $records + } + + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- + + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null + continue + } + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" + continue + } + + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } + + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } + + $survivorPath = Get-ProcessExecutablePath $survivor + $sameStart = $false + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError + continue + } + + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ + } + } + } + + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false + } + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false + } + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false + } + + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null + try { + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false + } + + OK "Uninstaller completed" + return $true + } catch { + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } + } + } + + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- + + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteSnapshot($Path, $Snapshot) { + if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + + return $ok + } + + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" + return + } + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" + return + } + + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + + try { + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." + } catch { + $lastError = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } + Record-Warning "could not remove $What" $hint + } + + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } + + $matched = $false + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break + } + } + if (-not $matched) { return "NotFound" } + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } + + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null + return "Failed" + } + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" + return "Removed" + } catch { + Record-Warning "could not update $label" $_ + return "Failed" + } finally { + if ($writeKey) { $writeKey.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } + try { + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + if ($targets.Count -eq 0) { return } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } + } + + function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } + } + + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" + continue + } + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" + continue + } + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" + } catch { + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is running; leaving installer temp directories untouched" + return + } + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" + continue + } + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" + } + } + + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + + function Invoke-DataPurge { + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } + + $purge = $false + if ($PurgeDataSetting -eq "1") { + $purge = $true + } elseif ($PurgeDataSetting -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { + $purge = $false + } + } + + if ($purge) { + $State.PurgeDataRequested = $true + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" + } + } + + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message + } + + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } + } catch { + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" + } + + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" + } + } + + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } + } + } + + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + + function Invoke-PythinkerUninstall { + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @() + $records = @() + $installDirs = New-Object System.Collections.Generic.List[string] + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] + + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } + + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } + + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } + + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs + + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } + } catch { + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" + } + + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +} diff --git a/sdks/pythinker-sdk/pyproject.toml b/sdks/pythinker-sdk/pyproject.toml index 48abfe2a..bf38b3da 100644 --- a/sdks/pythinker-sdk/pyproject.toml +++ b/sdks/pythinker-sdk/pyproject.toml @@ -22,10 +22,10 @@ classifiers = [ dependencies = ["pythinker-core==1.2.0", "mcp>=1.23,<2", "httpx>=0.28.1"] [project.urls] -Homepage = "https://github.com/Pythoughts-labs/pythinker-code" -Repository = "https://github.com/Pythoughts-labs/pythinker-code" -Issues = "https://github.com/Pythoughts-labs/pythinker-code/issues" -Changelog = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/sdks/pythinker-sdk/CHANGELOG.md" +Homepage = "https://github.com/Pythoughts-labs/pythinker-cli" +Repository = "https://github.com/Pythoughts-labs/pythinker-cli" +Issues = "https://github.com/Pythoughts-labs/pythinker-cli/issues" +Changelog = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/sdks/pythinker-sdk/CHANGELOG.md" [dependency-groups] dev = [ diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py index bb9a9779..7333048b 100644 --- a/src/pythinker_code/feedback_repo.py +++ b/src/pythinker_code/feedback_repo.py @@ -1,12 +1,13 @@ from __future__ import annotations -DEFAULT_FEEDBACK_GITHUB_REPO = "Pythoughts-labs/pythinker-code" +DEFAULT_FEEDBACK_GITHUB_REPO = "Pythoughts-labs/pythinker-cli" _LEGACY_FEEDBACK_GITHUB_OWNER = "mohamed-elkholy95" _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = { _LEGACY_FEEDBACK_GITHUB_OWNER + "/Pythinker-Code", _LEGACY_FEEDBACK_GITHUB_OWNER + "/pythinker-code", "TechMatrix-labs/pythinker-code", + "Pythoughts-labs/pythinker-code", } _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = { repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS diff --git a/src/pythinker_code/native.py b/src/pythinker_code/native.py index b8d9ec94..9ae8be61 100644 --- a/src/pythinker_code/native.py +++ b/src/pythinker_code/native.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -GITHUB_REPO = "Pythoughts-labs/pythinker-code" +GITHUB_REPO = "Pythoughts-labs/pythinker-cli" SENTINEL_FILENAME = ".pythinker-native" diff --git a/src/pythinker_code/skills/pythinker-code-help/SKILL.md b/src/pythinker_code/skills/pythinker-code-help/SKILL.md index 24e66102..1bd32818 100644 --- a/src/pythinker_code/skills/pythinker-code-help/SKILL.md +++ b/src/pythinker_code/skills/pythinker-code-help/SKILL.md @@ -15,17 +15,17 @@ Help users with Pythinker CLI questions by consulting documentation and source c ## Documentation -Base URL: `https://pythoughts-labs.github.io/pythinker-code/` +Base URL: `https://pythoughts-labs.github.io/pythinker-cli/` Fetch documentation index to find relevant pages: -``` -https://pythoughts-labs.github.io/pythinker-code/llms.txt +```text +https://pythoughts-labs.github.io/pythinker-cli/llms.txt ``` ### Page URL Pattern -- Pages: `https://pythoughts-labs.github.io/pythinker-code/en/...` +- Pages: `https://pythoughts-labs.github.io/pythinker-cli/en/...` ### Topic Mapping @@ -45,7 +45,7 @@ https://pythoughts-labs.github.io/pythinker-code/llms.txt ## Source Code -Repository: `https://github.com/Pythoughts-labs/pythinker-code` +Repository: `https://github.com/Pythoughts-labs/pythinker-cli` When to read source: diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 2e14d171..5ab7454d 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -733,7 +733,7 @@ def _fallback_to_issue(payload: dict[str, Any] | None = None) -> None: issue_url = ( build_feedback_issue_url(payload, soul.runtime.config.feedback.github_repo) if payload is not None and soul is not None - else "https://github.com/Pythoughts-labs/pythinker-code/issues/new/choose" + else "https://github.com/Pythoughts-labs/pythinker-cli/issues/new/choose" ) if open_url_in_browser(issue_url): console.print(f"[{_t_fb.success}]Opening GitHub feedback in your browser...[/]") @@ -860,7 +860,7 @@ async def report_error(app: Shell, args: str): _t_re = _get_tok_re() - ISSUE_URL = "https://github.com/Pythoughts-labs/pythinker-code/issues" + ISSUE_URL = "https://github.com/Pythoughts-labs/pythinker-cli/issues" def _fallback_to_issues() -> None: if not open_url_in_browser(ISSUE_URL): diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index fcd68195..ead5794e 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -52,7 +52,7 @@ from pythinker_code.utils.logging import logger from pythinker_code.utils.subprocess_env import get_clean_env -CHANGELOG_URL_EN = "https://github.com/Pythoughts-labs/pythinker-code/blob/main/CHANGELOG.md" +CHANGELOG_URL_EN = "https://github.com/Pythoughts-labs/pythinker-cli/blob/main/CHANGELOG.md" PYPI_VERSION_URL = "https://pypi.org/pypi/pythinker-code/{version}/json" HOMEBREW_FORMULA_URL = ( "https://raw.githubusercontent.com/Pythoughts-labs/homebrew-pythinker/" @@ -1735,7 +1735,7 @@ def _print(message: str) -> None: _print( f"[{_t.warning}]Auto-update disabled. " "Download the new installer manually from " - "https://github.com/Pythoughts-labs/pythinker-code/releases/latest[/]" + "https://github.com/Pythoughts-labs/pythinker-cli/releases/latest[/]" ) return UpdateResult.UPDATE_AVAILABLE if native_result is UpdateResult.FAILED: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index dbefd310..07bd21e8 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -103,7 +103,7 @@ def test_default_config_dump(): "api_key": None, "custom_headers": None, "github_client_id": "", - "github_repo": "Pythoughts-labs/pythinker-code", + "github_repo": "Pythoughts-labs/pythinker-cli", }, "hooks": [], "merge_all_available_skills": True, @@ -202,13 +202,19 @@ 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 == "Pythoughts-labs/pythinker-code" + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" def test_load_config_migrates_legacy_org_feedback_repo_default(): config = load_config_from_string('[feedback]\ngithub_repo = "TechMatrix-labs/pythinker-code"\n') - assert config.feedback.github_repo == "Pythoughts-labs/pythinker-code" + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" + + +def test_load_config_migrates_pre_rename_feedback_repo_default(): + config = load_config_from_string('[feedback]\ngithub_repo = "Pythoughts-labs/pythinker-code"\n') + + assert config.feedback.github_repo == "Pythoughts-labs/pythinker-cli" def test_agent_execution_profile_autonomous_sets_autonomy_defaults(): diff --git a/tests/core/test_simple_compaction.py b/tests/core/test_simple_compaction.py index 1382d06a..0a4e070a 100644 --- a/tests/core/test_simple_compaction.py +++ b/tests/core/test_simple_compaction.py @@ -249,8 +249,8 @@ def test_prepare_only_keeps_text_parts_in_compaction(): Non-text parts (media, think, etc.) are filtered out because the compaction API endpoint only supports text content. - Fixes: https://github.com/Pythoughts-labs/pythinker-code/issues/1395 - Fixes: https://github.com/Pythoughts-labs/pythinker-code/issues/1390 + Fixes: https://github.com/Pythoughts-labs/pythinker-cli/issues/1395 + Fixes: https://github.com/Pythoughts-labs/pythinker-cli/issues/1390 """ messages = [ Message( diff --git a/tests/test_installation_docs.py b/tests/test_installation_docs.py index 6a75fe70..938974ce 100644 --- a/tests/test_installation_docs.py +++ b/tests/test_installation_docs.py @@ -3,6 +3,8 @@ import tomllib from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] @@ -24,7 +26,7 @@ def test_windows_readme_documents_powershell_one_liner() -> None: assert "irm https://pythinker.com/install.ps1 \\| iex" in readme assert ( - "raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install.ps1" + "raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install.ps1" not in readme ) assert "-File $installer" not in readme @@ -38,7 +40,7 @@ def test_getting_started_uses_canonical_native_installer_url() -> None: assert "curl -fsSL https://pythinker.com/install.sh | bash" in guide assert "irm https://pythinker.com/install.ps1 | iex" in guide assert ( - "https://raw.githubusercontent.com/Pythoughts-labs/pythinker-code/main/scripts/install.ps1" + "https://raw.githubusercontent.com/Pythoughts-labs/pythinker-cli/main/scripts/install.ps1" ) not in guide @@ -62,7 +64,7 @@ def test_readme_downloads_rpm_before_local_install() -> None: rpm = f"pythinker-code-{version}.x86_64.rpm" checksum = f"{rpm}.sha256" - release_url = f"https://github.com/Pythoughts-labs/pythinker-code/releases/download/v{version}" + release_url = f"https://github.com/Pythoughts-labs/pythinker-cli/releases/download/v{version}" assert f"curl -LO {release_url}/{rpm}" in readme assert f"curl -LO {release_url}/{checksum}" in readme @@ -152,11 +154,14 @@ def test_native_shell_installers_are_parseable_when_bash_is_available() -> None: def test_public_install_scripts_match_native_sources_of_truth() -> None: native_sh = (ROOT / "scripts" / "install-native.sh").read_bytes() native_ps1 = (ROOT / "scripts" / "install.ps1").read_bytes() + native_uninstall_ps1 = (ROOT / "scripts" / "uninstall.ps1").read_bytes() assert (ROOT / "docs" / "public" / "install.sh").read_bytes() == native_sh assert (ROOT / "web" / "public" / "install.sh").read_bytes() == native_sh assert (ROOT / "docs" / "public" / "install.ps1").read_bytes() == native_ps1 assert (ROOT / "web" / "public" / "install.ps1").read_bytes() == native_ps1 + assert (ROOT / "docs" / "public" / "uninstall.ps1").read_bytes() == native_uninstall_ps1 + assert (ROOT / "web" / "public" / "uninstall.ps1").read_bytes() == native_uninstall_ps1 expected_sh_headers = ( "/install.sh\n" @@ -168,10 +173,49 @@ def test_public_install_scripts_match_native_sources_of_truth() -> None: " Content-Type: text/plain; charset=utf-8\n" " Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400\n" ) + expected_uninstall_ps1_headers = ( + "/uninstall.ps1\n" + " Content-Type: text/plain; charset=utf-8\n" + " Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400\n" + ) assert expected_sh_headers in (ROOT / "docs" / "public" / "_headers").read_text() assert expected_sh_headers in (ROOT / "web" / "public" / "_headers").read_text() assert expected_ps1_headers in (ROOT / "docs" / "public" / "_headers").read_text() assert expected_ps1_headers in (ROOT / "web" / "public" / "_headers").read_text() + assert expected_uninstall_ps1_headers in (ROOT / "docs" / "public" / "_headers").read_text() + assert expected_uninstall_ps1_headers in (ROOT / "web" / "public" / "_headers").read_text() + + +def test_windows_readme_documents_uninstall_one_liner() -> None: + readme = (ROOT / "README.md").read_text() + guide = (ROOT / "docs" / "en" / "guides" / "getting-started.md").read_text() + + assert "irm https://pythinker.com/uninstall.ps1 | iex" in readme + assert "irm https://pythinker.com/uninstall.ps1 | iex" in guide + + +def test_native_powershell_uninstaller_is_parseable_when_pwsh_is_available() -> None: + pwsh = shutil.which("pwsh") + if pwsh is None: + pytest.skip("pwsh is not available") + uninstaller = (ROOT / "scripts" / "uninstall.ps1").resolve() + result = subprocess.run( + [ + pwsh, + "-NoProfile", + "-Command", + ( + "$errs = $null;" + f"[System.Management.Automation.Language.Parser]::ParseFile('{uninstaller}'," + " [ref]$null, [ref]$errs) | Out-Null;" + " if ($errs) { $errs | ForEach-Object { $_.Message }; exit 1 }" + ), + ], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr def test_installation_docs_do_not_use_placeholder_package_artifacts() -> None: diff --git a/tests/test_native.py b/tests/test_native.py index 129a960a..a034ac30 100644 --- a/tests/test_native.py +++ b/tests/test_native.py @@ -55,7 +55,7 @@ def test_is_native_build_true_when_sentinel_present_without_frozen_flag(tmp_path def test_native_installer_release_url_latest(): url = native.native_installer_release_url(channel="latest") - assert url == ("https://api.github.com/repos/Pythoughts-labs/pythinker-code/releases/latest") + assert url == ("https://api.github.com/repos/Pythoughts-labs/pythinker-cli/releases/latest") def test_native_installer_release_url_stable(): diff --git a/tests/ui_and_conv/test_shell_feedback_slash.py b/tests/ui_and_conv/test_shell_feedback_slash.py index ec8a98ac..e644c6a3 100644 --- a/tests/ui_and_conv/test_shell_feedback_slash.py +++ b/tests/ui_and_conv/test_shell_feedback_slash.py @@ -50,7 +50,7 @@ async def test_opens_new_issue_url_when_no_soul(self, monkeypatch) -> None: open_mock.assert_called_once() url = open_mock.call_args.args[0] - assert "Pythoughts-labs/pythinker-code" in url + assert "Pythoughts-labs/pythinker-cli" in url assert "new" in url async def test_prints_success_when_browser_opens(self, monkeypatch) -> None: @@ -75,7 +75,7 @@ async def test_prints_url_when_browser_fails(self, monkeypatch) -> None: await _run_feedback(Mock(), "ux confusing prompt") output = " ".join(str(c) for c in print_mock.call_args_list) - assert "Pythoughts-labs/pythinker-code" in output + assert "Pythoughts-labs/pythinker-cli" in output async def test_invalid_args_without_soul_still_offer_github_fallback(self, monkeypatch) -> None: open_mock = Mock(return_value=True) @@ -146,7 +146,7 @@ async def test_submits_structured_payload(self, tmp_path: Path, monkeypatch) -> soul.runtime.session.subagents_dir = tmp_path / "subagents" soul.runtime.session.subagents_dir.mkdir() soul.runtime.role = "root" - soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-code" + soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-cli" soul.name = "default" soul.context.history = [ Message(role="user", content=[TextPart(text="please fix this")]), @@ -200,7 +200,7 @@ async def test_prints_follow_up_issue_url_when_endpoint_returns_no_link( soul = Mock(spec=PythinkerSoul) soul.runtime.session.id = "sess-123" - soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-code" + soul.runtime.config.feedback.github_repo = "Pythoughts-labs/pythinker-cli" app = Mock() app.soul = soul payload = { @@ -225,7 +225,7 @@ async def test_prints_follow_up_issue_url_when_endpoint_returns_no_link( output = " ".join(str(c) for c in print_mock.call_args_list) assert "No report link was returned" in output - assert "github.com/Pythoughts-labs/pythinker-code" in output + assert "github.com/Pythoughts-labs/pythinker-cli" in output async def test_prompts_before_submitting_by_default(self, monkeypatch) -> None: from pythinker_code.soul.pythinkersoul import PythinkerSoul @@ -302,7 +302,7 @@ def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None: url = build_feedback_issue_url(payload, old_owner + "/Pythinker-Code") - assert "github.com/Pythoughts-labs/pythinker-code/issues/new" in url + assert "github.com/Pythoughts-labs/pythinker-cli/issues/new" in url assert old_owner not in url def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None: diff --git a/web/public/_headers b/web/public/_headers index 5711b8e8..3f77a812 100644 --- a/web/public/_headers +++ b/web/public/_headers @@ -6,5 +6,9 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 +/uninstall.ps1 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=300, s-maxage=900, stale-if-error=86400 + /releases/* Cache-Control: public, max-age=31536000, immutable diff --git a/web/public/install.ps1 b/web/public/install.ps1 index 146ded20..a118158b 100644 --- a/web/public/install.ps1 +++ b/web/public/install.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = "Stop" try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} -$Repo = "Pythoughts-labs/pythinker-code" +$Repo = "Pythoughts-labs/pythinker-cli" $Version = $env:PYTHINKER_VERSION $NoColor = $env:NO_COLOR diff --git a/web/public/install.sh b/web/public/install.sh index 21f0c70f..f7f0944d 100755 --- a/web/public/install.sh +++ b/web/public/install.sh @@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do esac done -REPO="Pythoughts-labs/pythinker-code" +REPO="Pythoughts-labs/pythinker-cli" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' diff --git a/web/public/uninstall.ps1 b/web/public/uninstall.ps1 new file mode 100644 index 00000000..7644d5c3 --- /dev/null +++ b/web/public/uninstall.ps1 @@ -0,0 +1,1563 @@ +# Pythinker Code — native Windows uninstaller. +# +# Usage: +# irm https://pythinker.com/uninstall.ps1 | iex +# +# User data policy: +# $env:PYTHINKER_PURGE_DATA = "1" # delete $HOME\.pythinker without asking +# $env:PYTHINKER_PURGE_DATA = "0" # keep it without asking +# unset # ask once when interactive; keep otherwise +# +# Safety and failure model: +# - Runs inside an anonymous child scope and restores console encoding. +# - Never calls exit and never executes a registry-selected binary while elevated. +# - Never recursively deletes a registry-selected custom directory. +# - Only known installation directories are eligible for manual recursive sweep. +# - Registry-selected custom installations are never executed or recursively +# swept automatically; they are left intact and reported for manual action. +# - Elevated runs never execute HKCU or user-writable uninstallers. +# - Recursive deletion fails closed on roots, UNC/device paths, reparse points, +# incomplete tree inspection, and malformed paths. +# - Process escalation is per-PID with StartTime and executable-path revalidation. +# - Final verification fails closed: unknown state is unresolved, not success. +# - Requires Windows PowerShell 5.1+ or PowerShell 7+ on Windows. + +& { + $ErrorActionPreference = "Stop" + + $originalEncoding = $null + try { + $originalEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} + + try { + if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw "This uninstaller is for Windows." + } + + # ------------------------------------------------------------------------- + # Constants and state + # ------------------------------------------------------------------------- + + $AppId = "{4F4F2EAE-9D55-4E8E-92BC-7C1FA38B6F02}_is1" + $PurgeDataSetting = $env:PYTHINKER_PURGE_DATA + $NoColor = $env:NO_COLOR + + $DefaultInstallDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" + $DataDir = Join-Path $HOME ".pythinker" + $TempRoot = [System.IO.Path]::GetTempPath() + + $State = [pscustomobject]@{ + RemovedCount = 0 + Warnings = New-Object System.Collections.Generic.List[string] + Unresolved = New-Object System.Collections.Generic.List[string] + PendingReboot = New-Object System.Collections.Generic.List[string] + TempCleanupTargets = New-Object System.Collections.Generic.List[string] + PurgeDataRequested = $false + EnvironmentChanged = $false + } + + # ------------------------------------------------------------------------- + # Output helpers + # ------------------------------------------------------------------------- + + $ESC = [char]27 + $useColor = $false + if (-not $NoColor) { + try { + $vtProperty = $Host.UI.PSObject.Properties["SupportsVirtualTerminal"] + if ($vtProperty) { + $useColor = [bool]$Host.UI.SupportsVirtualTerminal + } elseif ($env:WT_SESSION -or $env:TERM_PROGRAM) { + $useColor = $true + } + } catch { + $useColor = $false + } + } + + if ($useColor) { + $NAVY = "$ESC[38;5;24m" + $FACE = "$ESC[38;5;255m" + $IRIS = "$ESC[38;5;152m" + $CORAL = "$ESC[38;5;216m" + $DIM = "$ESC[2m" + $BOLD = "$ESC[1m" + $RESET = "$ESC[0m" + } else { + $NAVY = $FACE = $IRIS = $CORAL = $DIM = $BOLD = $RESET = "" + } + + function Step($Message) { Write-Host " $IRIS⠿$RESET $Message" } + function OK($Message) { Write-Host " $IRIS✓$RESET $Message" } + function Warn($Message) { Write-Host " $CORAL!$RESET $Message" } + function Dim($Message) { Write-Host " ${DIM}$Message${RESET}" } + + function Format-ErrorMessage($ErrorObject) { + if ($null -eq $ErrorObject) { return "" } + if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) { + return [string]$ErrorObject.Exception.Message + } + return [string]$ErrorObject + } + + function Test-ListContainsInsensitive($List, [string]$Value) { + foreach ($item in $List) { + if ([string]::Equals([string]$item, $Value, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniqueString($List, [string]$Value) { + if (-not (Test-ListContainsInsensitive $List $Value)) { + [void]$List.Add($Value) + } + } + + function Record-Removed([string]$What) { + $State.RemovedCount = [int]$State.RemovedCount + 1 + OK $What + } + + function Record-Warning([string]$What, $ErrorObject = $null) { + $detail = $What + $message = Format-ErrorMessage $ErrorObject + if ($message) { $detail = "$What — $message" } + if (-not (Test-ListContainsInsensitive $State.Warnings $detail)) { + [void]$State.Warnings.Add($detail) + Warn $detail + } + } + + function Record-Unresolved([string]$What) { + if (-not (Test-ListContainsInsensitive $State.Unresolved $What)) { + [void]$State.Unresolved.Add($What) + Warn $What + } + } + + function Invoke-Step([string]$Name, [scriptblock]$Action) { + try { + return & $Action + } catch { + Record-Warning $Name $_ + return $null + } + } + + function Write-Header { + Write-Host "" + Write-Host " $CORAL●$RESET" + Write-Host " $NAVY│$RESET" + Write-Host " $NAVY▛$RESET$FACE▀▀▀▀▀▀▀$RESET$NAVY▜$RESET" + Write-Host " $CORAL◖$RESET$NAVY█$RESET $IRIS◉$RESET $IRIS◉$RESET $NAVY█$RESET$CORAL◗$RESET" + Write-Host " $NAVY▙▄▄▄$RESET$FACE≡$RESET$NAVY▄▄▄▟$RESET" + Write-Host "" + Write-Host " ${BOLD}${FACE}pythinker code${RESET} ${DIM}· uninstaller${RESET}" + Write-Host "" + } + + # ------------------------------------------------------------------------- + # Platform and path helpers + # ------------------------------------------------------------------------- + + function Test-IsAdmin { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object -TypeName Security.Principal.WindowsPrincipal -ArgumentList $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } + } + + function Test-Interactive { + if ($env:CI -eq "true" -or $env:CI -eq "1") { return $false } + try { + if ([Console]::IsInputRedirected) { return $false } + return ($null -ne $Host.UI.RawUI) + } catch { + return $false + } + } + + function Get-CanonicalPath($Path) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return $null } + $clean = [Environment]::ExpandEnvironmentVariables(([string]$Path).Trim().Trim('"')) + if ($clean -match '^[A-Za-z]:(?:$|[^\x5c/])') { return $null } # reject drive-relative paths such as C:foo + if (-not [IO.Path]::IsPathRooted($clean)) { return $null } + try { + return ([IO.Path]::GetFullPath($clean)).TrimEnd('\', '/') + } catch { + return $null + } + } + + function Test-LocalDrivePath($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + try { + $root = [IO.Path]::GetPathRoot($full) + return ($root -match '^[A-Za-z]:\\$') + } catch { + return $false + } + } + + function Get-NormalizedPathToken($Value) { + if ($null -eq $Value) { return "" } + $clean = ([string]$Value).Trim().Trim('"') + if ($clean -eq "") { return "" } + $expanded = [Environment]::ExpandEnvironmentVariables($clean) + $driveRelative = ($expanded -match '^[A-Za-z]:(?:$|[^\x5c/])') + if (-not $driveRelative -and [IO.Path]::IsPathRooted($expanded)) { + try { $expanded = [IO.Path]::GetFullPath($expanded) } catch {} + } + return $expanded.TrimEnd('\', '/') + } + + function Test-PathEqual($Left, $Right) { + $a = Get-NormalizedPathToken $Left + $b = Get-NormalizedPathToken $Right + if ($a -eq "" -or $b -eq "") { return $false } + return [string]::Equals($a, $b, [System.StringComparison]::OrdinalIgnoreCase) + } + + function Test-PathUnderDirs($Path, $Directories) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $false } + foreach ($directory in $Directories) { + $parent = Get-CanonicalPath $directory + if (-not $parent) { continue } + if ([string]::Equals($full, $parent, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + $prefix = $parent.TrimEnd('\') + [IO.Path]::DirectorySeparatorChar + if ($full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + + function Add-UniquePath($List, $Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return } + foreach ($existing in $List) { + if (Test-PathEqual $existing $full) { return } + } + [void]$List.Add($full) + } + + function Test-PendingReboot($Path) { + foreach ($pending in $State.PendingReboot) { + if (Test-PathEqual $pending $Path) { return $true } + } + return $false + } + + function Add-PendingReboot($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { $full = [string]$Path } + Add-UniquePath $State.PendingReboot $full + } + + function Get-KnownInstallDirectories { + $directories = New-Object System.Collections.Generic.List[string] + Add-UniquePath $directories $DefaultInstallDir + + $programFiles = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) + if ($programFiles) { Add-UniquePath $directories (Join-Path $programFiles "Pythinker") } + + $programFilesX86 = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86) + if ($programFilesX86) { Add-UniquePath $directories (Join-Path $programFilesX86 "Pythinker") } + + return $directories + } + + $KnownInstallDirs = Get-KnownInstallDirectories + + function Test-KnownInstallDirectory($Path) { + foreach ($known in $KnownInstallDirs) { + if (Test-PathEqual $Path $known) { return $true } + } + return $false + } + + function Test-PathHasReparseComponent($Path) { + $full = Get-CanonicalPath $Path + if (-not $full) { return $true } + + $current = $full + while ($current) { + try { + if (Test-Path -LiteralPath $current -ErrorAction Stop) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $true + } + } + } catch { + Record-Warning "could not inspect reparse status of $Path — treating it as unsafe" $_ + return $true + } + + $parent = Split-Path -Parent $current + if (-not $parent -or (Test-PathEqual $parent $current)) { break } + $current = $parent + } + + return $false + } + + function Get-SafeTreeSnapshot($Root) { + $files = New-Object System.Collections.Generic.List[string] + $directories = New-Object System.Collections.Generic.List[string] + $reparsePoints = New-Object System.Collections.Generic.List[string] + $errors = New-Object System.Collections.Generic.List[string] + $complete = $true + $rootIsDirectory = $false + + $fullRoot = Get-CanonicalPath $Root + if (-not $fullRoot) { + [void]$errors.Add("invalid or non-local root path") + return [pscustomobject]@{ + Complete = $false + Root = [string]$Root + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + try { + $rootItem = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop + if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($fullRoot) + } + $rootIsDirectory = [bool]($rootItem.Attributes -band [IO.FileAttributes]::Directory) + if (-not $rootIsDirectory) { + [void]$files.Add($fullRoot) + return [pscustomobject]@{ + Complete = $true + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + } catch { + [void]$errors.Add((Format-ErrorMessage $_)) + return [pscustomobject]@{ + Complete = $false + Root = $fullRoot + RootIsDirectory = $false + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + $stack = New-Object System.Collections.Generic.Stack[string] + $stack.Push($fullRoot) + + while ($stack.Count -gt 0) { + $directory = $stack.Pop() + $entries = $null + try { + $entries = @([IO.Directory]::EnumerateFileSystemEntries($directory)) + } catch { + $complete = $false + [void]$errors.Add("$directory — $(Format-ErrorMessage $_)") + continue + } + + foreach ($entry in $entries) { + $attributes = $null + try { + $attributes = [IO.File]::GetAttributes($entry) + } catch { + $complete = $false + [void]$errors.Add("$entry — $(Format-ErrorMessage $_)") + continue + } + + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + [void]$reparsePoints.Add($entry) + continue + } + + if ($attributes -band [IO.FileAttributes]::Directory) { + [void]$directories.Add($entry) + $stack.Push($entry) + } else { + [void]$files.Add($entry) + } + } + } + + return [pscustomobject]@{ + Complete = $complete + Root = $fullRoot + RootIsDirectory = $rootIsDirectory + Files = $files + Dirs = $directories + ReparsePoints = $reparsePoints + Errors = $errors + } + } + + function Get-SafeInstallDirectory($Candidate) { + if ([string]::IsNullOrWhiteSpace([string]$Candidate)) { return $null } + + $full = Get-CanonicalPath $Candidate + if (-not $full) { + Record-Warning "ignoring malformed or non-absolute install path: $Candidate" $null + return $null + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Warning "ignoring non-local, UNC, or device install path: $full" $null + return $null + } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Warning "refusing filesystem root as install directory: $full" $null + return $null + } + + if (-not [string]::Equals([IO.Path]::GetFileName($full), "Pythinker", [System.StringComparison]::OrdinalIgnoreCase)) { + Record-Warning "refusing install directory not named 'Pythinker': $full" $null + return $null + } + + if (Test-PathHasReparseComponent $full) { + Record-Warning "refusing install path with a reparse-point component: $full" $null + return $null + } + + $windowsDir = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::Windows) + $programData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData) + $forbiddenRoots = @($windowsDir, $programData) | Where-Object { $_ } + if (Test-PathUnderDirs $full $forbiddenRoots) { + Record-Warning "refusing install path below a protected Windows directory: $full" $null + return $null + } + + $criticalTargets = @( + $windowsDir, + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86), + [Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile), + $programData, + [IO.Path]::GetPathRoot($env:SystemRoot) + ) | Where-Object { $_ } + + foreach ($critical in $criticalTargets) { + if (Test-PathUnderDirs $critical @($full)) { + Record-Warning "refusing install path that is an ancestor of a critical directory: $full" $null + return $null + } + } + + $exists = $false + $isDirectory = $false + try { + $exists = Test-Path -LiteralPath $full -ErrorAction Stop + if ($exists) { $isDirectory = Test-Path -LiteralPath $full -PathType Container -ErrorAction Stop } + } catch { + Record-Warning "could not inspect install directory: $full" $_ + return $null + } + + if (-not $exists) { + # A missing, lexically safe directory can represent a stale registry entry. + return $full + } + if (-not $isDirectory) { + Record-Warning "refusing install path that is not a directory: $full" $null + return $null + } + + if (Test-KnownInstallDirectory $full) { return $full } + + $evidence = @( + (Join-Path $full "pythinker.exe"), + (Join-Path $full "pythinker-code.exe") + ) + foreach ($candidateFile in $evidence) { + if (Test-Path -LiteralPath $candidateFile -PathType Leaf -ErrorAction SilentlyContinue) { + return $full + } + } + + $innoFiles = @(Get-ChildItem -LiteralPath $full -File -Filter "unins*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^unins\d+\.exe$' }) + if ($innoFiles.Count -gt 0) { return $full } + + Record-Warning "ignoring unrecognized custom install directory with no product evidence: $full" $null + return $null + } + + function Test-TrustedUninstaller($Exe, $Directory) { + if (-not $Exe -or -not $Directory) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf -ErrorAction SilentlyContinue)) { return $false } + if ([IO.Path]::GetFileName($Exe) -notmatch '^unins\d+\.exe$') { return $false } + if (Test-PathHasReparseComponent $Exe) { return $false } + + try { + $item = Get-Item -LiteralPath $Exe -Force -ErrorAction Stop + if ($item.Length -le 0) { return $false } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Exe)) + } catch { + return $false + } + + return (Test-PathEqual $parent $Directory) + } + + # ------------------------------------------------------------------------- + # Registry discovery and installation records + # ------------------------------------------------------------------------- + + function Get-NativeRegistryView { + if ([Environment]::Is64BitOperatingSystem) { + return [Microsoft.Win32.RegistryView]::Registry64 + } + return [Microsoft.Win32.RegistryView]::Registry32 + } + + function Get-UninstallRegistryCombos { + $combos = New-Object System.Collections.Generic.List[object] + $nativeView = Get-NativeRegistryView + [void]$combos.Add([pscustomobject]@{ Hive = "CurrentUser"; View = $nativeView }) + if ([Environment]::Is64BitOperatingSystem) { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry64 }) + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } else { + [void]$combos.Add([pscustomobject]@{ Hive = "LocalMachine"; View = [Microsoft.Win32.RegistryView]::Registry32 }) + } + return $combos + } + + function Get-RegistryHiveEnum([string]$Hive) { + if ($Hive -eq "CurrentUser") { return [Microsoft.Win32.RegistryHive]::CurrentUser } + return [Microsoft.Win32.RegistryHive]::LocalMachine + } + + function Find-UninstallEntries { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + $entries = New-Object System.Collections.Generic.List[object] + + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + + [void]$entries.Add([pscustomobject]@{ + Hive = $combo.Hive + View = $combo.View + UninstallString = $key.GetValue("UninstallString") + QuietUninstallString = $key.GetValue("QuietUninstallString") + InstallLocation = $key.GetValue("InstallLocation") + DisplayName = $key.GetValue("DisplayName") + }) + } catch { + Record-Warning "could not inspect $($combo.Hive)\$($combo.View) uninstall registry" $_ + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + return $entries + } + + function Get-UninstallerPath($Entry) { + $raw = $Entry.QuietUninstallString + if (-not $raw) { $raw = $Entry.UninstallString } + if (-not $raw) { return $null } + + $rawText = [string]$raw + $match = [regex]::Match($rawText, '^\s*"([^"]+)"') + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, "^\s*'([^']+)'") + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) + } + + $match = [regex]::Match($rawText, '^\s*(.*?\.exe)(?:\s|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value.Trim()) + } + + return $null + } + + function Get-InstallationRecords($Entries) { + $records = New-Object System.Collections.Generic.List[object] + + foreach ($entry in $Entries) { + $exe = Get-UninstallerPath $entry + $validDirs = New-Object System.Collections.Generic.List[string] + $candidates = New-Object System.Collections.Generic.List[string] + + if ($entry.InstallLocation) { [void]$candidates.Add([string]$entry.InstallLocation) } + if ($exe) { + try { [void]$candidates.Add((Split-Path -Parent $exe)) } catch {} + } + + foreach ($candidate in $candidates) { + $safe = Get-SafeInstallDirectory $candidate + if ($safe) { Add-UniquePath $validDirs $safe } + } + + $dir = $null + $conflict = $false + if ($validDirs.Count -eq 1) { + $dir = $validDirs[0] + } elseif ($validDirs.Count -gt 1) { + $conflict = $true + Record-Warning "conflicting install directories in $($entry.Hive)\$($entry.View) registration; refusing automatic handling" $null + } + + $trusted = $false + if ($exe -and $dir -and -not $conflict) { + $trusted = Test-TrustedUninstaller $exe $dir + } + + [void]$records.Add([pscustomobject]@{ + Hive = $entry.Hive + View = $entry.View + Dir = $dir + CanSweep = ($dir -and (Test-KnownInstallDirectory $dir)) + Uninstaller = $exe + Trusted = $trusted + Conflict = $conflict + }) + } + + return $records + } + + # ------------------------------------------------------------------------- + # Process shutdown + # ------------------------------------------------------------------------- + + function Get-ProcessExecutablePath($Process) { + try { + $path = $Process.Path + if ($path) { return [string]$path } + } catch {} + try { + $path = $Process.MainModule.FileName + if ($path) { return [string]$path } + } catch {} + return $null + } + + function Stop-PythinkerProcesses($Directories) { + $names = @("pythinker", "pythinker-code") + $processes = @(Get-Process -Name $names -ErrorAction SilentlyContinue) + if ($processes.Count -eq 0) { return } + + foreach ($process in $processes) { + $path = Get-ProcessExecutablePath $process + + if (-not $path) { + Record-Warning "cannot inspect $($process.ProcessName) PID $($process.Id); leaving an unidentified process running" $null + continue + } + + if (-not (Test-PathUnderDirs $path $Directories)) { + Dim "skipping $($process.ProcessName) PID $($process.Id) — executable is outside a validated install directory" + continue + } + + $startTime = $null + try { $startTime = $process.StartTime } catch { $startTime = $null } + + Step "Stopping $($process.ProcessName) (PID $($process.Id))" + $stopError = $null + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + } catch { + $stopError = $_ + } + + $deadline = (Get-Date).AddSeconds(3) + while ((Get-Date) -lt $deadline) { + if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 200 + } + + $survivor = Get-Process -Id $process.Id -ErrorAction SilentlyContinue + if (-not $survivor) { + OK "Stopped $($process.ProcessName) (PID $($process.Id))" + continue + } + + $survivorPath = Get-ProcessExecutablePath $survivor + $sameStart = $false + if ($startTime) { + try { $sameStart = ($survivor.StartTime -eq $startTime) } catch { $sameStart = $false } + } + + if (-not ($sameStart -and $survivorPath -and (Test-PathEqual $survivorPath $path))) { + Record-Warning "PID $($process.Id) identity changed after stop attempt; refusing taskkill escalation" $stopError + continue + } + + $taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue + if (-not $taskkill) { + Record-Warning "taskkill.exe is unavailable; process PID $($process.Id) may remain running" $stopError + continue + } + + try { + $output = & taskkill.exe /F /T /PID $process.Id 2>&1 + if ($LASTEXITCODE -ne 0) { throw "$output" } + } catch { + Record-Warning "taskkill failed for PID $($process.Id)" $_ + } + } + } + + # ------------------------------------------------------------------------- + # Inno uninstaller execution + # ------------------------------------------------------------------------- + + function Test-CanExecuteUninstaller($Record) { + if (-not $Record.Trusted) { return $false } + + if (-not $Record.CanSweep) { + Record-Warning "refusing execution of uninstaller from a custom registry-selected directory: $($Record.Uninstaller)" $null + return $false + } + + # Never execute a registry-selected binary with an elevated token. Known + # directories are handled by the controlled cleanup below. + if (Test-IsAdmin) { + Record-Warning "refusing elevated execution of registry-selected uninstaller: $($Record.Uninstaller)" $null + return $false + } + + if ($Record.Hive -eq "LocalMachine") { + Record-Warning "machine-scope uninstaller was not executed from a non-elevated shell: $($Record.Uninstaller)" $null + return $false + } + + return $true + } + + function Invoke-InnoUninstaller($Record) { + Step "Running Pythinker uninstaller ($($Record.Hive) scope)" + $process = $null + try { + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $Record.Uninstaller + $processInfo.Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + $processInfo.WorkingDirectory = Split-Path -Parent $Record.Uninstaller + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $false + + $process = [System.Diagnostics.Process]::Start($processInfo) + if ($null -eq $process) { throw "Process.Start returned null." } + if (-not $process.WaitForExit(600000)) { + try { $process.Kill() } catch {} + Record-Warning "uninstaller exceeded the 10-minute timeout and was stopped: $($Record.Uninstaller)" $null + return $false + } + + if ($process.ExitCode -ne 0) { + Record-Warning "uninstaller exited with code $($process.ExitCode); continuing with controlled cleanup" $null + return $false + } + + OK "Uninstaller completed" + return $true + } catch { + Record-Warning "could not launch uninstaller $($Record.Uninstaller); continuing with controlled cleanup" $_ + return $false + } finally { + if ($process) { $process.Dispose() } + } + } + + # ------------------------------------------------------------------------- + # Robust deletion + # ------------------------------------------------------------------------- + + function Initialize-PendingDelete { + if ("Win32.PendingDelete" -as [type]) { return $true } + try { + $signature = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);' + Add-Type -Namespace Win32 -Name PendingDelete -MemberDefinition $signature -ErrorAction Stop + } catch {} + return ($null -ne ("Win32.PendingDelete" -as [type])) + } + + function Register-PendingDeleteSnapshot($Path, $Snapshot) { + if (-not (Initialize-PendingDelete)) { return $false } + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { return $false } + + $MOVEFILE_DELAY_UNTIL_REBOOT = 0x4 + $ok = $true + + if (-not $Snapshot.RootIsDirectory) { + try { + return [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT) + } catch { + return $false + } + } + + foreach ($file in $Snapshot.Files) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($file, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + try { + if (-not [Win32.PendingDelete]::MoveFileEx($directory, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + } + + try { + if (-not [Win32.PendingDelete]::MoveFileEx($Snapshot.Root, $null, $MOVEFILE_DELAY_UNTIL_REBOOT)) { $ok = $false } + } catch { + $ok = $false + } + + return $ok + } + + + function Remove-SafeSnapshotNow($Snapshot) { + if (-not $Snapshot.Complete -or $Snapshot.ReparsePoints.Count -gt 0) { + throw "Unsafe or incomplete tree snapshot." + } + + if (-not $Snapshot.RootIsDirectory) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + return + } + + foreach ($file in $Snapshot.Files) { + if (Test-Path -LiteralPath $file -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $file -Force -ErrorAction Stop + } + } + + foreach ($directory in @($Snapshot.Dirs | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $directory -ErrorAction SilentlyContinue) { + # Deliberately non-recursive: a directory that changed after the safe + # snapshot remains non-empty and fails rather than being traversed. + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } + } + + if (Test-Path -LiteralPath $Snapshot.Root -ErrorAction SilentlyContinue) { + Remove-Item -LiteralPath $Snapshot.Root -Force -ErrorAction Stop + } + } + + function Remove-PathRobust($Path, [string]$What) { + if ([string]::IsNullOrWhiteSpace([string]$Path)) { return } + + $full = Get-CanonicalPath $Path + if (-not $full) { + Record-Unresolved "refusing malformed deletion path: $Path" + return + } + + if (-not (Test-LocalDrivePath $full)) { + Record-Unresolved "refusing non-local, UNC, or device deletion path: $full" + return + } + + $exists = $false + try { $exists = Test-Path -LiteralPath $full -ErrorAction Stop } catch { + Record-Unresolved "could not determine whether $What exists: $full" + return + } + if (-not $exists) { return } + + $root = ([IO.Path]::GetPathRoot($full)).TrimEnd('\', '/') + if ($full -ieq $root) { + Record-Unresolved "refusing to remove filesystem root: $full" + return + } + + if (Test-PathHasReparseComponent $full) { + Record-Unresolved "refusing recursive deletion through a reparse point: $full" + return + } + + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + $snapshot = Get-SafeTreeSnapshot $full + if (-not $snapshot.Complete) { + $firstError = "" + if ($snapshot.Errors.Count -gt 0) { $firstError = " (first error: $($snapshot.Errors[0]))" } + Record-Unresolved "could not safely inspect the complete directory tree for $What at $full$firstError" + return + } + + if ($snapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "refusing controlled deletion because $($snapshot.ReparsePoints.Count) reparse point(s) exist inside $full (first: $($snapshot.ReparsePoints[0]))" + return + } + + try { + Remove-SafeSnapshotNow $snapshot + if (-not (Test-Path -LiteralPath $full -ErrorAction SilentlyContinue)) { + Record-Removed $What + return + } + throw "Deletion snapshot completed but the root still exists." + } catch { + $lastError = $_ + if ($attempt -lt 3) { Start-Sleep -Milliseconds (400 * $attempt) } + } + } + + # Re-inspect before scheduling deletion; the tree may have changed. + $pendingSnapshot = Get-SafeTreeSnapshot $full + if (-not $pendingSnapshot.Complete -or $pendingSnapshot.ReparsePoints.Count -gt 0) { + Record-Unresolved "could not safely inspect the complete directory tree before reboot scheduling: $full" + return + } + + if (Register-PendingDeleteSnapshot $full $pendingSnapshot) { + Add-PendingReboot $full + OK "$What — locked now; scheduled for deletion on next reboot" + return + } + + $hint = Format-ErrorMessage $lastError + if (-not (Test-IsAdmin)) { $hint = "$hint; an Administrator PowerShell may be required" } + Record-Warning "could not remove $What" $hint + } + + # ------------------------------------------------------------------------- + # PATH cleanup + # ------------------------------------------------------------------------- + + function Get-EnvironmentRegistryView { + return (Get-NativeRegistryView) + } + + function Split-PathValue([string]$Value) { + if ($null -eq $Value) { return @() } + return @($Value.Split([char[]]@(';'), [System.StringSplitOptions]::None)) + } + + function Remove-PathEntry($Directory, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + $label = if ($Hive -eq "CurrentUser") { "user PATH" } else { "system PATH" } + $target = Get-NormalizedPathToken $Directory + if ($target -eq "") { return "NotFound" } + + $base = $null + $readKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $readKey = $base.OpenSubKey($subkey, $false) + if ($null -eq $readKey) { return "NotFound" } + $current = $readKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return "NotFound" } + + $matched = $false + foreach ($token in (Split-PathValue ([string]$current))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $matched = $true + break + } + } + if (-not $matched) { return "NotFound" } + } catch { + Record-Warning "could not inspect $label" $_ + return "InspectionFailed" + } finally { + if ($readKey) { $readKey.Dispose() } + if ($base) { $base.Dispose() } + } + + if ($Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "$label still contains $Directory; re-run from an Administrator PowerShell to remove it" $null + return "Failed" + } + + $base = $null + $writeKey = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $writeKey = $base.OpenSubKey($subkey, $true) + if ($null -eq $writeKey) { + Record-Warning "could not open $label for writing" $null + return "Failed" + } + + # Re-read under the writable handle so a concurrent PATH update is not + # overwritten with the stale value from the read-only inspection phase. + $latest = $writeKey.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $latest) { return "NotFound" } + $kind = $writeKey.GetValueKind("Path") + + $kept = New-Object System.Collections.Generic.List[string] + $foundLatest = $false + foreach ($token in (Split-PathValue ([string]$latest))) { + if ((Get-NormalizedPathToken $token) -ieq $target) { + $foundLatest = $true + } else { + [void]$kept.Add($token) + } + } + + if (-not $foundLatest) { return "NotFound" } + $newValue = ($kept.ToArray() -join ";") + $writeKey.SetValue("Path", $newValue, $kind) + $State.EnvironmentChanged = $true + Record-Removed "removed $Directory from $label" + return "Removed" + } catch { + Record-Warning "could not update $label" $_ + return "Failed" + } finally { + if ($writeKey) { $writeKey.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Test-PathEntryPresent($Directories, [string]$Hive) { + $subkey = if ($Hive -eq "CurrentUser") { + "Environment" + } else { + "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" + } + + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $Hive), (Get-EnvironmentRegistryView)) + $key = $base.OpenSubKey($subkey, $false) + if ($null -eq $key) { return $false } + $current = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($null -eq $current) { return $false } + + foreach ($token in (Split-PathValue ([string]$current))) { + $probe = Get-NormalizedPathToken $token + foreach ($target in $targets) { + if ($probe -ieq $target) { return $true } + } + } + return $false + } catch { + return $null + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + function Remove-SessionPathEntries($Directories) { + if ($null -eq $env:PATH) { return } + try { + $targets = New-Object System.Collections.Generic.List[string] + foreach ($directory in $Directories) { + $target = Get-NormalizedPathToken $directory + if ($target) { Add-UniqueString $targets $target } + } + if ($targets.Count -eq 0) { return } + + $kept = New-Object System.Collections.Generic.List[string] + $changed = $false + foreach ($token in (Split-PathValue ([string]$env:PATH))) { + $probe = Get-NormalizedPathToken $token + $match = $false + foreach ($target in $targets) { + if ($probe -ieq $target) { $match = $true; break } + } + if ($match) { + $changed = $true + } else { + [void]$kept.Add($token) + } + } + + if ($changed) { + $env:PATH = ($kept.ToArray() -join ";") + OK "Cleaned PATH for this session" + } + } catch { + Record-Warning "could not clean this session's PATH" $_ + } + } + + function Send-EnvironmentBroadcast { + if (-not $State.EnvironmentChanged) { return } + try { + if (-not ("Win32.UninstallNativeMethods" -as [type])) { + $signature = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + Add-Type -Namespace Win32 -Name UninstallNativeMethods -MemberDefinition $signature -ErrorAction Stop + } + $result = [UIntPtr]::Zero + [void][Win32.UninstallNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x001A, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 5000, + [ref]$result + ) + } catch { + Record-Warning "could not broadcast the environment change to the desktop" $_ + } + } + + # ------------------------------------------------------------------------- + # Registry cleanup + # ------------------------------------------------------------------------- + + function Remove-HandledRegistryEntries($Records) { + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "registry entry for an unvalidated installation was left in place ($($record.Hive)\$($record.View))" + continue + } + + $exists = $null + try { $exists = Test-Path -LiteralPath $record.Dir -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify installation directory before registry cleanup: $($record.Dir)" + continue + } + if ($exists -and -not (Test-PendingReboot $record.Dir)) { + Record-Unresolved "registry entry left in place because files remain at $($record.Dir) ($($record.Hive)\$($record.View))" + continue + } + + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $record.Hive), $record.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($null -eq $key) { continue } + $key.Dispose() + $key = $null + + $base.DeleteSubKeyTree($uninstallPath, $false) + Record-Removed "removed uninstall registry entry ($($record.Hive)\$($record.View))" + } catch { + if ($record.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + Record-Warning "machine uninstall registry entry remains; re-run from an Administrator PowerShell" $_ + } else { + Record-Warning "could not remove uninstall registry entry ($($record.Hive)\$($record.View))" $_ + } + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + } + + # ------------------------------------------------------------------------- + # Owned stale installer temp directories + # ------------------------------------------------------------------------- + + function Test-InstallerTempDirectoryOwned($Directory) { + if (Test-PathHasReparseComponent $Directory) { return $false } + $snapshot = Get-SafeTreeSnapshot $Directory + if (-not $snapshot.Complete -or $snapshot.ReparsePoints.Count -gt 0 -or $snapshot.Dirs.Count -gt 0) { + return $false + } + + $files = @($snapshot.Files) + $markerPath = Join-Path $Directory ".pythinker-installer" + $markerValid = $false + if (Test-Path -LiteralPath $markerPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $markerValid = ((Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop).Trim() -eq $AppId) + } catch { + $markerValid = $false + } + } + + $setupFiles = @($files | Where-Object { [IO.Path]::GetFileName($_) -match '^PythinkerSetup-[0-9]+(?:\.[0-9]+){1,3}\.exe$' }) + if ($setupFiles.Count -ne 1) { return $false } + + $setupPath = $setupFiles[0] + $checksumPath = "${setupPath}.sha256" + $checksumValid = $false + if (Test-Path -LiteralPath $checksumPath -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $expectedText = (Get-Content -LiteralPath $checksumPath -Raw -ErrorAction Stop).Trim() + $expectedMatch = [regex]::Match($expectedText, '^[0-9a-fA-F]{64}') + if ($expectedMatch.Success) { + $actual = (Get-FileHash -LiteralPath $setupPath -Algorithm SHA256 -ErrorAction Stop).Hash + $checksumValid = ($actual -ieq $expectedMatch.Value) + } + } catch { + $checksumValid = $false + } + } + + foreach ($file in $files) { + $name = [IO.Path]::GetFileName($file) + if ($name -eq ".pythinker-installer") { continue } + if (Test-PathEqual $file $setupPath) { continue } + if (Test-PathEqual $file $checksumPath) { continue } + return $false + } + + return ($markerValid -or $checksumValid) + } + + function Remove-StaleInstallerTempDirs { + $setupRunning = @(Get-Process -Name "PythinkerSetup*" -ErrorAction SilentlyContinue).Count -gt 0 + if ($setupRunning) { + Dim "a Pythinker setup is running; leaving installer temp directories untouched" + return + } + + $cutoff = (Get-Date).AddHours(-2) + $guidPattern = '^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$' + $candidates = @() + try { + $candidates = @(Get-ChildItem -LiteralPath $TempRoot -Directory -Filter "pythinker-install-*" -ErrorAction Stop | + Where-Object { + $_.Name.Substring("pythinker-install-".Length) -match $guidPattern -and + $_.LastWriteTime -lt $cutoff + }) + } catch { + Record-Warning "could not inspect installer temp directory root" $_ + return + } + + foreach ($candidate in $candidates) { + if (-not (Test-InstallerTempDirectoryOwned $candidate.FullName)) { + Dim "skipping unverified installer temp directory: $($candidate.FullName)" + continue + } + Add-UniquePath $State.TempCleanupTargets $candidate.FullName + Remove-PathRobust $candidate.FullName "installer temp directory $($candidate.FullName)" + } + } + + # ------------------------------------------------------------------------- + # Optional user-data purge + # ------------------------------------------------------------------------- + + function Invoke-DataPurge { + if ($PurgeDataSetting -eq "1") { $State.PurgeDataRequested = $true } + $exists = $false + try { $exists = Test-Path -LiteralPath $DataDir -ErrorAction Stop } catch { + Record-Warning "could not inspect user data directory $DataDir" $_ + return + } + if (-not $exists) { return } + + $purge = $false + if ($PurgeDataSetting -eq "1") { + $purge = $true + } elseif ($PurgeDataSetting -eq "0") { + $purge = $false + } elseif (Test-Interactive) { + Write-Host "" + Write-Host " ${BOLD}User data found at $DataDir${RESET} ${DIM}(config, sessions, logs)${RESET}" + try { + $answer = Read-Host " Delete it too? [y/N]" + $purge = ($answer -match '^(?i)y(es)?$') + } catch { + $purge = $false + } + } + + if ($purge) { + $State.PurgeDataRequested = $true + Remove-PathRobust $DataDir "user data $DataDir" + } else { + Write-Host "" + Dim "User data kept at $DataDir — delete it manually or re-run with `$env:PYTHINKER_PURGE_DATA = '1'" + } + } + + # ------------------------------------------------------------------------- + # Final verification — unknown state is unresolved + # ------------------------------------------------------------------------- + + function Test-PathFinalState($Path, [string]$Label) { + $exists = $null + try { $exists = Test-Path -LiteralPath $Path -ErrorAction Stop } catch { $exists = $null } + if ($null -eq $exists) { + Record-Unresolved "could not verify $Label state: $Path" + return + } + if ($exists -and -not (Test-PendingReboot $Path)) { + Record-Unresolved "$Label still present: $Path" + } + } + + function Test-FinalState($InstallDirectories, $UserPathDirectories, $MachinePathDirectories, $Records, $StartDirectories) { + foreach ($directory in $InstallDirectories) { + Test-PathFinalState $directory "install directory" + } + + $userPathPresent = Test-PathEntryPresent $UserPathDirectories "CurrentUser" + if ($null -eq $userPathPresent) { + Record-Unresolved "could not verify user PATH state" + } elseif ($userPathPresent) { + Record-Unresolved "user PATH still contains a Pythinker entry" + } + + $machinePathPresent = Test-PathEntryPresent $MachinePathDirectories "LocalMachine" + if ($null -eq $machinePathPresent) { + Record-Unresolved "could not verify system PATH state" + } elseif ($machinePathPresent) { + $message = "system PATH still contains a Pythinker entry" + if (-not (Test-IsAdmin)) { $message += "; re-run from an Administrator PowerShell" } + Record-Unresolved $message + } + + $uninstallPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\$AppId" + foreach ($combo in (Get-UninstallRegistryCombos)) { + $base = $null + $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey((Get-RegistryHiveEnum $combo.Hive), $combo.View) + $key = $base.OpenSubKey($uninstallPath, $false) + if ($key) { + $message = "uninstall registry entry still present ($($combo.Hive)\$($combo.View))" + if ($combo.Hive -eq "LocalMachine" -and -not (Test-IsAdmin)) { + $message += "; re-run from an Administrator PowerShell" + } + Record-Unresolved $message + } + } catch { + Record-Unresolved "could not verify $($combo.Hive)\$($combo.View) uninstall registry state" + } finally { + if ($key) { $key.Dispose() } + if ($base) { $base.Dispose() } + } + } + + foreach ($directory in $StartDirectories) { + Test-PathFinalState $directory "Start Menu shortcut directory" + } + + foreach ($process in @(Get-Process -Name @("pythinker", "pythinker-code") -ErrorAction SilentlyContinue)) { + $path = Get-ProcessExecutablePath $process + if (-not $path) { + Record-Unresolved "could not verify remaining $($process.ProcessName) process PID $($process.Id)" + continue + } + if (Test-PathUnderDirs $path $InstallDirectories) { + Record-Unresolved "Pythinker process still running from an install directory (PID $($process.Id))" + } + } + + if ($State.PurgeDataRequested) { + Test-PathFinalState $DataDir "requested user data" + } + + foreach ($directory in $State.TempCleanupTargets) { + Test-PathFinalState $directory "owned stale installer temp directory" + } + + foreach ($record in $Records) { + if (-not $record.Dir) { + Record-Unresolved "installation registration could not be safely associated with a directory ($($record.Hive)\$($record.View))" + } + } + } + + # ------------------------------------------------------------------------- + # Main workflow + # ------------------------------------------------------------------------- + + function Invoke-PythinkerUninstall { + Write-Header + Step "Uninstalling Pythinker Code" + + $entries = @() + $records = @() + $installDirs = New-Object System.Collections.Generic.List[string] + $sweepDirs = New-Object System.Collections.Generic.List[string] + $userPathDirs = New-Object System.Collections.Generic.List[string] + $machinePathDirs = New-Object System.Collections.Generic.List[string] + $startDirs = New-Object System.Collections.Generic.List[string] + + try { + $entriesResult = Invoke-Step "could not read uninstall registry entries" { Find-UninstallEntries } + if ($null -ne $entriesResult) { $entries = @($entriesResult) } + + $recordsResult = Invoke-Step "installation record validation failed" { Get-InstallationRecords $entries } + if ($null -ne $recordsResult) { $records = @($recordsResult) } + + foreach ($known in $KnownInstallDirs) { + $safe = Get-SafeInstallDirectory $known + if ($safe) { + Add-UniquePath $installDirs $safe + Add-UniquePath $sweepDirs $safe + Add-UniquePath $userPathDirs $safe + Add-UniquePath $machinePathDirs $safe + } else { + Record-Unresolved "known installation directory failed safety validation: $known" + } + } + + foreach ($record in $records) { + if ($record.Dir) { + Add-UniquePath $installDirs $record.Dir + if ($record.Hive -eq "CurrentUser") { + Add-UniquePath $userPathDirs $record.Dir + } else { + Add-UniquePath $machinePathDirs $record.Dir + } + } + if ($record.Dir -and $record.CanSweep) { Add-UniquePath $sweepDirs $record.Dir } + } + + if ($records.Count -eq 0) { + Dim "No registered Pythinker uninstaller found — running controlled manual cleanup only" + } + + Invoke-Step "process shutdown failed" { Stop-PythinkerProcesses $installDirs } | Out-Null + + $executedUninstallers = New-Object System.Collections.Generic.List[string] + foreach ($record in $records) { + if (-not $record.Uninstaller) { continue } + if (-not $record.Trusted) { + Record-Warning "uninstaller failed trust validation: $($record.Uninstaller)" $null + continue + } + if (-not (Test-CanExecuteUninstaller $record)) { continue } + if (Test-ListContainsInsensitive $executedUninstallers $record.Uninstaller) { continue } + [void]$executedUninstallers.Add($record.Uninstaller) + Invoke-InnoUninstaller $record | Out-Null + } + + Step "Removing leftover files, PATH entries, shortcuts, and registry keys" + + # Only known installation directories are recursively swept. A custom + # registry-selected path is never passed to Remove-PathRobust. + foreach ($directory in $sweepDirs) { + Remove-PathRobust $directory "known installation directory $directory" + } + + # Removing an exact PATH token is safe for both known and validated custom + # installations, even when the custom directory itself is not swept. + foreach ($directory in $userPathDirs) { + [void](Remove-PathEntry $directory "CurrentUser") + } + foreach ($directory in $machinePathDirs) { + [void](Remove-PathEntry $directory "LocalMachine") + } + Remove-SessionPathEntries $installDirs + + Add-UniquePath $startDirs (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Pythinker") + Add-UniquePath $startDirs (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Pythinker") + foreach ($directory in $startDirs) { + Remove-PathRobust $directory "Start Menu shortcut directory $directory" + } + + Invoke-Step "registry entry handling failed" { Remove-HandledRegistryEntries $records } | Out-Null + Invoke-Step "temp cleanup failed" { Remove-StaleInstallerTempDirs } | Out-Null + Send-EnvironmentBroadcast + Invoke-Step "user data step failed" { Invoke-DataPurge } | Out-Null + + Step "Verifying final state" + try { + Test-FinalState $installDirs $userPathDirs $machinePathDirs $records $startDirs + } catch { + Record-Unresolved "final verification did not complete — $(Format-ErrorMessage $_)" + } + } catch { + Record-Unresolved "unexpected uninstall failure — $(Format-ErrorMessage $_)" + } + + Write-Host "" + Write-Host " ${BOLD}${FACE}Uninstall summary${RESET}" + Write-Host " $IRIS$($State.RemovedCount) item(s) removed$RESET" + + if ($State.PendingReboot.Count -gt 0) { + Write-Host " $CORAL$($State.PendingReboot.Count) item(s) scheduled for deletion on next reboot:${RESET}" + foreach ($path in $State.PendingReboot) { Write-Host " $CORAL•$RESET $path" } + } + + if ($State.Warnings.Count -gt 0) { + Write-Host " ${DIM}$($State.Warnings.Count) warning(s) during the run:${RESET}" + foreach ($warning in $State.Warnings) { Dim " • $warning" } + } + + Write-Host "" + if ($State.Unresolved.Count -gt 0) { + Write-Host " $CORAL$($State.Unresolved.Count) item(s) could not be fully removed or verified:${RESET}" + foreach ($issue in $State.Unresolved) { Write-Host " $CORAL•$RESET $issue" } + Write-Host "" + Dim "Most permission issues resolve by re-running this script from an Administrator PowerShell." + Write-Host "" + } elseif ($State.PendingReboot.Count -gt 0) { + Write-Host " ${BOLD}${IRIS}pythinker$RESET cleanup complete — ${BOLD}a reboot is required${RESET} to finish removing locked items." + Write-Host "" + } else { + Write-Host " ${BOLD}${IRIS}pythinker$RESET has been uninstalled. Open a fresh PowerShell for persistent PATH changes to appear." + Write-Host "" + } + + return [pscustomobject]@{ + Success = ($State.Unresolved.Count -eq 0) + Removed = $State.RemovedCount + PendingReboot = @($State.PendingReboot) + Warnings = @($State.Warnings) + Unresolved = @($State.Unresolved) + } + } + + $result = Invoke-PythinkerUninstall + if ($null -eq $result) { + throw "Pythinker uninstall did not produce a result." + } + if (-not $result.Success) { + throw "Pythinker uninstall completed with $($result.Unresolved.Count) unresolved issue(s) — see the summary above." + } + } finally { + if ($originalEncoding) { + try { [Console]::OutputEncoding = $originalEncoding } catch {} + } + } +}