Skip to content

feat: ship self-contained single-file binaries (#16) - #35

Open
t41372 wants to merge 10 commits into
fix/design-auditfrom
feat/issue-16-single-binary
Open

feat: ship self-contained single-file binaries (#16)#35
t41372 wants to merge 10 commits into
fix/design-auditfrom
feat/issue-16-single-binary

Conversation

@t41372

@t41372 t41372 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #16.

Base note: stacked on fix/design-audit (this branch's design fixes yield to that branch's implementations where the two reviews overlapped). When design-audit merges, GitHub will retarget this PR to main automatically; only the 7 commits above belong to this PR.

What ships

Every release now attaches self-contained single-file binaries — no Python, no uv needed on the user's machine:

Asset Platform Verified
skit-linux-x86_64 glibc ≥ 2.26 (RHEL 8+, Amazon Linux 2, Debian 10+) smoke 12/12 + amazonlinux:2 boot
skit-linux-aarch64 glibc arm64 CI smoke
skit-linux-x86_64-musl Alpine & friends docker-rehearsed, smoke 12/12
skit-darwin-arm64 / -x86_64 macOS (ad-hoc signed) CI smoke
skit-windows-x86_64.exe Windows 10+ CI smoke
  • Why PyInstaller: measured against Nuitka (slower warm start 425 ms vs 372 ms, open onefile signal issue masks exit codes), pex --scie eager (121 MB / 542 ms), and PyApp (first run downloads uv from GitHub + deps from PyPI — the exact mainland-China failure this binary exists to avoid). The glibc floor comes from the uv-managed python-build-standalone interpreter, not the runner — packaging/skit.spec's header documents every collect line as a contract.
  • packaging/smoke.py gates every asset: frozen-app failures are silent by design (a lost tree-sitter grammar degrades analyzers to None; lost metadata reads 0.0.0+unknown), so each check asserts the positive outcome, TUI-in-a-pty included. workflow_dispatch = dry run (build + smoke, no publish).
  • src/skit/childenv.py — the one runtime change: the frozen bootloader poisons LD_LIBRARY_PATH for its own libraries, and skit's whole job is spawning children (user scripts, uv, editors, npm installers). Every child-env assembly point and every {env:X} delivery value now goes through child_env(); smoke asserts the child environment is clean.
  • Release plumbing: draft-first GitHub Release (assets attach before releases/latest flips), checksums.txt + build-provenance attestation, scripts/install.sh with sha256 verification, uv/pipx-shim collision guard, and SKIT_INSTALL_MIRROR for gh-proxy-style mirrors (mainland China; docs state exactly what the checksum does and does not prove through a mirror).

Also in this PR — the absurdity-review fixes that survived the design-audit overlap

A parallel whole-codebase review ran here; overlapping findings yielded to design-audit's implementations, and the survivors landed with tests:

  • skit doctor on a fresh/non-Python library no longer exits 1 telling the binary's own target audience to install uv from a website — "not needed yet", exit 0; red only when Python entries actually cannot run (healthcheck.uv_required, one fact for both faces; --json grows an additive uv_required key).
  • uv download failures now carry the mirror lifeline (skit config mirror.github nju) — the first-run wizard only fires on bare skit, a door agents never walk through.
  • preset save gains --set NAME=VALUE: the deterministic mint-without-running lane; the non-interactive refusal is now exit 2 like every sibling wrong-shape refusal, --dry-run persists nothing, and a new test executes the Agent Skill's Presets recipe end-to-end so the SKILL can never again teach semantics the CLI dropped.
  • Vocabulary reunification: "Unknown JS runtime" ↔ skit runner cross-signposts, "Entry not found", Entry-settings labels across all three READMEs, params --help files its six policy flags under an "Entry & launch policy" panel, and cli.mdx explains param_origin vs the historical param_source token.

Verification

ruff / ty / i18n gate (zh-CN + zh-TW 100%, no fuzzy) all green · 5456 tests, 100% line+branch coverage · docs build + link checker green · targeted mutation testing on every function this PR touches: 0 survivors (full local mutmut is environmentally invalid on the dev box — missing fish/pwsh/deno make unrelated mutants "survive"; nightly CI mutation remains the authoritative gate) · binary rebuilt and smoke-tested after every round.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added standalone binary installation for supported platforms, including checksum verification and configurable mirrors.
    • Added non-interactive preset creation with explicit values.
    • Added clearer health checks for when the uv runtime is required.
    • Added secure child-process environment handling for standalone binaries.
  • Bug Fixes

    • Dry runs no longer save presets.
    • Improved release retries and asset publishing reliability.
    • Corrected several shortcut labels and user-facing error messages.
  • Documentation

    • Expanded installation, upgrade, uninstall, binary, preset, and CLI reference guidance in English and Chinese.

t41372 and others added 7 commits July 26, 2026 12:53
skit required uv (and therefore a Python toolchain) before it could help
anyone. Every release now also carries a standalone binary — PyInstaller
onefile, one per platform — so a user with neither Python nor uv can run
skit, and skit keeps its existing contract for the one thing that still
needs uv: running Python scripts (ask first, fetch a pinned, checksum-
verified copy, mirror-aware).

Why PyInstaller: it is the only battle-tested lane for this exact shape
(Textual TUI + Typer + tree-sitter C extensions) — yt-dlp, mitmproxy and
borg all ship it. Measured against the alternatives on this stack:
Nuitka's cached-onefile start was slower than PyInstaller's (425 ms vs
372 ms) and its open onefile signal issue can mask exit codes and leave
the terminal in alt-screen; pex --scie eager came out at 121 MB / 542 ms;
PyApp resolves dependencies from PyPI on the user's first run, which is
exactly the mainland-China failure the binary exists to avoid.

- packaging/skit.spec: the build recipe. Its header documents every
  collect/hidden-import as a contract, not a default — the grammars and
  skit's own string-imported module degrade SILENTLY when dropped.
- packaging/smoke.py: the release gate. Each check asserts a positive
  outcome (real version, CJK help, a parameter found by the analyzer,
  a clean child env, the TUI reaching the alternate screen), because
  every frozen-app failure mode here is otherwise quiet.
- src/skit/childenv.py: the one behavioural change. The frozen bootloader
  points LD_LIBRARY_PATH at its private bundle; skit spawns user scripts,
  uv, editors and installers, so every child-env assembly point — and
  every {env:X} value skit delivers, including the TUI's env picker —
  now goes through child_env() instead of raw os.environ.
- release.yml: 6 binaries (linux x86_64/aarch64 glibc + x86_64 musl,
  macOS arm64/x86_64, Windows x64) built on free runners, smoke-tested,
  then attached to a draft release with checksums.txt and a provenance
  attestation; the draft only flips public once every asset is uploaded,
  so releases/latest is never half-populated. workflow_dispatch is the
  no-publish dry run.
- scripts/install.sh: OS/arch/musl detection, sha256 verification, and
  SKIT_INSTALL_MIRROR for gh-proxy-style mirrors; a binary that installs
  but cannot run fails loudly instead of reporting success.

The glibc floor (verified down to 2.26) comes from the uv-managed
python-build-standalone interpreter, not the build host — which is why
the workflow must keep using setup-uv's managed Python.

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

# Conflicts:
#	src/skit/langs/powershell/cli_reader.py
Two ways the release could break silently between tag pushes: a new
language's tree-sitter grammar lands without the spec learning about it
(the analyzer import is guarded, so the binary ships with that language's
parameter analysis quietly disabled instead of crashing), and a rename on
one side of the workflow/packaging pair (spec path, smoke path, the
packaging dependency group) that only surfaces at tag time, when half a
release already exists. Both are now discovered, not hand-listed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Targeted mutation testing caught what the seam tests missed: dropping
env=child_env() from the bash -n gate or the pwsh param() probe killed
no test — the scrub was only pinned at the launcher and editor seams.
Two new tests capture the env each probe actually passes and assert the
loader path is gone (they also kill six mutants that already survived
on main). The one surviving childenv mutant is a true equivalent —
getattr's absent-attribute default, False vs None, both falsy — pinned
with the repo's standard pragma instead of an unkillable test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A parallel whole-codebase review ran against the same codebase as the
fix/design-audit branch; the overlapping findings (tui_add newline
destruction, the per-highlight plan rebuild, the Ctrl+O chord collision,
README's uv-consent claim) yield to design-audit's implementations, which
this branch now sits on. What remains here is the non-overlapping set:

- `skit doctor` on an empty library exited 1 and prescribed a system-wide
  uv install — to exactly the audience the standalone binary targets,
  and against README's "the binary is complete in itself" promise. uv
  missing is now red only when Python entries actually cannot run; an
  empty/non-Python library reports "not needed yet" and exits 0. The TUI
  health screen mirrors the same split.
- The uv download failure now hands mainland-China users the mirror
  lifeline (skit config mirror.github nju) in the error itself — the
  first-run wizard only fires on bare `skit`, a door agents and binary
  users never walk through.
- Non-interactive `preset save` minted presets out of whatever prefill
  lay around ("never guess" says the contract); it now refuses and names
  the two deterministic sources. `--dry-run` persisted presets; a dry
  run now writes nothing and says so.
- install.sh refused-shim guard: uv tool's `skit` shim lives in the same
  ~/.local/bin the installer writes to; overwriting it is how installs
  "mysteriously downgrade" on the next `uv tool upgrade`. READMEs and
  the install docs now name the collision.
- Vocabulary reunification: "Unknown JS runtime" (with a signpost to
  skit runner), `skit runner` help signposts js.runner, "Entry not
  found", Entry settings labels in all three READMEs, the launch-menu/
  run-form bridge on first mention, params --help files its six policy
  flags under an "Entry & launch policy" panel, and cli.mdx explains
  param_origin against the historical param_source token.

Deliberately NOT changed: Esc quitting the Library instantly. skit is a
launcher; dismissing it must cost one keystroke (the double-press guard
stays on Ctrl+C, the abort chord people mash by reflex).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test/i18n follow-up to 9c38c81 — plan-cache tests rewritten, doctor/preset/
dry-run/chord-law/CRLF contracts added, 9 msgids translated (zh-CN + zh-TW).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…base

The rebase took design-audit's catalogs wholesale, dropping the nine msgids the
surviving fixes emit; regenerated via extract/update, translations re-applied
from the pre-rebase branch, three pybabel fuzzy mismatches retranslated, and one
test repointed at the lazily-imported promptform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-review hunted the interfaces where this branch's fixes and the
design-audit branch's fixes meet; seven seams, worst first:

- SKILL.md still taught `run --save-preset --dry-run` as "create without
  running" — a recipe the dry-run fix had just turned into a guaranteed
  three-step failure (exit 0, nothing saved, then exit 2 on -p), and the
  capability itself had silently died with it. `preset save` gains --set
  NAME=VALUE: the explicit-values lane that mints a preset with no
  terminal, no history, and no execution — run's strict parser, C3 secret
  skip included. The skill's recipe now teaches that lane, and a new
  semantic smoke test EXECUTES the skill's Presets block end-to-end, so
  the doc can never again promise semantics the CLI dropped (the
  command-tree checks alone stayed green through exactly that break).
- The non-interactive preset-save refusal exited 1 where every sibling
  wrong-shape refusal (remove/preset delete without --yes, editor lanes
  without a terminal) exits 2 — now EXIT_USAGE with the house sentence
  shape, naming --set and --from-last.
- "uv is required here" lived as two hand-synced copies in doctor and
  the Health screen, against healthcheck.py's own one-fact charter; the
  predicate moved into healthcheck.uv_required, both faces render it.
- doctor --json now carries the verdict (additive uv_required key)
  instead of making automation re-derive the exit code's reasoning.
- Recovered the _has_drift mutation witness the rebase dropped: the
  or→and guard mutant lost its accidental killer when the plan cache
  became missing-file-tolerant; the analyzer-less-drifted-prompt test
  pins the operator on the new base too.
- Paper cuts: tui_prefs docstrings still said Ctrl+O/Ctrl+K after the
  chord move, AGENTS.md's key law still said "Script settings", zh-TW's
  screenshot alt text still said 腳本設定, and the dry-run comment
  claimed "writes NOTHING" three lines after the runner pick persists —
  it now states exactly what a dry run does not persist.

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

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a7b26e4c-5b91-4f64-a2dd-bf573bb8bbc6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 18 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing feat/issue-16-single-binary (b44fcdc) with fix/design-audit (9ed2152)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

The macOS CI runners convince rich the captured stream is color-capable,
so every --flag arrives sliced apart by style codes and a literal `in`
can never match — the same test passes bare on Linux. _flat now strips
escapes before collapsing whitespace, which is what "the help output
contains this flag" always meant.

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

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Round-3 sign-off memos plus the /code-review finder results, all small,
none structural:

- An all-secret `preset save --set TOKEN=…` minted an empty preset and
  reported success — the exact husk argstate.purge_secret's own comment
  calls garbage, so two modules disagreed about whether {} may exist as
  a preset. Now a wrong-shape refusal (exit 2) naming why; the mixed
  case keeps the skip-notice lane. The interactive all-secret form path
  gets the same treatment (its old test pinned the husk).
- cli.py's header exit-code contract said "Other commands: 0/1/2" while
  preset save --set can exit 125 through run's own value machinery; the
  header now says so instead of the docs being righter than the code.
- doctor evaluated healthcheck.uv_required up to twice per invocation
  across four call sites; hoisted to one evaluation.
- The skill recipe test hand-rolled dir isolation conftest already
  applies autouse, and grew a second fence parser three screens below
  the existing one; _skill_command_lines gained a section filter so one
  walk owns the block grammar, and whole-line comments in a recipe are
  skipped instead of tripping an IndexError.
- README.zh-CN.md's screenshot alt text still said 脚本设置 — the
  byte-parallel twin of the zh-TW fix the previous commit made.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 154-160: Update the binary-musl job’s container configuration to
use a glibc-compatible or Node-provisioned Alpine image so the Node-based
Checkout and upload-artifact actions execute successfully; alternatively, remove
the job-level container and run the PyInstaller build explicitly inside
python:3.13-alpine while keeping the surrounding GitHub Actions steps on the
normal Ubuntu runner.

In `@docs/content/docs/installation.mdx`:
- Around line 41-47: Update the installation “Knobs” documentation to include
SKIT_INSTALL_FORCE=1 as the escape hatch for overriding the shim-overwrite
refusal described in the same paragraph. Keep the existing guidance for
uninstalling one lane or setting SKIT_INSTALL_DIR unchanged.

In `@packaging/smoke.py`:
- Line 26: Update the CJK validation in the smoke-test logic using the CJK
symbol to detect whether translated help output contains any CJK ideograph,
rather than checking membership in the fragile 40-character sample range. Apply
the same property-based check to the related validation at the additionally
affected lines, preserving the existing pass/fail behavior.

In `@pyproject.toml`:
- Around line 62-68: Update the packaging dependency configuration to make
release builds reproducible: verify that the binary release workflow enforces a
committed lockfile, and if it does not, replace the range constraints in the
packaging group with exact versions for both pyinstaller and
pyinstaller-hooks-contrib. Ensure hooks-contrib cannot change implicitly between
builds.

In `@README.md`:
- Around line 224-226: Update the standalone-binary uninstall command in
README.md (lines 224-226), README.zh-CN.md (lines 221-223), and README.zh-TW.md
(lines 221-223) to honor SKIT_INSTALL_DIR, using the default ~/.local/bin path
only when the variable is unset.

In `@scripts/install.sh`:
- Around line 63-64: Rename the destination-path variable assigned near the
existing-file check from target to dest, and update all references in that
installation block accordingly. Preserve target as the platform slug used by the
surrounding platform-selection logic.
- Around line 89-98: Update the failed run-check branch in scripts/install.sh to
remove "$INSTALL_DIR/skit" after the version check fails and before calling die.
Keep the existing diagnostic message and failure behavior unchanged, ensuring
the unusable binary is not left on PATH.

In `@tests/test_agent_skill.py`:
- Around line 172-184: Update the recipe execution assertions in the test around
the preset save command to inspect argstate.load_state(entry.slug)["presets"]
immediately after the “preset save” line, asserting the saved preset contents
equal {"a": "1", "b": "2"} before deletion proceeds. Keep the existing final
assertion confirming the “nightly” preset is removed.

In `@tests/test_cli_cov.py`:
- Around line 310-327: Update test_preset_save_set_skips_secrets_with_notice to
assert that result.output contains “Secret values are never stored in presets,”
while preserving the existing assertions that the secret is excluded from the
saved preset and state.

In `@tests/test_exec_mut.py`:
- Around line 858-864: Update the test covering ensure_uv_downloaded() to pin
SKIT_LANG to en before asserting the exact English exception message, following
the locale setup used in tests/test_uvman.py. Keep the existing message
assertion unchanged.

In `@tests/test_packaging.py`:
- Around line 85-95: The test test_release_workflow_and_packaging_files_agree
must verify the actual PyInstaller contract: assert that the packaging
dependency group contains an exact pyinstaller entry rather than relying on
substring matching, and assert that the release workflow invokes PyInstaller
through a command such as “uv run pyinstaller.”
- Around line 64-82: Update the grammar import regex in
test_binary_spec_pins_every_tree_sitter_grammar to match tree_sitter_* imports
regardless of indentation, including those inside try/except blocks. Preserve
the existing recursive discovery and missing-grammar validation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 018cac84-7940-4d4e-9c34-1b8eace6541c

📥 Commits

Reviewing files that changed from the base of the PR and between 9ed2152 and fcacd6b.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (54)
  • .github/workflows/release.yml
  • AGENTS.md
  • README.md
  • README.zh-CN.md
  • README.zh-TW.md
  • docs/content/docs/cli.mdx
  • docs/content/docs/installation.mdx
  • docs/content/docs/meta.json
  • packaging/entry.py
  • packaging/skit.spec
  • packaging/smoke.py
  • pyproject.toml
  • scripts/install.sh
  • skills/skit/SKILL.md
  • src/skit/childenv.py
  • src/skit/cli.py
  • src/skit/editor.py
  • src/skit/flows.py
  • src/skit/healthcheck.py
  • src/skit/langs/javascript/inject.py
  • src/skit/langs/launch.py
  • src/skit/langs/powershell/cli_reader.py
  • src/skit/langs/shell/inject.py
  • src/skit/launcher.py
  • src/skit/locales/skit.pot
  • src/skit/locales/zh_CN/LC_MESSAGES/skit.mo
  • src/skit/locales/zh_CN/LC_MESSAGES/skit.po
  • src/skit/locales/zh_TW/LC_MESSAGES/skit.mo
  • src/skit/locales/zh_TW/LC_MESSAGES/skit.po
  • src/skit/skills/skit/SKILL.md
  • src/skit/store.py
  • src/skit/tokens.py
  • src/skit/tui_form.py
  • src/skit/tui_health.py
  • src/skit/tui_prefs.py
  • src/skit/uvman.py
  • tests/test_agent_skill.py
  • tests/test_childenv.py
  • tests/test_cli.py
  • tests/test_cli_cov.py
  • tests/test_cli_design_cov.py
  • tests/test_cli_gaps_cov.py
  • tests/test_cli_mut_part01.py
  • tests/test_default_semantics_review_fixes.py
  • tests/test_editor.py
  • tests/test_exec_mut.py
  • tests/test_langs.py
  • tests/test_packaging.py
  • tests/test_run_set.py
  • tests/test_store_mut.py
  • tests/test_tui_add_mut_part04.py
  • tests/test_tui_mut_part05.py
  • tests/test_tui_prefs_health_cov.py
  • tests/test_uvman.py

Comment on lines +154 to +160
container:
image: python:3.13-alpine
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

GitHub Actions container alpine musl node20 checkout "no such file or directory" workaround

💡 Result:

The error "no such file or directory" when running GitHub Actions in an Alpine Linux container occurs because GitHub Actions attempts to use a pre-packaged Node.js binary (often in /e/) that is dynamically linked against glibc [1][2]. Alpine Linux uses musl libc, which is incompatible with glibc-linked binaries, causing the OS to report that the file does not exist even though the path is correct [1][2]. There are several common workarounds depending on your requirements: 1. Install Node.js inside the container: You can install Node.js manually using your distribution's package manager (e.g., apk add nodejs npm). To ensure GitHub Actions uses this version instead of the internal one, you can link it to the expected location: yaml - name: Fix Node path run: | mkdir -p /e/node20/bin ln -sf $(which node) /e/node20/bin/node ln -sf $(which npm) /e/node20/bin/npm ln -sf $(which npx) /e/node20/bin/npx [3] 2. Trick the Runner into Detecting Alpine: If your container is Alpine-based (or another musl-based system) but the runner fails to detect it correctly, you can temporarily modify /etc/os-release to identify the system as Alpine before the checkout step [1]: yaml - name: Force Alpine identification run: | sed '/^ID="/s/original-distro-name/alpine/' /etc/os-release > /tmp/os-release mv /tmp/os-release /etc/os-release Replace original-distro-name with your current distribution's ID [1]. 3. Use a different base image: If possible, use a Debian-based container (e.g., ubuntu or debian) instead of Alpine, as these distributions use glibc and are natively compatible with the Node.js binaries provided by GitHub Actions [4][2]. 4. Check your architecture: In some cases, this error also appears on 32-bit (i386) architectures because the standard Node.js binaries provided by GitHub Actions are often 64-bit only [3][5]. If you are running on non-x64_86 hardware, installing Node.js manually (as in step 1) is the recommended approach [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file context =="
if [ -f .github/workflows/release.yml ]; then
  nl -ba .github/workflows/release.yml | sed -n '130,180p'
  echo "== action refs in workflow =="
  rg -n 'uses: actions/checkout|uses: actions/upload-artifact|js' .github/workflows/release.yml || true
  echo "== python image and container context =="
  rg -n 'python:3\.13-alpine|container:|steps:' .github/workflows/release.yml || true
else
  echo "workflow file missing"
fi

Repository: t41372/skit

Length of output: 217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow context =="
if [ -f .github/workflows/release.yml ]; then
  awk 'NR>=130 && NR<=180 {printf "%d\t%s\n", NR, $0}' .github/workflows/release.yml
  echo
  echo "== relevant uses/ref comments =="
  grep -nE 'uses: actions/(checkout|upload-artifact|setup-node)|python:3\.13-alpine|container:|workdir' .github/workflows/release.yml || true
else
  echo "workflow file missing"
fi

Repository: t41372/skit

Length of output: 3109


🌐 Web query:

GitHub Actions container Node20 Alpine python:3.13-alpine no such file or directory

💡 Result:

The error "exec /e/node20/bin/node: no such file or directory" in GitHub Actions when using a container (such as Alpine-based images) occurs because the GitHub Actions runner attempts to execute its own pre-packaged, dynamically linked Node.js binary inside your container [1][2][3]. This error typically arises for one of two reasons: 1. Dynamic Linker Mismatch (glibc vs. musl): The GitHub Actions runner often injects a Node.js binary compiled against glibc (the standard C library on most Linux distributions). Alpine Linux, however, uses musl libc [4][2][5]. Because the glibc-based binary cannot find the required dynamic linker (e.g., /lib64/ld-linux-x86-64.so.2) on an Alpine system, the operating system reports that the file does not exist, even though the binary is physically present [4][2]. 2. Environment/Volume Issues: GitHub Actions mounts the runner's tool directory (often located at /e) into the container [1][6]. If the container environment is misconfigured or lacks the expected directory structure, the shell will be unable to locate the injected binary [3]. To resolve this issue, consider these approaches: Workaround 1: Install Node.js natively in your Dockerfile Instead of relying on the runner to inject its own Node.js version, install Node.js directly in your container image [1][7]. - Add the following to your Dockerfile: RUN apk add --no-cache nodejs npm Workaround 2: Create a Symlink (Quick Fix) If you cannot modify the base image, you can try to create a symlink in a setup step to point the runner to the Node.js binary installed within the container [1]. - In your workflow, add a setup step: - name: Fix Node path run: | mkdir -p /e/node20/bin ln -sf $(which node) /e/node20/bin/node ln -sf $(which npm) /e/node20/bin/npm Workaround 3: Adjust /etc/os-release In some cases, the runner detects Alpine incorrectly or experiences issues because of the distribution name. Some users have successfully "tricked" the runner by temporarily modifying /etc/os-release to match a different distribution ID, though this is fragile and generally discouraged [4]. It is recommended to avoid using custom container actions on Alpine-based images if they rely heavily on pre-injected Node.js tools, as this incompatibility is a known architectural limitation of how GitHub Actions runners currently interact with non-glibc container environments [2][8].

Citations:


Run the musl job with a glibc-compatible container or a Node-provisioned Alpine image.

.github/workflows/release.yml uses python:3.13-alpine for binary-musl, but the actions/checkout and actions/upload-artifact steps are Node.js actions that run using the GitHub runner’s glibc-linked Node binary; on this musl-only container, these steps typically fail with no such file or directory before/after the musl build. Use a base image that contains the Node binary GitHub Actions expects, run the PyInstaller build inside docker run python:3.13-alpine … from a normal ubuntu job, or add gcompat/libstdc++ coverage earlier in jobs that can reach the first Node action.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 154 - 160, Update the binary-musl
job’s container configuration to use a glibc-compatible or Node-provisioned
Alpine image so the Node-based Checkout and upload-artifact actions execute
successfully; alternatively, remove the job-level container and run the
PyInstaller build explicitly inside python:3.13-alpine while keeping the
surrounding GitHub Actions steps on the normal Ubuntu runner.

Comment on lines +41 to +47
Knobs: `SKIT_VERSION=v0.5.0` pins a release, `SKIT_INSTALL_DIR=~/bin` changes the target
directory, and `SKIT_INSTALL_MIRROR` is covered under [mainland China](#mainland-china)
below. On Windows, download the `.exe`, rename it `skit.exe`, and put its directory on
`PATH`. Note the PyPI lane's `uv tool install` puts its `skit` shim in the same
`~/.local/bin`; the script refuses to overwrite such a shim (a binary that a later
`uv tool upgrade` would silently stomp is worse than an error) — uninstall one lane
before installing the other, or set `SKIT_INSTALL_DIR`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document SKIT_INSTALL_FORCE.

The knobs list omits the escape hatch the script itself advertises in its refusal message (SKIT_INSTALL_FORCE=1), so users hitting the shim guard have to read the script to find it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/installation.mdx` around lines 41 - 47, Update the
installation “Knobs” documentation to include SKIT_INSTALL_FORCE=1 as the escape
hatch for overriding the shim-overwrite refusal described in the same paragraph.
Keep the existing guidance for uninstalling one lane or setting SKIT_INSTALL_DIR
unchanged.

Comment thread packaging/smoke.py
import time
from pathlib import Path

CJK = tuple(chr(c) for c in range(0x4E00, 0x4E00 + 40)) # spot-check range is plenty

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

CJK spot-check range is fragile.

CJK covers only U+4E00–U+4E27; whether the translated --help happens to contain one of those 40 glyphs is incidental, so a perfectly good zh catalog can FAIL (or a partially translated one PASS). Test the property you mean: any CJK ideograph in the output.

♻️ Proposed check
-CJK = tuple(chr(c) for c in range(0x4E00, 0x4E00 + 40))  # spot-check range is plenty
+def is_cjk(ch: str) -> bool:  # CJK Unified Ideographs (BMP block)
+    return "\u4e00" <= ch <= "\u9fff"
-        any(ch in out.stdout for ch in CJK),
+        any(is_cjk(ch) for ch in out.stdout),

Also applies to: 126-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packaging/smoke.py` at line 26, Update the CJK validation in the smoke-test
logic using the CJK symbol to detect whether translated help output contains any
CJK ideograph, rather than checking membership in the fragile 40-character
sample range. Apply the same property-based check to the related validation at
the additionally affected lines, preserving the existing pass/fail behavior.

Comment thread pyproject.toml
Comment on lines +62 to +68
# Binary release builds (packaging/skit.spec) — not part of the dev gate. hooks-contrib is
# pyinstaller's own dependency; the floor is explicit because 2026.2 added the hook that
# collects rich>=14.3's split-out unicode data, without which the frozen TUI misrenders.
packaging = [
"pyinstaller>=6.21,<7",
"pyinstaller-hooks-contrib>=2026.2",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the packaging toolchain reproducible.

The group is described as pinned, but pyinstaller is range-constrained and pyinstaller-hooks-contrib has only a lower bound. Confirm release builds enforce a committed lockfile; otherwise pin exact versions, especially for hooks-contrib, because hook changes can alter the shipped binary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 62 - 68, Update the packaging dependency
configuration to make release builds reproducible: verify that the binary
release workflow enforces a committed lockfile, and if it does not, replace the
range constraints in the packaging group with exact versions for both
pyinstaller and pyinstaller-hooks-contrib. Ensure hooks-contrib cannot change
implicitly between builds.

Comment thread README.md
Comment on lines 224 to +226
```bash
uv tool uninstall skit-cli
uv tool uninstall skit-cli # installed from PyPI via uv/pip
rm ~/.local/bin/skit # installed the standalone binary

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make all uninstall instructions honor SKIT_INSTALL_DIR.

The installation docs support custom binary directories, but every uninstall example removes only the default path. Use ${SKIT_INSTALL_DIR:-$HOME/.local/bin}/skit or explicitly document the custom-path alternative.

  • README.md#L224-L226: update the standalone-binary removal command.
  • README.zh-CN.md#L221-L223: update the standalone-binary removal command.
  • README.zh-TW.md#L221-L223: update the standalone-binary removal command.
📍 Affects 3 files
  • README.md#L224-L226 (this comment)
  • README.zh-CN.md#L221-L223
  • README.zh-TW.md#L221-L223
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 224 - 226, Update the standalone-binary uninstall
command in README.md (lines 224-226), README.zh-CN.md (lines 221-223), and
README.zh-TW.md (lines 221-223) to honor SKIT_INSTALL_DIR, using the default
~/.local/bin path only when the variable is unset.

Comment thread tests/test_agent_skill.py
Comment on lines +172 to +184
entry = store.add_command("echo {a} {b}", name="job")
runner = CliRunner()
ran_something = False
for line in lines:
argv = shlex.split(line, comments=True)
assert argv[0] == "skit"
argv = [tok.replace("<name>", "job") for tok in argv[1:]]
result = runner.invoke(cli.app, argv, catch_exceptions=False)
assert result.exit_code == 0, f"skill recipe line failed: {line!r}\n{result.output}"
ran_something = True
assert ran_something
# The recipe's own promises: the preset existed mid-recipe and delete removed it.
assert "nightly" not in argstate.load_state(entry.slug)["presets"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the preset contents before deletion.

The final state only proves that deletion succeeded. Assert {"a": "1", "b": "2"} immediately after the preset save line so the recipe test catches a regression that creates an empty or incorrectly parsed preset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_agent_skill.py` around lines 172 - 184, Update the recipe
execution assertions in the test around the preset save command to inspect
argstate.load_state(entry.slug)["presets"] immediately after the “preset save”
line, asserting the saved preset contents equal {"a": "1", "b": "2"} before
deletion proceeds. Keep the existing final assertion confirming the “nightly”
preset is removed.

Comment thread tests/test_cli_cov.py
Comment on lines +310 to +327
def test_preset_save_set_skips_secrets_with_notice(tmp_path):
"""C3 holds on the --set lane too: a secret value never lands in the preset."""
text = metawriter.write_params(
'CITY = "Taipei"\nTOKEN = "x"\nprint(CITY, TOKEN)\n',
[
ParamDecl(name="CITY", binding="const", type="str", default="Taipei"),
ParamDecl(name="TOKEN", binding="const", type="str", default="", secret=True),
],
)
ent = store.add_python(_py(tmp_path, text), name="a")
result = runner.invoke(
cli.app,
["preset", "save", "a", "n", "--set", "CITY=Kyoto", "--set", "TOKEN=hunter2"],
)
assert result.exit_code == 0
saved = argstate.load_state(ent.slug)["presets"]["n"]
assert saved == {"CITY": "Kyoto"}
assert "hunter2" not in str(argstate.load_state(ent.slug))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the secret-skipped notice.

Line 324 verifies the secret is excluded, but this test does not verify the promised notice. Add an assertion for Secret values are never stored in presets in result.output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cli_cov.py` around lines 310 - 327, Update
test_preset_save_set_skips_secrets_with_notice to assert that result.output
contains “Secret values are never stored in presets,” while preserving the
existing assertions that the secret is excluded from the saved preset and state.

Comment thread tests/test_exec_mut.py
Comment on lines +858 to +864
# The wrapped failure carries the mirror lifeline: users who hit this arrive via
# `skit add && skit run`, never through the bare-`skit` first-run wizard.
assert str(exc_info.value) == (
"Failed to download uv: <urlopen error connection refused>. "
"Behind a firewall or in mainland China? Point skit at a mirror: "
"skit config mirror.github nju (or TUI Preferences → mirrors)."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pin the locale before asserting the English message.

ensure_uv_downloaded() translates this text through gettext, but this test does not set SKIT_LANG=en (unlike tests/test_uvman.py, Line 311). A non-English test environment can therefore fail this exact assertion. Set the locale here or assert against the localized message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_exec_mut.py` around lines 858 - 864, Update the test covering
ensure_uv_downloaded() to pin SKIT_LANG to en before asserting the exact English
exception message, following the locale setup used in tests/test_uvman.py. Keep
the existing message assertion unchanged.

Comment thread tests/test_packaging.py
Comment on lines +64 to +82
def test_binary_spec_pins_every_tree_sitter_grammar() -> None:
"""The frozen binary's spec must name every grammar the analyzers import.

Each ``import tree_sitter_<lang>`` in langs/ sits inside a try/except guard, so a
grammar missing from the bundle does not crash the binary — it silently turns that
language's analyzer into ``None`` and ships a build with parameter analysis quietly
disabled. Discover the imports instead of naming today's three, so a new language's
grammar cannot be added without the spec learning about it."""
spec = (ROOT / "packaging" / "skit.spec").read_text(encoding="utf-8")
grammars = {
match.group(1)
for path in (ROOT / "src" / "skit" / "langs").rglob("*.py")
for match in re.finditer(
r"^import (tree_sitter_\w+)", path.read_text(encoding="utf-8"), re.MULTILINE
)
}
assert grammars
missing = sorted(name for name in grammars if name not in spec)
assert not missing, f"packaging/skit.spec does not collect: {missing}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Match the guarded grammar imports.

Line 77 only matches import at column zero, but the test states these imports are inside try/except guards and therefore indented. grammars becomes empty and Line 80 fails before validating the spec.

Proposed fix
-            r"^import (tree_sitter_\w+)", path.read_text(encoding="utf-8"), re.MULTILINE
+            r"^[ \t]*import (tree_sitter_\w+)",
+            path.read_text(encoding="utf-8"),
+            re.MULTILINE,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_binary_spec_pins_every_tree_sitter_grammar() -> None:
"""The frozen binary's spec must name every grammar the analyzers import.
Each ``import tree_sitter_<lang>`` in langs/ sits inside a try/except guard, so a
grammar missing from the bundle does not crash the binaryit silently turns that
language's analyzer into ``None`` and ships a build with parameter analysis quietly
disabled. Discover the imports instead of naming today's three, so a new language's
grammar cannot be added without the spec learning about it."""
spec = (ROOT / "packaging" / "skit.spec").read_text(encoding="utf-8")
grammars = {
match.group(1)
for path in (ROOT / "src" / "skit" / "langs").rglob("*.py")
for match in re.finditer(
r"^import (tree_sitter_\w+)", path.read_text(encoding="utf-8"), re.MULTILINE
)
}
assert grammars
missing = sorted(name for name in grammars if name not in spec)
assert not missing, f"packaging/skit.spec does not collect: {missing}"
def test_binary_spec_pins_every_tree_sitter_grammar() -> None:
"""The frozen binary's spec must name every grammar the analyzers import.
Each ``import tree_sitter_<lang>`` in langs/ sits inside a try/except guard, so a
grammar missing from the bundle does not crash the binaryit silently turns that
language's analyzer into ``None`` and ships a build with parameter analysis quietly
disabled. Discover the imports instead of naming today's three, so a new language's
grammar cannot be added without the spec learning about it."""
spec = (ROOT / "packaging" / "skit.spec").read_text(encoding="utf-8")
grammars = {
match.group(1)
for path in (ROOT / "src" / "skit" / "langs").rglob("*.py")
for match in re.finditer(
r"^[ \t]*import (tree_sitter_\w+)",
path.read_text(encoding="utf-8"),
re.MULTILINE,
)
}
assert grammars
missing = sorted(name for name in grammars if name not in spec)
assert not missing, f"packaging/skit.spec does not collect: {missing}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_packaging.py` around lines 64 - 82, Update the grammar import
regex in test_binary_spec_pins_every_tree_sitter_grammar to match tree_sitter_*
imports regardless of indentation, including those inside try/except blocks.
Preserve the existing recursive discovery and missing-grammar validation
behavior.

Comment thread tests/test_packaging.py
Comment on lines +85 to +95
def test_release_workflow_and_packaging_files_agree() -> None:
"""The release workflow builds the binary from files in this repo; a rename that
updates only one side would fail at tag time, when a release is already half-made."""
workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
for referenced in ("packaging/skit.spec", "packaging/smoke.py"):
assert referenced in workflow
assert (ROOT / referenced).is_file()
# The build installs the project plus this group; a rename would leave the release
# jobs installing nothing and failing on the first `pyinstaller` call.
assert "--group packaging" in workflow
assert "pyinstaller" in " ".join(PYPROJECT["dependency-groups"]["packaging"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the actual PyInstaller workflow contract.

Line 95 only checks whether the substring pyinstaller appears in the dependency-group strings, so pyinstaller-hooks-contrib alone would satisfy it. The test also never verifies that the workflow invokes PyInstaller. Assert an exact PyInstaller dependency entry and a workflow command such as uv run pyinstaller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_packaging.py` around lines 85 - 95, The test
test_release_workflow_and_packaging_files_agree must verify the actual
PyInstaller contract: assert that the packaging dependency group contains an
exact pyinstaller entry rather than relying on substring matching, and assert
that the release workflow invokes PyInstaller through a command such as “uv run
pyinstaller.”

…tation

The review's strongest catch: `preset save --set` stored a PARTIAL mapping,
so unnamed fields fell through to this machine's last-used values at run
time — history-dependent behavior in the lane sold as deterministic, and a
second "one rule for every preset writer" violation. --set now stores the
same full snapshot every other writer does: unnamed fields take the entry's
own declared defaults ("" when defaultless), pinned by a test that poisons
last-used first. Providing only secret values refuses up front instead of
minting a defaults-only preset the user never asked for.

Also landed:
- SKILL.md's doctor contract caught up with this branch's own change: the
  --json key list gains uv_required and the exit-code sentence no longer
  claims "uv availability only" (an agent probing `doctor; echo $?` on a
  uv-less non-Python library would have concluded uv exists).
- Direct Python calls to preset_save without the new kwarg hit typer's
  truthy OptionInfo default and crashed; normalized like run() does.
- preset list's empty-state hint now teaches the mint-without-running lane
  before the one that executes the entry.
- The skill recipe test is marker-driven: an HTML comment in SKILL.md
  declares which fences are machine-executed (the edit site now warns the
  editor), and the Runners add/replace/remove recipe joins the Presets one.
- The C3 skip notice gained its missing output assertion; the mutation
  witness docstring pointed at a function that no longer exists
  (_plan_stamp -> _cached_plan); dangling cli._uv_required references in
  benchmarks/tests corrected (one also misstated the empty-library case);
  dry-run docs mirror the code comment's precise carve-out; the retired
  "Script settings" name swept from the demo tape label and eleven
  contributor-facing comments.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant