feat: preflight - prerequisites checked and tool paths recorded before wikitool runs; launcher refuses without it (#151, POSIX half)
Files changed: - .claude/settings.json - .gitea/workflows/ci.yml - .gitea/workflows/nightly.yml - .gitea/workflows/release.yml - .gitea/workflows/tracker-live.yml - .github/hooks/wiki-trace.json - .gitignore - .vibe/hooks.toml - AGENTS.md - CHANGES.md - EVALS.md - INSTALL.md - README.md - VERSION - instructions/bootstrap.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/prerequisites.py - tools/chemenu/search/ripgrep.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_dist_upgrade.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/toolpaths.py - tools/preflight.sh - tools/prerequisites.txt - tools/run_wikitool.py - tools/trace-hook - tools/wikitool
This commit is contained in:
1 parent
5a731729f6
commit
e4b2b6d9b1
40 files changed
+1849
-125
No files matched your search
@@ -11,7 +11,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./tools/trace_ingest.py --source claude-code --event prompt.submitted 2>/dev/null || true",
|
||||
"command": "./tools/trace-hook --source claude-code --event prompt.submitted 2>/dev/null || true",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
+21
-8
@@ -90,21 +90,27 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Tool environment
|
||||
# The preflight, not a venv block of our own: it is the one way a
|
||||
# checkout gets set up (instructions/preflight.md), and tools/wikitool
|
||||
# refuses to start until it has passed. Run twice - the second run must
|
||||
# pass without changing anything, which is what an instance relies on
|
||||
# when it re-runs it after every update.
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet --upgrade pip
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
tools/preflight.sh
|
||||
cp .wikitool-tools.json /tmp/tools-first.json
|
||||
tools/preflight.sh > /tmp/preflight-second.txt
|
||||
cmp .wikitool-tools.json /tmp/tools-first.json
|
||||
# pytest-cov is CI-only: tools/requirements.txt describes what an
|
||||
# *instance* needs at runtime and ships with `dist export`, and an
|
||||
# instance does not measure this suite. Installed beside pytest for
|
||||
# the same reason pytest itself is.
|
||||
tools/.venv/bin/pip install --quiet pytest pytest-cov
|
||||
tools/.venv/bin/python -m pip install --quiet pytest pytest-cov
|
||||
# The MCP server's dependency is optional for an instance but not for
|
||||
# CI: its tests skip without it, and a skipped golden test is exactly
|
||||
# how the server's output and the CLI's would drift apart unnoticed.
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements-mcp.txt
|
||||
tools/.venv/bin/python -m pip install --quiet -r tools/requirements-mcp.txt
|
||||
|
||||
- name: Tests
|
||||
# Not run with WIKI_TRACE=0: two telemetry tests assert that a trace is
|
||||
@@ -137,7 +143,7 @@ jobs:
|
||||
# $GITHUB_ENV, which only reaches the steps after the one that wrote it.
|
||||
run: |
|
||||
set -eu
|
||||
tools/.venv/bin/pip install --quiet radicale
|
||||
tools/.venv/bin/python -m pip install --quiet radicale
|
||||
.gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
|
||||
|
||||
- name: Live tracker suite (CalDAV)
|
||||
@@ -258,8 +264,15 @@ jobs:
|
||||
for template in kb/*/COLLECTION.md.template types/*.template; do
|
||||
cp "$template" "${template%.template}"
|
||||
done
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
# A fresh instance refuses to run before its preflight (exit 42), so
|
||||
# this proves both halves: the refusal, then the setup that ends it.
|
||||
set +e
|
||||
tools/wikitool doctor > /tmp/before-preflight.txt 2>&1
|
||||
refused=$?
|
||||
set -e
|
||||
test "$refused" -eq 42
|
||||
grep -q 'tools/preflight.sh' /tmp/before-preflight.txt
|
||||
tools/preflight.sh
|
||||
tools/wikitool instructions sync
|
||||
tools/wikitool index rebuild
|
||||
tools/wikitool sources rebuild-index
|
||||
|
||||
@@ -79,9 +79,7 @@ jobs:
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git config --global user.name "Nightly"
|
||||
git config --global user.email "nightly@example.invalid"
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet --upgrade pip
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
tools/preflight.sh
|
||||
tools/wikitool instructions sync
|
||||
|
||||
- name: The instance is still correctly configured
|
||||
|
||||
@@ -54,9 +54,7 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet --upgrade pip
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
tools/preflight.sh
|
||||
|
||||
- name: Resolve the version and refuse to re-release it
|
||||
id: version
|
||||
|
||||
@@ -51,10 +51,8 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
python3 -m venv tools/.venv
|
||||
tools/.venv/bin/pip install --quiet --upgrade pip
|
||||
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||
tools/.venv/bin/pip install --quiet pytest radicale
|
||||
tools/preflight.sh
|
||||
tools/.venv/bin/python -m pip install --quiet pytest radicale
|
||||
|
||||
- name: Start Radicale
|
||||
run: .gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"sessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event session.start 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event session.start 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event session.start 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event session.start 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
"sessionEnd": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event session.end 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event session.end 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event session.end 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event session.end 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -22,8 +22,8 @@
|
||||
"userPromptSubmitted": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event prompt.submitted 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event prompt.submitted 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event prompt.submitted 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event prompt.submitted 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -31,8 +31,8 @@
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event tool.pre 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event tool.pre 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event tool.pre 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event tool.pre 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -40,8 +40,8 @@
|
||||
"postToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event tool.post 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event tool.post 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event tool.post 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event tool.post 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -49,8 +49,8 @@
|
||||
"postToolUseFailure": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event tool.error 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event tool.error 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event tool.error 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event tool.error 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -58,8 +58,8 @@
|
||||
"errorOccurred": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event session.error 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event session.error 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event session.error 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event session.error 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -67,8 +67,8 @@
|
||||
"subagentStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event subagent.start 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event subagent.start 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event subagent.start 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event subagent.start 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -76,8 +76,8 @@
|
||||
"subagentStop": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event subagent.stop 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event subagent.stop 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event subagent.stop 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event subagent.stop 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -85,8 +85,8 @@
|
||||
"preCompact": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event compaction 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event compaction 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event compaction 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event compaction 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
@@ -94,8 +94,8 @@
|
||||
"agentStop": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "./tools/trace_ingest.py --source copilot-cli --event turn.end 2>/dev/null || true",
|
||||
"powershell": "python tools/trace_ingest.py --source copilot-cli --event turn.end 2>$null; exit 0",
|
||||
"bash": "./tools/trace-hook --source copilot-cli --event turn.end 2>/dev/null || true",
|
||||
"powershell": ".\\tools\\.venv\\Scripts\\python.exe tools\\trace_ingest.py --source copilot-cli --event turn.end 2>$null; exit 0",
|
||||
"cwd": ".",
|
||||
"timeoutSec": 5
|
||||
}
|
||||
|
||||
@@ -141,6 +141,13 @@ npm-debug.log*
|
||||
# configured; `doctor` reports which.
|
||||
/.wikitool-tasks.json
|
||||
|
||||
# Tool paths recorded by the preflight (tools/preflight.sh / .ps1, see
|
||||
# instructions/preflight.md): the absolute paths of python, git, rg - and pwsh
|
||||
# on Windows - as this machine has them. Per-checkout for the plainest reason of
|
||||
# all: a path on one computer means nothing on another. Absent means the
|
||||
# preflight has not run, and `tools/wikitool` refuses to start (exit 42).
|
||||
/.wikitool-tools.json
|
||||
|
||||
# Live-suite tracker profiles (Gitea #156): one file per tracker of the user's own that the
|
||||
# live suite may be pointed at (`CHEMENU_LIVE_PROFILE=<name>`,
|
||||
# instructions/dev/tracker-testing.md). They carry the same credentials as the file above
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@
|
||||
name = "wiki-trace-pre-tool"
|
||||
type = "pre_tool"
|
||||
description = "Record an intended tool call. Observational only - never decides."
|
||||
command = "./tools/trace_ingest.py --source mistral-vibe --event tool.pre 2>/dev/null || true"
|
||||
command = "./tools/trace-hook --source mistral-vibe --event tool.pre 2>/dev/null || true"
|
||||
timeout = 5.0
|
||||
# `strict = false` is the default and is spelled out here because it is the
|
||||
# safety property that matters: under a non-strict hook, a crash or a timeout is
|
||||
@@ -26,7 +26,7 @@ strict = false
|
||||
name = "wiki-trace-post-tool"
|
||||
type = "post_tool"
|
||||
description = "Record the outcome of a tool call: status, output, duration."
|
||||
command = "./tools/trace_ingest.py --source mistral-vibe --event tool.post 2>/dev/null || true"
|
||||
command = "./tools/trace-hook --source mistral-vibe --event tool.post 2>/dev/null || true"
|
||||
timeout = 5.0
|
||||
strict = false
|
||||
|
||||
@@ -35,5 +35,5 @@ strict = false
|
||||
name = "wiki-trace-post-agent"
|
||||
type = "post_agent"
|
||||
description = "Record the end of an agent turn."
|
||||
command = "./tools/trace_ingest.py --source mistral-vibe --event turn.end 2>/dev/null || true"
|
||||
command = "./tools/trace-hook --source mistral-vibe --event turn.end 2>/dev/null || true"
|
||||
timeout = 5.0
|
||||
@@ -26,6 +26,10 @@ maintained permanently; anything mechanical is done by `tools/wikitool`, never b
|
||||
|
||||
## Bootstrap
|
||||
|
||||
**No `tools/wikitool` call works before the preflight has passed in this checkout** - it exits 42
|
||||
and names it: [instructions/preflight.md](instructions/preflight.md). On its own exit 42, show
|
||||
the output verbatim and wait; never install or work around what it reports.
|
||||
|
||||
`.agents/skills/` and `.claude/skills/` are generated and **not committed**. If they are
|
||||
missing or empty - a fresh clone - the harness offers no skills until they are published:
|
||||
|
||||
|
||||
+29
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 8.0.0-beta.13 - 2026-09-30 - raw/CONTRACT.md points at the path budget for a name accepted from incoming/
|
||||
## 8.0.0-beta.14 - 2026-09-30 - Preflight: prerequisites checked and tool paths recorded before wikitool runs (#151, POSIX half)
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
@@ -67,6 +67,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
|
||||
- publish without --no-push now exits 1 before committing when the remote is unreachable or not configured, where it used to commit locally and fail at the push - an offline session or a local-only instance must pass --no-push
|
||||
- new, rename, move and raw accept refuse a target whose path below the instance root is over 160 characters (UTF-16 code units); lint reports existing files over it as Long Paths (advisory) - rename each affected page with tools/wikitool rename, and shorten an incoming/ file name before raw accept
|
||||
- tools/wikitool now refuses to start (exit 42) until tools/preflight.sh has passed in the checkout - after updating, run tools/preflight.sh once: it checks Python, git and ripgrep, records their paths in .wikitool-tools.json and sets up tools/.venv
|
||||
|
||||
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
|
||||
|
||||
@@ -75,6 +76,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
|
||||
- dist upgrade --latest: one-command update from the release feed
|
||||
- Page titles must form valid, unique file names on Windows and macOS
|
||||
- Preflight: prerequisites checked and tool paths recorded before wikitool runs (#151, POSIX half)
|
||||
|
||||
**Medium impact**
|
||||
- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
|
||||
@@ -123,6 +125,32 @@ concern - readable here, never shipped as something to parse.
|
||||
- raw/CONTRACT.md points at the path budget for a name accepted from incoming/
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
### Preflight: prerequisites checked and tool paths recorded before wikitool runs (#151, POSIX half)
|
||||
|
||||
The Windows install that prompted this found Python missing, then `rg`, and the agent worked
|
||||
around each gap instead of stopping. `tools/preflight.sh` is now the one way a checkout gets
|
||||
set up. It checks the tools `tools/prerequisites.txt` lists (Python 3.11+, git, ripgrep, and
|
||||
PowerShell 7 on Windows) and records each one's absolute path in `.wikitool-tools.json`, which
|
||||
is gitignored and per checkout. It then creates `tools/.venv` from the recorded Python with
|
||||
`-m venv` and `-m pip`. Anything missing, too old or unusable ends in exit 42 with a numbered
|
||||
block for the user: what, why, the command that fixes it, what next. `--set <tool>=<path>`
|
||||
takes a path the user names. It is POSIX sh because it has to run before Python is known to
|
||||
exist, under dash, bash and Git Bash. On Windows it tries `python`, `py -3`, `python3` in that
|
||||
order, never runs a Microsoft Store alias, and records `sys.executable`. With long paths off it
|
||||
refuses an install folder over 95 characters, the other half of the 160-character path budget.
|
||||
|
||||
`tools/wikitool` is POSIX sh now as well. It stops with exit 42 until the preflight has written
|
||||
a complete file, reads both venv layouts, and starts Python through `tools/run_wikitool.py`
|
||||
instead of `PYTHONPATH`. Every `git` and `rg` the package starts goes through
|
||||
`chemenu.toolpaths`, so it comes from the recorded path rather than the session's `PATH`. A
|
||||
path that has gone is an `ERROR` line naming the preflight, not a traceback. `doctor` gains
|
||||
`tool-paths` and `install-dir`. The harness hooks start `trace_ingest.py` through
|
||||
`tools/trace-hook`, under the venv's Python, because the script's `python3` shebang is the
|
||||
Store alias in Git Bash. The CI workflows, `bootstrap.md`, `setup-instance.md` and
|
||||
`upgrade-instance.md` run the preflight instead of their own venv steps. The rules an agent
|
||||
follows around it are in `instructions/preflight.md`. The PowerShell half and the download
|
||||
mode for a first install follow in the same candidate.
|
||||
|
||||
### raw/CONTRACT.md points at the path budget for a name accepted from incoming/
|
||||
|
||||
The path budget's close-out review found the raw stage contract silent on it, although `raw
|
||||
|
||||
@@ -128,7 +128,8 @@ Verified against vendor documentation on 2026-08-23.
|
||||
|
||||
### Claude Code
|
||||
|
||||
`.claude/settings.json` wires `UserPromptSubmit` to `tools/trace_ingest.py`. That is what makes
|
||||
`.claude/settings.json` wires `UserPromptSubmit` to `tools/trace_ingest.py`, through
|
||||
`tools/trace-hook` like every hook here (see the load-bearing details under Copilot CLI). That is what makes
|
||||
`clearance-ended-the-turn` scorable here: without a `prompt.submitted` event there is no turn
|
||||
boundary to place an exit-42 call and its `--confirm` on either side of, and the rule reports
|
||||
"cannot say" instead of a verdict.
|
||||
@@ -157,8 +158,17 @@ own decision-document schema verified against a live CLI first (this repo has no
|
||||
unverified, per the same rule that governed the Vibe adapter: an adapter that cannot be verified
|
||||
is not written.
|
||||
|
||||
Two details in that file are load-bearing:
|
||||
Three details in that file are load-bearing, and the first holds for all three hook
|
||||
configurations:
|
||||
|
||||
- **The interpreter is the venv's, never the script's shebang.** Each `bash` command is
|
||||
`./tools/trace-hook ...`, which runs `trace_ingest.py` with `tools/.venv`'s Python in either
|
||||
venv layout; each `powershell` command names `tools\.venv\Scripts\python.exe` directly. A
|
||||
hook command is a fixed string and cannot read `.wikitool-tools.json`, but the venv is
|
||||
created from the recorded interpreter at a fixed place, so it stands in for it. The shebang
|
||||
(`python3`) was the Microsoft Store alias in Git Bash on Windows, which made every hook there
|
||||
a silent no-op. Before the preflight has created the venv, `trace-hook` records nothing and
|
||||
exits 0.
|
||||
- **Every command ends in `|| true`.** `preToolUse` hooks are *fail-closed*: a non-zero exit
|
||||
denies the tool call. Without the guard, a missing interpreter would turn the observer into
|
||||
a blocker that refuses every tool call in the session. (Timeouts are fail-open, so the
|
||||
|
||||
+20
-4
@@ -18,6 +18,12 @@ Terminal auf dieser Maschine ist.
|
||||
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) - wird von `search` und
|
||||
`sources coverage` gebraucht
|
||||
|
||||
Ob das alles da ist, prüft der Preflight (`tools/preflight.sh`), bevor irgendein
|
||||
`wikitool`-Befehl läuft - der erste Schritt jeder Einrichtung, siehe
|
||||
[instructions/preflight.md](instructions/preflight.md). Die maßgebliche Liste steht in
|
||||
`tools/prerequisites.txt`. Fehlt etwas, hält der Agent an und zeigt eine Anleitung mit dem
|
||||
Befehl, der es behebt; installieren muss man selbst, der Agent tut es nie.
|
||||
|
||||
## Weg A: Release herunterladen
|
||||
|
||||
Der kürzeste Weg zu einer eigenen Instanz - kein Checkout dieses Repos nötig. Jedes Release
|
||||
@@ -114,7 +120,7 @@ git clone https://gitea.nehmer.net/torben/chemenu.git
|
||||
cd chemenu
|
||||
```
|
||||
|
||||
Danach den Agenten `instructions/bootstrap.md` ausführen lassen (Werkzeugumgebung + Skills
|
||||
Danach den Agenten `instructions/bootstrap.md` ausführen lassen (Preflight + Skills
|
||||
publizieren). Git-Repo, Autor-Identität und Inhalt existieren hier bereits.
|
||||
|
||||
Ein Clone, der älter ist als die Personalization-Dateien, hat kein `USER.md`/`SOUL.md` -
|
||||
@@ -319,6 +325,15 @@ export WIKITOOL_UPDATE_TOKEN="<gitea-token>"
|
||||
tools/wikitool version check
|
||||
```
|
||||
|
||||
**Tool-Pfade - schreibt der Preflight, nicht der Mensch.** `.wikitool-tools.json` im Repo-Root
|
||||
hält die absoluten Pfade von Python, git und ripgrep, so wie der Preflight sie auf diesem Rechner
|
||||
gefunden hat; `wikitool` startet git und rg von dort statt über `PATH`. Pro Checkout und
|
||||
gitignored - ein Pfad auf einem Rechner sagt über den nächsten nichts. Fehlt die Datei oder ist
|
||||
sie unvollständig, startet `tools/wikitool` nicht (Exit 42) und nennt den Preflight. Liegt ein
|
||||
Tool woanders, als der Preflight sucht, nennt man den Pfad mit
|
||||
`tools/preflight.sh --set rg=<pfad>`; von Hand bearbeitet wird die Datei nicht. `doctor` meldet
|
||||
unter `tool-paths`, ob alle Pfade noch stimmen.
|
||||
|
||||
**Aufgaben-Tracker anbinden - optional.** Der Wochenrückblick (`tools/wikitool review`, Skill
|
||||
`gtd-weekly-review`) gleicht die Projektseiten unter `kb/gtd/` gegen einen Aufgaben-Tracker ab. Welcher
|
||||
das ist, steht in `.wikitool-tasks.json` im Repo-Root - der dritten Datei dieser Art neben
|
||||
@@ -446,9 +461,10 @@ tools/wikitool instructions verify
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`wikitool: venv not found`** - Schritt "Werkzeugumgebung anlegen" aus
|
||||
[instructions/bootstrap.md](instructions/bootstrap.md) bzw.
|
||||
[instructions/setup-instance.md](instructions/setup-instance.md) wurde noch nicht ausgeführt.
|
||||
- **`tools/wikitool` endet mit `STOP - this checkout is not set up yet` (Exit 42)** - der
|
||||
Preflight ist in diesem Checkout noch nicht durchgelaufen, oder seit dem letzten Update nicht
|
||||
mehr: `tools/preflight.sh` ausführen, siehe
|
||||
[instructions/preflight.md](instructions/preflight.md).
|
||||
- **Der Agent bietet keine Skills an (`wiki-ingest`, `wiki-query`, ...)** - `.agents/skills/`
|
||||
und `.claude/skills/` sind generiert und nicht committet. `tools/wikitool instructions sync`
|
||||
ausführen, dann die Agent-Session neu starten (Harnesses lesen Skills nur beim Start).
|
||||
|
||||
@@ -33,10 +33,13 @@ Two starting points, depending on what you're doing - full walkthrough in [INSTA
|
||||
committed**. Publish them once:
|
||||
|
||||
```bash
|
||||
cd tools && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && cd ..
|
||||
tools/preflight.sh # checks python/git/rg, records their paths, creates tools/.venv
|
||||
tools/wikitool instructions sync
|
||||
```
|
||||
|
||||
`tools/wikitool` refuses to start (exit 42) until the preflight has passed; if it stops
|
||||
instead, its output says what to install - `instructions/preflight.md`.
|
||||
|
||||
That copies each `instructions/<name>/SKILL.md` into `.agents/skills/` (GitHub Copilot, Codex
|
||||
CLI, Mistral Vibe) and `.claude/skills/` (Claude Code). Re-run it after changing a skill.
|
||||
Full procedure: `instructions/bootstrap.md`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: bootstrap
|
||||
description: Prepare a fresh clone for work - create the tools venv and publish the skills into the harness directories, which are generated and not committed.
|
||||
description: Prepare a fresh clone for work - run the preflight (tool paths and the tools venv) and publish the skills into the harness directories, which are generated and not committed.
|
||||
---
|
||||
|
||||
# Bootstrap a fresh clone
|
||||
@@ -19,15 +19,16 @@ they are published: the agent harness will not offer `wiki-ingest`, `wiki-query`
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Create the tool environment** (once per clone):
|
||||
1. **Run the preflight** (once per clone, and again after moving it) - see
|
||||
[preflight.md](preflight.md). It records the tool paths in `.wikitool-tools.json` and
|
||||
creates `tools/.venv`; until it exits 0, `tools/wikitool` refuses to start:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
cd ..
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
On exit 42, show its output to the user verbatim and wait.
|
||||
|
||||
2. **Publish the skills:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: preflight
|
||||
description: Run the preflight before any wikitool command in a new, cloned, moved or updated checkout - it checks Python, git and ripgrep, records their paths in .wikitool-tools.json and sets up tools/.venv; on exit 42 show its output verbatim and wait for the user, never install or work around anything yourself.
|
||||
---
|
||||
# Check the machine before anything else runs
|
||||
|
||||
`tools/wikitool` does not start in a checkout the preflight has not passed in. It stops with
|
||||
exit 42 and names this procedure instead - so there is no skipping it, only running it early
|
||||
or being sent back to it.
|
||||
|
||||
The preflight is a shell script, not a `wikitool` command, because it has to work before
|
||||
Python is known to exist. It does three things, all inside the install folder:
|
||||
|
||||
- checks the tools listed in `tools/prerequisites.txt` - Python 3.11 or newer, git, ripgrep
|
||||
(`rg`) - and, on Windows, that the install folder is short enough for Windows' path limit;
|
||||
- records the absolute path of each tool in `.wikitool-tools.json`, which `wikitool` then starts
|
||||
them from instead of trusting whatever `PATH` a session inherited;
|
||||
- creates `tools/.venv` from the recorded Python and installs `tools/requirements.txt` into it.
|
||||
|
||||
## When to run
|
||||
|
||||
- First step of every installation procedure: [setup-instance.md](setup-instance.md) and
|
||||
[bootstrap.md](bootstrap.md) both start here.
|
||||
- After every stack update ([upgrade-instance.md](upgrade-instance.md)) - a release can change
|
||||
what the machine needs, or the requirements the venv holds.
|
||||
- Whenever `tools/wikitool` exits 42 and names the preflight, and whenever `tools/wikitool doctor`
|
||||
reports `tool-paths` or `install-dir` as `FAIL`.
|
||||
|
||||
It is safe to run at any time: a second run on a ready checkout changes nothing and exits 0.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Run it** from the root of the checkout:
|
||||
|
||||
```bash
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
This covers Linux, macOS and Git Bash on Windows, which is where Claude Code runs its
|
||||
commands there.
|
||||
|
||||
2. **Read the exit code.**
|
||||
|
||||
| Exit | Meaning | What you do |
|
||||
|---|---|---|
|
||||
| 0 | Everything is in place | Continue with the procedure that sent you here |
|
||||
| 42 | The user has to act | Step 3 |
|
||||
| 1 | Called wrongly, or `tools/prerequisites.txt` is missing next to the script | Report the exact command and output to the user; do not retry blindly |
|
||||
|
||||
3. **On exit 42, show the output to the user exactly as it is, then stop and wait.** It is
|
||||
written for someone without an IT background: each numbered block says what is missing, why
|
||||
it matters, the command that fixes it, and what happens next. When the user's language is not
|
||||
the language of the output, add a translation below it - never instead of it, since the
|
||||
commands inside have to reach them unchanged.
|
||||
|
||||
While you wait, **install nothing, and work around nothing** - not with the user's consent
|
||||
either. No package manager call, no other Python, no WSL, no hand-written
|
||||
`.wikitool-tools.json`, no `wikitool` command "to see whether it works anyway". The command in
|
||||
the output is for the user to run; how their machine is administered is theirs to decide.
|
||||
|
||||
4. **When the user says it is done, run the preflight again.** Repeat steps 2-4 until it exits 0.
|
||||
|
||||
When the user tells you where a tool is installed instead, pass the path on:
|
||||
|
||||
```bash
|
||||
tools/preflight.sh --set rg=/opt/ripgrep/rg
|
||||
```
|
||||
|
||||
`--set <tool>=<path>` may be given several times. A path that does not work is refused with
|
||||
exit 42 and nothing is written; a working one is recorded and kept on later runs, even though
|
||||
the tool is still not on `PATH`.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **The output names a folder that is too long.** Only on Windows with long paths off: the
|
||||
install folder may be at most 95 characters, because every file of the wiki below it has to
|
||||
stay within 259. Moving the wiki to a shorter folder is the user's step; do not try to shorten
|
||||
paths inside the wiki instead.
|
||||
- **The venv or its libraries could not be installed.** The output carries the last lines of
|
||||
what Python or pip said. A network, proxy or security-product cause is for the user - or
|
||||
whoever administers their machine - to resolve; do not retry with other flags.
|
||||
- **`.wikitool-tools.json` looks wrong.** Never edit it. Run the preflight again, with `--set` for
|
||||
a path the user names; `doctor` reports whether the result holds.
|
||||
|
||||
## Scope
|
||||
|
||||
Not a wiki content procedure - it touches nothing under `kb/`, `raw/`, `work/` or `reports/`.
|
||||
It does not configure identity, remotes or the harness either; those are later steps of
|
||||
[setup-instance.md](setup-instance.md).
|
||||
@@ -193,15 +193,17 @@ and ready for its first ingest.
|
||||
`FAIL`, and so is one still carrying the sentinel - a renamed template is not a filled-in
|
||||
one.
|
||||
|
||||
7. **Create the tool environment** (details: [bootstrap.md](bootstrap.md)):
|
||||
7. **Run the preflight** ([preflight.md](preflight.md)). It checks Python, git and ripgrep,
|
||||
records their paths in `.wikitool-tools.json` and creates `tools/.venv` - no `tools/wikitool`
|
||||
call works before it has passed:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
cd ..
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
On exit 42, show its output to the user verbatim and wait; run it again once they have
|
||||
acted. Continue here only after it exits 0.
|
||||
|
||||
8. **Publish the skills:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -150,8 +150,19 @@ fresh clone ([bootstrap.md](bootstrap.md)), and not for the clone-with-upstream
|
||||
|
||||
It writes, and commits nothing.
|
||||
|
||||
8. **Republish the skills.** `tools/wikitool instructions sync` - the published skill directories
|
||||
are copies, so until this runs the harness is still offering the previous release's skills.
|
||||
8. **Run the preflight, then republish the skills.** The release may need other tools or
|
||||
other libraries than the one it replaced, and `tools/wikitool` refuses to start (exit 42)
|
||||
until the preflight has passed against the new `tools/` - an instance upgrading from a
|
||||
release without one has never run it at all. On its exit 42, show the output verbatim and
|
||||
wait ([preflight.md](preflight.md)):
|
||||
|
||||
```bash
|
||||
tools/preflight.sh
|
||||
tools/wikitool instructions sync
|
||||
```
|
||||
|
||||
`instructions sync` is needed because the published skill directories are copies: until it
|
||||
runs, the harness is still offering the previous release's skills.
|
||||
|
||||
9. **Verify the machinery, and fix what the release said would need fixing:**
|
||||
|
||||
|
||||
+8
-4
@@ -54,14 +54,15 @@ file end to end is for changing the CLI itself.
|
||||
|
||||
Full bootstrap for a fresh clone - including publishing the skills, which are not
|
||||
committed - is [`instructions/bootstrap.md`](../instructions/bootstrap.md). The
|
||||
environment alone:
|
||||
environment alone is the preflight ([`instructions/preflight.md`](../instructions/preflight.md)),
|
||||
from the repo root:
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
Until it has passed, every `tools/wikitool` call exits 42 and names it.
|
||||
|
||||
## Usage
|
||||
|
||||
Run from the repo root:
|
||||
@@ -3220,6 +3221,8 @@ Check that this instance is correctly configured.
|
||||
**NOTES**
|
||||
|
||||
- Checks dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, the kb/raw/reports/work/instructions structure, and generated files.
|
||||
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again.
|
||||
- Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 characters) - the limit the preflight enforces before it sets anything up.
|
||||
- Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`.
|
||||
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
|
||||
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
||||
@@ -3235,6 +3238,7 @@ Check that this instance is correctly configured.
|
||||
**SEE ALSO**
|
||||
|
||||
- `instructions/setup-instance.md` - the setup steps most findings point back to
|
||||
- `instructions/preflight.md` - what `tool-paths` and `install-dir` point back to
|
||||
- `INSTALL.md` § "Konfiguration" - the per-checkout configuration files
|
||||
- `EVALS.md` - telemetry state and caps
|
||||
- `instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`
|
||||
|
||||
+24
-5
@@ -18,26 +18,45 @@ file deliberately has none - and `docs verify` now enforces that.
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tools
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
tools/preflight.sh # from the repo root
|
||||
```
|
||||
|
||||
The preflight is the one way a checkout gets its environment: it checks the
|
||||
tools listed in `prerequisites.txt`, records their absolute paths in
|
||||
`../.wikitool-tools.json`, creates `.venv` and installs `requirements.txt` into
|
||||
it with `-m pip`. It is POSIX sh because it has to run before Python is known
|
||||
to exist; exit 42 means the user has to act, and its output says how. The
|
||||
procedure an agent follows around it is `instructions/preflight.md`.
|
||||
|
||||
`tools/wikitool` refuses to start (exit 42) until the preflight has written a
|
||||
*complete* `.wikitool-tools.json` and the venv exists. Past that, every `git`
|
||||
and `rg` the package starts goes through `chemenu/toolpaths.py`, which reads
|
||||
the recorded path - or, with no file at all (the test suite, a bare
|
||||
`python -m chemenu.cli`), falls back to the bare name. A file that is present
|
||||
but names a path that has gone raises `ToolPathError`, which the CLI turns
|
||||
into an `ERROR` line pointing at the preflight rather than a traceback.
|
||||
|
||||
`jsonschema` and `PyYAML` are hard dependencies, not optional extras: schema
|
||||
validation is the tool's whole safety net, so `cli.py` fails loudly with the
|
||||
install command rather than degrading silently.
|
||||
fix rather than degrading silently.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tools/
|
||||
wikitool entry point
|
||||
wikitool entry point (POSIX sh): stops with exit 42 until the preflight has passed
|
||||
run_wikitool.py what the launcher runs with the venv's Python - puts chemenu on sys.path without PYTHONPATH
|
||||
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh)
|
||||
prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor`
|
||||
trace-hook what the harness hooks call: trace_ingest.py under the venv's Python
|
||||
chemenu/
|
||||
cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract
|
||||
cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from
|
||||
config.py repo layout: root resolution and every path under it
|
||||
api.py the in-process entry point - point Chemenu at a corpus and read it
|
||||
errors.py ChemenuError / ValidationError / BackendError
|
||||
toolpaths.py where git and rg are started from: .wikitool-tools.json, bare name only without the file
|
||||
prerequisites.py prerequisites.txt read from Python, plus the platform and long-path questions `doctor` asks
|
||||
corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty
|
||||
kb_scan.py page iteration/loading over kb/
|
||||
blocks.py generated regions in a page body, found by marker rather than by heading
|
||||
|
||||
+12
-2
@@ -21,6 +21,7 @@ import typer.core as _typer_core
|
||||
_typer_core.HAS_RICH = False
|
||||
|
||||
from chemenu import cli_contract # noqa: E402 - after the HAS_RICH patch, which must land first
|
||||
from chemenu import toolpaths # noqa: E402
|
||||
|
||||
try:
|
||||
from chemenu.commands import (
|
||||
@@ -59,8 +60,9 @@ except ModuleNotFoundError as exc:
|
||||
# degradation or a raw traceback.
|
||||
sys.stderr.write(
|
||||
f"wikitool: missing required dependency '{exc.name}'.\n"
|
||||
"This is not optional - schema validation depends on it. Run:\n"
|
||||
" cd tools && .venv/bin/pip install -r requirements.txt\n"
|
||||
"This is not optional - schema validation depends on it. Run the preflight,\n"
|
||||
"which (re)installs tools/.venv from tools/requirements.txt:\n"
|
||||
" tools/preflight.sh\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -326,6 +328,14 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
||||
code = exc.code
|
||||
exit_code = code if isinstance(code, int) else (0 if code is None else 1)
|
||||
raise
|
||||
except toolpaths.ToolPathError as exc:
|
||||
# Raised from wherever git or rg is about to start, often deep inside a
|
||||
# helper that treats a missing tool as "no answer". It is neither a
|
||||
# crash nor a validation error to retry: the fix is the preflight, so
|
||||
# it gets the ERROR line and exit 1 rather than a traceback.
|
||||
exit_code = 1
|
||||
print(f"ERROR {exc}", file=sys.stdout)
|
||||
raise SystemExit(1) from None
|
||||
except BaseException:
|
||||
exit_code = 1
|
||||
raise
|
||||
|
||||
@@ -54,6 +54,7 @@ from chemenu import (
|
||||
kb_state,
|
||||
ownership,
|
||||
toc,
|
||||
toolpaths,
|
||||
version as version_mod,
|
||||
)
|
||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||
@@ -953,7 +954,7 @@ def _git_working_tree_status() -> Optional[str]:
|
||||
repository at all - which is a valid, if unprotected, state for a tarball
|
||||
instance, not a reason to refuse."""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config.ROOT), "status", "--porcelain"],
|
||||
[toolpaths.git(), "-C", str(config.ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -1515,6 +1516,7 @@ def run_upgrade(
|
||||
summary += (
|
||||
" Nothing was committed and nothing is verified yet."
|
||||
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||
" and resumes at `wikitool instructions sync`."
|
||||
" and resumes with the preflight (`tools/preflight.sh`), which `tools/wikitool` now"
|
||||
" refuses to run without."
|
||||
)
|
||||
success(summary)
|
||||
@@ -68,7 +68,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, toolpaths, version as version_mod
|
||||
from chemenu.commands import dist_cmd
|
||||
from chemenu.commands._util import fail, rel_path, success
|
||||
|
||||
@@ -842,7 +842,7 @@ def _git(args: list[str], stdin: Optional[str] = None) -> Optional[subprocess.Co
|
||||
are unknowable rather than wrong."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, input=stdin
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, input=stdin
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,7 @@ remote yet, or no `WIKITOOL_SESSION_ID` set, is a valid state, not a fault.
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -20,7 +21,7 @@ from typing import Optional
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from chemenu import cli_contract, config, conventions, kb_collections, version as version_mod
|
||||
from chemenu import cli_contract, config, conventions, kb_collections, prerequisites, toolpaths, version as version_mod
|
||||
from chemenu.commands import git_publish, instructions_cmd
|
||||
from chemenu.commands._util import rel_path
|
||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||
@@ -40,33 +41,100 @@ class Check:
|
||||
def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, timeout=5
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
except (OSError, subprocess.SubprocessError, toolpaths.ToolPathError):
|
||||
# A broken tool-paths file is reported once, by `check_tool_paths` -
|
||||
# not as a crash from every check that happens to need git.
|
||||
return None
|
||||
|
||||
|
||||
PREFLIGHT_FIX = "Run tools/preflight.sh"
|
||||
|
||||
|
||||
def check_python() -> Check:
|
||||
version = sys.version_info
|
||||
if version < (3, 11):
|
||||
return Check(
|
||||
"python", "FAIL", f"Python {version.major}.{version.minor} found, need >= 3.11",
|
||||
"Install Python 3.11+ and recreate tools/.venv",
|
||||
f"Install Python 3.11+, then: {PREFLIGHT_FIX}",
|
||||
)
|
||||
return Check("python", "OK", f"Python {version.major}.{version.minor}.{version.micro}")
|
||||
|
||||
|
||||
def check_ripgrep() -> Check:
|
||||
if shutil.which("rg"):
|
||||
return Check("ripgrep", "OK", "rg found on PATH")
|
||||
try:
|
||||
rg = toolpaths.rg()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("ripgrep", "FAIL", str(exc), PREFLIGHT_FIX)
|
||||
if shutil.which(rg):
|
||||
where = "on PATH" if rg == "rg" else f"at {rg}"
|
||||
return Check("ripgrep", "OK", f"rg found {where}")
|
||||
return Check(
|
||||
"ripgrep", "FAIL", "rg not found on PATH - `search` and `sources coverage` need it",
|
||||
"Install ripgrep (e.g. `apt install ripgrep` / `brew install ripgrep`)",
|
||||
"ripgrep", "FAIL", "rg not found - `search` and `sources coverage` need it",
|
||||
f"Install ripgrep (e.g. `apt install ripgrep` / `brew install ripgrep`), then: {PREFLIGHT_FIX}",
|
||||
)
|
||||
|
||||
|
||||
def check_tool_paths() -> Check:
|
||||
"""`.wikitool-tools.json`: written by a preflight that finished, and every
|
||||
path in it still there. The launcher refuses to start without a complete
|
||||
file, so a FAIL here is mostly a path that went away since - an uninstalled
|
||||
or moved tool."""
|
||||
name = toolpaths.FILE_NAME
|
||||
try:
|
||||
data = toolpaths.load()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("tool-paths", "FAIL", str(exc), PREFLIGHT_FIX)
|
||||
if data is None:
|
||||
return Check(
|
||||
"tool-paths", "FAIL", f"{name} is missing - the preflight has not run in this checkout",
|
||||
PREFLIGHT_FIX,
|
||||
)
|
||||
if data.get("complete") is not True:
|
||||
return Check(
|
||||
"tool-paths", "FAIL", f"{name} is incomplete - the last preflight run stopped before the end",
|
||||
PREFLIGHT_FIX,
|
||||
)
|
||||
recorded = data["tools"]
|
||||
needed = [tool.name for tool in prerequisites.load_manifest().tools_for(prerequisites.platform())]
|
||||
unrecorded = [tool for tool in needed if not recorded.get(tool)]
|
||||
gone = [f"{tool} ({path})" for tool, path in sorted(recorded.items())
|
||||
if not (isinstance(path, str) and os.path.isfile(path))]
|
||||
problems = []
|
||||
if unrecorded:
|
||||
problems.append("not recorded: " + ", ".join(unrecorded))
|
||||
if gone:
|
||||
problems.append("no longer there: " + ", ".join(gone))
|
||||
if problems:
|
||||
return Check("tool-paths", "FAIL", f"{name}: " + "; ".join(problems), PREFLIGHT_FIX)
|
||||
return Check("tool-paths", "OK", f"{name}: " + ", ".join(sorted(recorded)) + " recorded and present")
|
||||
|
||||
|
||||
def check_install_dir() -> Check:
|
||||
"""The install folder against Windows' MAX_PATH, the same limit the
|
||||
preflight enforces before it unpacks anything."""
|
||||
problem = prerequisites.install_dir_problem()
|
||||
if problem is None:
|
||||
if prerequisites.platform() != "windows":
|
||||
detail = "no folder length limit on this platform"
|
||||
elif prerequisites.long_paths_enabled():
|
||||
detail = "Windows long paths are on - no folder length limit"
|
||||
else:
|
||||
detail = "fits Windows' path limit"
|
||||
return Check("install-dir", "OK", detail)
|
||||
return Check(
|
||||
"install-dir", "FAIL", problem[:1].upper() + problem[1:],
|
||||
"Move the wiki to a shorter folder (for example C:\\Chemenu) and run the preflight there, "
|
||||
"or have someone with administrator rights turn on long paths in Windows",
|
||||
)
|
||||
|
||||
|
||||
def check_author() -> Check:
|
||||
author = config.default_author()
|
||||
try:
|
||||
author = config.default_author()
|
||||
except toolpaths.ToolPathError as exc:
|
||||
return Check("author", "FAIL", f"`git config user.name` cannot be asked: {exc}", PREFLIGHT_FIX)
|
||||
if author is None:
|
||||
return Check(
|
||||
"author", "FAIL", "Neither $WIKI_AUTHOR nor `git config user.name` resolves",
|
||||
@@ -313,10 +381,8 @@ def check_publish_remotes() -> Check:
|
||||
f"Gate armed: {len(urls)} allowed push target(s) in "
|
||||
f"{config.PUBLISH_REMOTES_FILENAME}",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["git", "remote"], cwd=config.ROOT, capture_output=True, text=True
|
||||
)
|
||||
remotes = [r for r in result.stdout.split() if r]
|
||||
result = _git(["remote"])
|
||||
remotes = [r for r in result.stdout.split() if r] if result is not None else []
|
||||
if len(remotes) > 1:
|
||||
return Check(
|
||||
"publish-remotes", "WARN",
|
||||
@@ -600,7 +666,9 @@ def check_kb_version() -> Check:
|
||||
def run_doctor() -> list[Check]:
|
||||
checks: list[Check] = [
|
||||
check_python(),
|
||||
check_tool_paths(),
|
||||
check_ripgrep(),
|
||||
check_install_dir(),
|
||||
check_author(),
|
||||
check_stack_version(),
|
||||
check_kb_version(),
|
||||
@@ -635,6 +703,13 @@ def run_doctor() -> list[Check]:
|
||||
"Checks dependencies (Python, ripgrep), author resolution, stack version, git "
|
||||
"identity/branch/remote, published skills, the kb/raw/reports/work/instructions "
|
||||
"structure, and generated files.",
|
||||
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
|
||||
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
|
||||
"every recorded path still there - a `FAIL` otherwise, fixed by running "
|
||||
"`tools/preflight.sh` again.",
|
||||
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
|
||||
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
|
||||
"characters) - the limit the preflight enforces before it sets anything up.",
|
||||
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
|
||||
"the template's sentinel is a `FAIL`.",
|
||||
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
|
||||
@@ -674,6 +749,7 @@ def run_doctor() -> list[Check]:
|
||||
),
|
||||
see_also=(
|
||||
"`instructions/setup-instance.md` - the setup steps most findings point back to",
|
||||
"`instructions/preflight.md` - what `tool-paths` and `install-dir` point back to",
|
||||
"`INSTALL.md` § \"Konfiguration\" - the per-checkout configuration files",
|
||||
"`EVALS.md` - telemetry state and caps",
|
||||
"`instructions/session-setup.md` - setting `WIKITOOL_SESSION_ID`",
|
||||
@@ -684,7 +760,7 @@ def doctor_command(
|
||||
):
|
||||
"""Check that this instance is correctly configured.
|
||||
\f
|
||||
Dependencies, author, git identity/remote, published skills, structure,
|
||||
Dependencies, recorded tool paths, install folder length, author, git identity/remote, published skills, structure,
|
||||
personalization, KB conventions, generated files, session scoping,
|
||||
telemetry state, whether the MCP `submit` tool is armed, and which
|
||||
task-tracker provider (if any) is configured for the GTD review.
|
||||
|
||||
@@ -60,7 +60,7 @@ from typing import NamedTuple, Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import cli_contract, config
|
||||
from chemenu import cli_contract, config, toolpaths
|
||||
from chemenu.commands._util import fail, needs_clearance, success
|
||||
from chemenu.telemetry import emit
|
||||
|
||||
@@ -81,6 +81,9 @@ GATE_EXEMPT_PREFIXES = ("work/",)
|
||||
|
||||
|
||||
def _run(args: list[str]) -> subprocess.CompletedProcess:
|
||||
"""Run a `git ...` argument list, starting git from its recorded path."""
|
||||
if args and args[0] == "git":
|
||||
args = [toolpaths.git(), *args[1:]]
|
||||
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
||||
|
||||
|
||||
@@ -266,7 +269,7 @@ def collect_changes(paths: list[str]) -> list[FileChange]:
|
||||
|
||||
def run(args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=config.ROOT, capture_output=True, text=True, env=env,
|
||||
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail(f"git {args[0]} failed:\n{result.stderr}")
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, version as version_mod
|
||||
from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, toolpaths, version as version_mod
|
||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||
from chemenu.frontmatter_io import read_page
|
||||
from chemenu.page import Page
|
||||
@@ -500,7 +500,7 @@ def baseline_command(
|
||||
|
||||
def _git_show(rev: str, relative: str) -> Optional[str]:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{rev}:{relative}"],
|
||||
[toolpaths.git(), "show", f"{rev}:{relative}"],
|
||||
cwd=config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -510,7 +510,7 @@ def _git_show(rev: str, relative: str) -> Optional[str]:
|
||||
|
||||
def _paths_at(rev: str) -> Optional[list[str]]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
|
||||
[toolpaths.git(), "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
|
||||
cwd=config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -299,9 +299,11 @@ def default_author() -> str | None:
|
||||
override = os.environ.get("WIKI_AUTHOR", "").strip()
|
||||
if override:
|
||||
return override
|
||||
from chemenu import toolpaths # local: toolpaths imports this module
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "config", "user.name"],
|
||||
[toolpaths.git(), "config", "user.name"],
|
||||
cwd=_root(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -28,7 +28,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, toolpaths
|
||||
from chemenu.page import Page
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def is_dirty(root: Optional[Path] = None, path: Optional[Path] = None) -> bool:
|
||||
def _git(args: list[str], root: Optional[Path] = None):
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
[toolpaths.git(), *args],
|
||||
cwd=root or config.ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""`tools/prerequisites.txt` read from Python, for `wikitool doctor`.
|
||||
|
||||
The file is the one list of what the machine needs; `tools/preflight.sh` and
|
||||
`tools/preflight.ps1` read it too, which is why it is a `|`-separated line format
|
||||
rather than anything a shell would need a parser for. This module answers the
|
||||
same questions the preflight asks, so `doctor` can report afterwards what the
|
||||
preflight enforced up front - a tool whose recorded path has gone, a checkout
|
||||
moved into a folder too long for Windows.
|
||||
|
||||
`CHEMENU_PREFLIGHT_PLATFORM` and `CHEMENU_PREFLIGHT_LONGPATHS` stand in for the
|
||||
real platform and registry, exactly as they do for the preflight scripts; the
|
||||
test suite is their only user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, titles
|
||||
|
||||
ENV_PLATFORM = "CHEMENU_PREFLIGHT_PLATFORM"
|
||||
ENV_LONGPATHS = "CHEMENU_PREFLIGHT_LONGPATHS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tool:
|
||||
name: str
|
||||
minimum: Optional[str]
|
||||
platforms: str
|
||||
label: str
|
||||
why: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
limits: dict[str, int]
|
||||
tools: tuple[Tool, ...]
|
||||
|
||||
def tools_for(self, platform: str) -> tuple[Tool, ...]:
|
||||
return tuple(t for t in self.tools if t.platforms in ("all", platform))
|
||||
|
||||
|
||||
def manifest_path() -> Path:
|
||||
return config._PACKAGE_ROOT / "tools" / "prerequisites.txt"
|
||||
|
||||
|
||||
def load_manifest(path: Optional[Path] = None) -> Manifest:
|
||||
path = manifest_path() if path is None else path
|
||||
limits: dict[str, int] = {}
|
||||
tools: list[Tool] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
fields = line.split("|")
|
||||
if fields[0] == "limit":
|
||||
limits[fields[1]] = int(fields[2])
|
||||
elif fields[0] == "tool":
|
||||
minimum = None if fields[2] == "-" else fields[2]
|
||||
tools.append(Tool(fields[1], minimum, fields[3], fields[4], fields[5]))
|
||||
return Manifest(limits, tuple(tools))
|
||||
|
||||
|
||||
def platform() -> str:
|
||||
"""windows, macos or linux - the same three names the preflight uses."""
|
||||
forced = os.environ.get(ENV_PLATFORM, "").strip()
|
||||
if forced:
|
||||
return forced
|
||||
if sys.platform == "win32":
|
||||
return "windows"
|
||||
if sys.platform == "darwin":
|
||||
return "macos"
|
||||
return "linux"
|
||||
|
||||
|
||||
def long_paths_enabled() -> bool:
|
||||
"""Whether Windows lifts MAX_PATH on this machine. An unreadable key counts
|
||||
as off, as it does in the preflight: the limit then protects a machine that
|
||||
did not need it, rather than the other way round."""
|
||||
forced = os.environ.get(ENV_LONGPATHS, "").strip()
|
||||
if forced:
|
||||
return forced == "1"
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try: # pragma: no cover - Windows only
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\FileSystem"
|
||||
) as key:
|
||||
value, _ = winreg.QueryValueEx(key, "LongPathsEnabled")
|
||||
return value == 1
|
||||
except OSError: # pragma: no cover - Windows only
|
||||
return False
|
||||
|
||||
|
||||
def install_dir() -> str:
|
||||
"""The folder that holds `tools/`, as the operating system spells it."""
|
||||
return str(config._PACKAGE_ROOT)
|
||||
|
||||
|
||||
def install_dir_problem(folder: Optional[str] = None) -> Optional[str]:
|
||||
"""Why this install folder is too long for Windows with long paths off, or
|
||||
None when it fits (or the question does not arise on this platform)."""
|
||||
if platform() != "windows" or long_paths_enabled():
|
||||
return None
|
||||
folder = install_dir() if folder is None else folder
|
||||
limit = load_manifest().limits["install_dir_max"]
|
||||
length = titles.path_length(folder)
|
||||
if length <= limit:
|
||||
return None
|
||||
return (
|
||||
f"the install folder is {length} characters long and Windows allows at most "
|
||||
f"{limit} here (long paths are off): {folder}"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, toolpaths
|
||||
from chemenu.errors import BackendError
|
||||
from chemenu.page import Page
|
||||
from chemenu.search.base import page_key
|
||||
@@ -65,7 +65,7 @@ class RipgrepFailed(BackendError):
|
||||
def build_argv(query: SearchQuery, root: Path) -> list[str]:
|
||||
"""The exact command line. Split out so a test can assert the safety
|
||||
properties above without running anything."""
|
||||
argv = ["rg", "--json", "--smart-case", "--glob", "*.md"]
|
||||
argv = [toolpaths.rg(), "--json", "--smart-case", "--glob", "*.md"]
|
||||
if not query.regex:
|
||||
argv.append("--fixed-strings")
|
||||
# `--` terminates option parsing: a query starting with `-` is a search
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, toolpaths
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.session import HARNESS_ENV_VARS
|
||||
from chemenu.telemetry import policy as telemetry_policy
|
||||
@@ -33,6 +33,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_UPDATE_TOKEN",
|
||||
"CHEMENU_ROOT",
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
@@ -156,6 +158,12 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
for name in (*_WIKITOOL_ENV, *_GIT_ENV):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
# `.wikitool-tools.json` beside this checkout records where the developer's
|
||||
# git and rg live. A test must not depend on whether the preflight has run
|
||||
# here, so every test sees no file - the bare-name fallback - unless it
|
||||
# writes one of its own and points `toolpaths.tools_file` at it.
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: home / toolpaths.FILE_NAME)
|
||||
|
||||
# The same hole as the environment above, one layer in: `config` resolves
|
||||
# its paths on access, and `monkeypatch.setattr(config, "KB_DIR", ...)`
|
||||
# undoes itself by writing the *resolved* old path back as a real
|
||||
|
||||
@@ -345,7 +345,9 @@ def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, ca
|
||||
|
||||
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
||||
assert "instructions/upgrade-instance.md" in out
|
||||
assert "instructions sync" in out
|
||||
# The first thing the new launcher needs (Gitea #151): it refuses to start
|
||||
# until the preflight has passed against the swapped-in tools/.
|
||||
assert "tools/preflight.sh" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||
|
||||
@@ -4,12 +4,14 @@ is missing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config, conventions, prerequisites, toolpaths
|
||||
from chemenu.commands import doctor, instructions_cmd
|
||||
|
||||
|
||||
@@ -17,6 +19,18 @@ def _git(root: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _write_tools_file(path: Path, tools: dict, complete: bool = True) -> Path:
|
||||
path.write_text(
|
||||
json.dumps({"schema": 1, "complete": complete, "tools": tools}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _real_tools() -> dict:
|
||||
return {"python": sys.executable, "git": shutil.which("git"), "rg": shutil.which("rg")}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal, fully-configured wiki instance: a git repo with identity,
|
||||
@@ -72,6 +86,11 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
_git(root, "config", "user.name", "Fixture Author")
|
||||
_git(root, "config", "user.email", "fixture@example.com")
|
||||
|
||||
# What the preflight would have written: every tool this platform needs,
|
||||
# at the path it really has on this machine.
|
||||
tools_file = _write_tools_file(root / toolpaths.FILE_NAME, _real_tools())
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools_file)
|
||||
|
||||
instructions_cmd.sync(force=False)
|
||||
return root
|
||||
|
||||
@@ -558,3 +577,66 @@ def test_tasks_provider_names_an_active_override(instance, monkeypatch):
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tasks-provider") == "OK"
|
||||
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
|
||||
|
||||
|
||||
# --- tool paths and the install folder (Gitea #151) --------------------------
|
||||
|
||||
|
||||
def test_tool_paths_missing_file_is_a_fail_naming_the_preflight(instance):
|
||||
(instance / toolpaths.FILE_NAME).unlink()
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
fix = next(c.fix for c in checks if c.name == "tool-paths")
|
||||
assert "tools/preflight.sh" in fix
|
||||
|
||||
|
||||
def test_tool_paths_incomplete_run_is_a_fail(instance):
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, _real_tools(), complete=False)
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_vanished_path_is_a_fail(instance):
|
||||
tools = {**_real_tools(), "rg": str(instance / "gone" / "rg")}
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "no longer there: rg" in _detail(checks, "tool-paths")
|
||||
# The same broken path makes the ripgrep check fail instead of finding rg on PATH.
|
||||
assert _status(checks, "ripgrep") == "FAIL"
|
||||
|
||||
|
||||
def test_tool_paths_unrecorded_tool_is_a_fail(instance):
|
||||
tools = _real_tools()
|
||||
del tools["git"]
|
||||
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "tool-paths") == "FAIL"
|
||||
assert "not recorded: git" in _detail(checks, "tool-paths")
|
||||
|
||||
|
||||
def test_tool_paths_complete_file_is_ok(instance):
|
||||
assert _status(doctor.run_doctor(), "tool-paths") == "OK"
|
||||
|
||||
|
||||
def _folder_of(length: int) -> str:
|
||||
return "C:\\" + "x" * (length - 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", "OK"),
|
||||
(96, "0", "FAIL"),
|
||||
(96, "1", "OK"),
|
||||
])
|
||||
def test_install_dir_limit_at_the_boundary(instance, monkeypatch, length, longpaths, expected):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
monkeypatch.setenv(prerequisites.ENV_LONGPATHS, longpaths)
|
||||
folder = _folder_of(length)
|
||||
assert len(folder) == length
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: folder)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == expected
|
||||
|
||||
|
||||
def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == "OK"
|
||||
@@ -0,0 +1,529 @@
|
||||
"""tools/preflight.sh, the launcher, `tools/trace-hook` and `toolpaths` (Gitea #151).
|
||||
|
||||
The preflight runs before Python is known to exist, so it is tested as what it
|
||||
is: a POSIX shell script, run under every shell this machine has of dash and
|
||||
bash, against a `PATH` built for the test. That `PATH` holds two directories -
|
||||
the handful of utilities the script uses, symlinked one by one, and stubs for
|
||||
the tools under test - so whether `rg` or `python3` is "installed" is decided
|
||||
here and nowhere else. The stub Python fakes `-m venv` and `-m pip` as well, so
|
||||
no test creates a real venv or touches the network; the CI workflows run the
|
||||
preflight for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, prerequisites, titles, toolpaths
|
||||
|
||||
TOOLS = config._PACKAGE_ROOT / "tools"
|
||||
|
||||
# What preflight.sh, the launcher and trace-hook call besides shell built-ins
|
||||
# and the tools under test. `iconv` is optional in the script itself.
|
||||
UTILITIES = ("dirname", "uname", "sed", "tr", "wc", "iconv", "tail", "cat", "mv", "head",
|
||||
"cut", "grep", "mkdir", "cp", "rm")
|
||||
|
||||
|
||||
def _shells() -> list[str]:
|
||||
found = {}
|
||||
for name in ("dash", "bash", "sh"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
found.setdefault(os.path.realpath(path), path)
|
||||
return sorted(found.values())
|
||||
|
||||
|
||||
SHELLS = _shells()
|
||||
|
||||
|
||||
def _executable(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
FAKE_PYTHON = """#!/bin/sh
|
||||
# A Python that answers the preflight's probe as itself, and fakes venv and pip.
|
||||
case "$1" in
|
||||
-c)
|
||||
case "$2" in
|
||||
*hashlib*) exec "{real}" "$@" ;;
|
||||
esac
|
||||
printf '%s\\n%s\\n' "${{FAKE_PY_VERSION:-3.13}}" "$0"
|
||||
exit 0 ;;
|
||||
-m)
|
||||
case "$2" in
|
||||
venv)
|
||||
shift 2
|
||||
[ "$1" = --clear ] && shift
|
||||
if [ -n "${{FAKE_VENV_FAIL:-}}" ]; then
|
||||
echo "Error: ensurepip is not available" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$1/bin"
|
||||
cp "$0" "$1/bin/python"
|
||||
exit 0 ;;
|
||||
pip)
|
||||
shift 2
|
||||
[ "$1" = --version ] && exit 0
|
||||
echo "$*" >> "$PIP_LOG"
|
||||
if [ -n "${{FAKE_PIP_FAIL:-}}" ]; then
|
||||
echo "ERROR: Could not find a version that satisfies the requirement (network unreachable)" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0 ;;
|
||||
esac ;;
|
||||
esac
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
class Machine:
|
||||
"""A stack tree plus a PATH of stubs, for one preflight run or several."""
|
||||
|
||||
def __init__(self, base: Path, root_name: str = "instance"):
|
||||
self.base = base
|
||||
self.root = base / root_name
|
||||
self.tools = self.root / "tools"
|
||||
self.tools.mkdir(parents=True)
|
||||
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
self.sysbin.mkdir(exist_ok=True)
|
||||
for name in UTILITIES:
|
||||
found = shutil.which(name)
|
||||
if found and not (self.sysbin / name).exists():
|
||||
(self.sysbin / name).symlink_to(found)
|
||||
self.stubs = base / "stubs"
|
||||
self.pip_log = base / "pip.log"
|
||||
self.extra_env: dict[str, str] = {}
|
||||
self.path_dirs: list[Path] = [self.stubs, self.sysbin]
|
||||
|
||||
def stub(self, name: str, text: str, where: Path | None = None) -> Path:
|
||||
return _executable((where or self.stubs) / name, text)
|
||||
|
||||
def python(self, name: str = "python3", where: Path | None = None) -> Path:
|
||||
return self.stub(name, FAKE_PYTHON.format(real=sys.executable), where)
|
||||
|
||||
def standard(self, rg: bool = True, pwsh: bool = False) -> "Machine":
|
||||
self.python()
|
||||
self.stub("git", "#!/bin/sh\necho 'git version 2.47.1'\n")
|
||||
if rg:
|
||||
self.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1 (rev 4649aa9700)'\n")
|
||||
if pwsh:
|
||||
self.stub("pwsh", "#!/bin/sh\necho 'PowerShell 7.6.6'\n")
|
||||
return self
|
||||
|
||||
def run(self, *args: str, shell: str) -> subprocess.CompletedProcess:
|
||||
env = {
|
||||
"PATH": os.pathsep.join(str(d) for d in self.path_dirs),
|
||||
"HOME": str(self.base),
|
||||
"PIP_LOG": str(self.pip_log),
|
||||
**self.extra_env,
|
||||
}
|
||||
return subprocess.run(
|
||||
[shell, str(self.tools / "preflight.sh"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=60,
|
||||
)
|
||||
|
||||
@property
|
||||
def tools_file(self) -> Path:
|
||||
return self.root / toolpaths.FILE_NAME
|
||||
|
||||
def recorded(self) -> dict:
|
||||
return json.loads(self.tools_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def assert_guidance(output: str, *fragments: str) -> None:
|
||||
"""Every exit-42 stop carries the block: what, why, the fixing command, what next."""
|
||||
assert "STOP" in output
|
||||
for marker in ("Why:", "Fix:", "Next:"):
|
||||
assert marker in output, f"no {marker!r} in:\n{output}"
|
||||
for fragment in fragments:
|
||||
assert fragment in output, f"no {fragment!r} in:\n{output}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path: Path) -> Machine:
|
||||
return Machine(tmp_path.resolve())
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
|
||||
|
||||
|
||||
# --- the happy path ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = machine.recorded()
|
||||
assert data["schema"] == 1 and data["complete"] is True
|
||||
assert set(data["tools"]) == {"python", "git", "rg"}
|
||||
for path in data["tools"].values():
|
||||
assert os.path.isabs(path) and os.path.isfile(path)
|
||||
assert data["tools"]["rg"] == str(machine.stubs / "rg")
|
||||
assert (machine.tools / ".venv" / "bin" / "python").is_file()
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
assert "Preflight passed" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_second_run_changes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
again = machine.run(shell=shell)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
|
||||
|
||||
# --- stop cases -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_missing_rg_stops_with_42_and_a_missing_line(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
|
||||
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
|
||||
# What was found is kept for the next round of the loop, but not as ready.
|
||||
data = machine.recorded()
|
||||
assert data["complete"] is False and "rg" not in data["tools"]
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_python_too_old_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert "TOO_OLD" in result.stdout
|
||||
assert_guidance(result.stdout, "Python 3.9 is too old")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_writes_nothing(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", f"rg={machine.base / 'nowhere' / 'rg'}", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_invalid_set_path_leaves_an_existing_file_alone(machine, shell):
|
||||
machine.standard()
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
result = machine.run("--set", "git=/nowhere/git", shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_valid_set_path_is_recorded_and_kept(machine, shell):
|
||||
machine.standard(rg=False)
|
||||
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
|
||||
result = machine.run("--set", f"rg={elsewhere}", shell=shell)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
# The next run finds it in the file, although it is still not on PATH.
|
||||
again = machine.run(shell=shell)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_set_for_a_tool_this_platform_does_not_need_is_a_usage_error(machine, shell):
|
||||
machine.standard()
|
||||
result = machine.run("--set", "nonsense=/bin/true", shell=shell)
|
||||
assert result.returncode == 1
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_venv_that_cannot_be_created_stops(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_VENV_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "tools/.venv) could not be created", "ensurepip is not available")
|
||||
assert machine.recorded()["complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_pip_failure_stops_and_is_retried_next_time(machine, shell):
|
||||
machine.standard()
|
||||
machine.extra_env["FAKE_PIP_FAIL"] = "1"
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "could not be installed into tools/.venv", "network unreachable")
|
||||
assert machine.recorded()["complete"] is False
|
||||
del machine.extra_env["FAKE_PIP_FAIL"]
|
||||
assert machine.run(shell=shell).returncode == 0
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 2
|
||||
|
||||
|
||||
# --- the store alias ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
|
||||
def test_store_alias_is_never_run_and_never_recorded(machine, shell, platform, alias):
|
||||
marker = machine.base / "alias-was-run"
|
||||
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
|
||||
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
|
||||
machine.path_dirs.insert(0, apps)
|
||||
machine.standard(pwsh=True)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
if platform == "windows":
|
||||
machine.python("python") # the real one, behind the alias on PATH
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 0, result.stdout
|
||||
recorded = machine.recorded()["tools"]["python"]
|
||||
assert "WindowsApps" not in recorded
|
||||
assert recorded.startswith(str(machine.stubs))
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
def test_windows_needs_pwsh_too(machine, shell):
|
||||
machine.standard(pwsh=False)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "PowerShell 7 (pwsh) was not found")
|
||||
|
||||
|
||||
# --- install folder length (D32) --------------------------------------------------
|
||||
|
||||
|
||||
def _machine_with_root_of(tmp_path: Path, length: int) -> Machine:
|
||||
base = tmp_path.resolve()
|
||||
name_length = length - len(str(base)) - 1
|
||||
assert name_length > 0, "tmp_path is too long for this test"
|
||||
machine = Machine(base, "r" * name_length)
|
||||
assert len(str(machine.root)) == length
|
||||
machine.standard(pwsh=True)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows"
|
||||
return machine
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shell", SHELLS)
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", 0),
|
||||
(96, "0", 42),
|
||||
(96, "1", 0),
|
||||
])
|
||||
def test_install_folder_limit_at_the_boundary(tmp_path, shell, length, longpaths, expected):
|
||||
machine = _machine_with_root_of(tmp_path, length)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
|
||||
result = machine.run(shell=shell)
|
||||
assert result.returncode == expected, result.stdout
|
||||
if expected == 42:
|
||||
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
def test_manifest_limit_and_path_budget_fit_max_path_together():
|
||||
"""95 + separator + 160 must stay within Windows' 259 - the two numbers are
|
||||
one sum, kept in two places (tools/prerequisites.txt, titles.PATH_BUDGET)."""
|
||||
limit = prerequisites.load_manifest().limits["install_dir_max"]
|
||||
assert limit + 1 + titles.PATH_BUDGET <= 259
|
||||
|
||||
|
||||
def test_manifest_names_the_tools_the_stack_starts():
|
||||
manifest = prerequisites.load_manifest()
|
||||
assert [t.name for t in manifest.tools_for("linux")] == ["python", "git", "rg"]
|
||||
assert [t.name for t in manifest.tools_for("windows")] == ["python", "git", "rg", "pwsh"]
|
||||
|
||||
|
||||
# --- the launcher -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "wikitool"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
ECHO_PYTHON = "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"
|
||||
|
||||
|
||||
def _complete_file(machine: Machine, complete: bool = True) -> None:
|
||||
machine.tools_file.write_text(json.dumps(
|
||||
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
|
||||
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
|
||||
if state != "no file":
|
||||
_complete_file(machine, complete=(state != "incomplete"))
|
||||
if state != "no venv":
|
||||
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
|
||||
result = _launch(machine, "doctor")
|
||||
assert result.returncode == 42
|
||||
assert "tools/preflight.sh" in result.stderr
|
||||
assert "Traceback" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
|
||||
_complete_file(machine)
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _launch(machine, "doctor", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
# --- trace-hook -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
|
||||
return subprocess.run([str(machine.tools / "trace-hook"), *args],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_trace_hook_runs_trace_ingest_with_the_venv_python(machine, layout):
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.splitlines() == [
|
||||
str(machine.tools / "trace_ingest.py"), "--source", "claude-code", "--event", "prompt.submitted",
|
||||
]
|
||||
|
||||
|
||||
def test_trace_hook_is_silent_without_a_venv(machine):
|
||||
result = _hook(machine, "--source", "claude-code")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "" and result.stderr == ""
|
||||
|
||||
|
||||
def _hook_commands() -> list[str]:
|
||||
root = config._PACKAGE_ROOT
|
||||
commands = []
|
||||
claude = json.loads((root / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||
for entries in claude["hooks"].values():
|
||||
for entry in entries:
|
||||
commands += [hook["command"] for hook in entry["hooks"]]
|
||||
copilot = json.loads((root / ".github" / "hooks" / "wiki-trace.json").read_text(encoding="utf-8"))
|
||||
for entries in copilot["hooks"].values():
|
||||
for hook in entries:
|
||||
commands += [hook["bash"], hook["powershell"]]
|
||||
import tomllib
|
||||
|
||||
vibe = tomllib.loads((root / ".vibe" / "hooks.toml").read_text(encoding="utf-8"))
|
||||
commands += [hook["command"] for hook in vibe["hooks"]]
|
||||
return commands
|
||||
|
||||
|
||||
def test_no_hook_relies_on_a_shebang_or_a_bare_python():
|
||||
commands = _hook_commands()
|
||||
assert len(commands) > 10
|
||||
for command in commands:
|
||||
assert command.startswith(("./tools/trace-hook ", ".\\tools\\.venv\\Scripts\\python.exe ")), command
|
||||
|
||||
|
||||
# --- toolpaths --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _point_at(monkeypatch, path: Path) -> Path:
|
||||
monkeypatch.setattr(toolpaths, "tools_file", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
def test_no_file_means_the_bare_name(tmp_path, monkeypatch):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME)
|
||||
assert toolpaths.git() == "git" and toolpaths.rg() == "rg"
|
||||
|
||||
|
||||
def test_recorded_path_is_used(tmp_path, monkeypatch):
|
||||
rg = _executable(tmp_path / "bin" / "rg", "#!/bin/sh\n")
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"rg": str(rg)}}), encoding="utf-8")
|
||||
assert toolpaths.rg() == str(rg)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content, message", [
|
||||
('{"schema": 1, "complete": true, "tools": {}}', "not recorded"),
|
||||
('{"schema": 1, "complete": true, "tools": {"git": "/nowhere/git"}}', "no longer exists"),
|
||||
("{not json", "not valid JSON"),
|
||||
('{"schema": 2, "tools": {}}', "shape this version reads"),
|
||||
])
|
||||
def test_a_present_but_unusable_file_never_falls_back_to_path(tmp_path, monkeypatch, content, message):
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(content, encoding="utf-8")
|
||||
with pytest.raises(toolpaths.ToolPathError, match=message) as caught:
|
||||
toolpaths.git()
|
||||
assert "tools/preflight.sh" in str(caught.value)
|
||||
assert not isinstance(caught.value, OSError)
|
||||
|
||||
|
||||
def _logging_wrapper(directory: Path, name: str, log: Path) -> Path:
|
||||
real = shutil.which(name)
|
||||
assert real, f"{name} is needed for this test"
|
||||
return _executable(directory / name, f"#!/bin/sh\necho \"$@\" >> '{log}'\nexec '{real}' \"$@\"\n")
|
||||
|
||||
|
||||
def test_git_and_rg_start_from_the_recorded_paths_not_from_path(tmp_path, monkeypatch):
|
||||
log = tmp_path / "calls.log"
|
||||
hidden = tmp_path / "not-on-path"
|
||||
git = _logging_wrapper(hidden, "git", log)
|
||||
rg = _logging_wrapper(hidden, "rg", log)
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(json.dumps(
|
||||
{"schema": 1, "complete": True, "tools": {"git": str(git), "rg": str(rg)}}), encoding="utf-8")
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Recorded Git"], cwd=repo, check=True)
|
||||
(repo / "kb").mkdir()
|
||||
(repo / "kb" / "Page.md").write_text("---\ntitle: Page\n---\nkingfisher\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "ROOT", repo)
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
|
||||
|
||||
assert config.default_author() == "Recorded Git"
|
||||
|
||||
from chemenu.search.base import SearchQuery
|
||||
from chemenu.search.ripgrep import RipgrepBackend
|
||||
backend = RipgrepBackend(search_root=repo / "kb", repo_root=repo)
|
||||
backend.search(SearchQuery(text="kingfisher"), {})
|
||||
|
||||
calls = log.read_text(encoding="utf-8")
|
||||
assert "config user.name" in calls
|
||||
assert "kingfisher" in calls
|
||||
|
||||
|
||||
def test_cli_turns_a_tool_path_error_into_an_error_line(tmp_path, monkeypatch, capsys):
|
||||
from chemenu import cli
|
||||
|
||||
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
|
||||
'{"schema": 1, "complete": true, "tools": {}}', encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["wikitool", "search", "kingfisher"])
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
cli._run_traced("search", ["kingfisher"])
|
||||
assert exited.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "ERROR" in out and "tools/preflight.sh" in out
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Where the programs this package starts live: `.wikitool-tools.json`.
|
||||
|
||||
The preflight (`tools/preflight.sh`, `tools/preflight.ps1`) finds `git`, `rg` and
|
||||
the rest once, checks they run, and records their absolute native paths in this
|
||||
file beside `tools/`. Everything here that starts one of them asks `resolve()`
|
||||
for the path instead of trusting whatever `PATH` the calling process inherited -
|
||||
a harness session on Windows can hold a `PATH` from before the tool was
|
||||
installed, and then the same machine answers "missing" in one terminal and
|
||||
"present" in the next.
|
||||
|
||||
The file belongs to the checkout the code runs from, not to the corpus it is
|
||||
pointed at: `CHEMENU_ROOT` may name another tree, and that tree's tools are this
|
||||
installation's tools. So it is read from `config._PACKAGE_ROOT`, never `ROOT`.
|
||||
|
||||
Enforcement lives in the launcher, which refuses to start (exit 42) until the
|
||||
preflight has written a complete file. Here, an absent file falls back to the
|
||||
bare program name - the case of the test suite and of `python -m chemenu.cli`
|
||||
run by hand. A file that is present but wrong never falls back: a tool it does
|
||||
not name, or names at a path that has gone, is a `ToolPathError` that tells the
|
||||
user to run the preflight again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.errors import ChemenuError
|
||||
|
||||
FILE_NAME = ".wikitool-tools.json"
|
||||
SCHEMA = 1
|
||||
PREFLIGHT = "run the preflight again: tools/preflight.sh"
|
||||
|
||||
|
||||
class ToolPathError(ChemenuError):
|
||||
"""The recorded tool paths cannot be used. Deliberately not an `OSError`:
|
||||
call sites that treat a failed start as "tool absent" must not swallow
|
||||
this - the fix is a command for the user, not a degraded answer."""
|
||||
|
||||
|
||||
def tools_file() -> Path:
|
||||
return config._PACKAGE_ROOT / FILE_NAME
|
||||
|
||||
|
||||
def load(path: Optional[Path] = None) -> Optional[dict]:
|
||||
"""The parsed file, or None when it does not exist. Raises `ToolPathError`
|
||||
for a file that exists but is not one this version writes."""
|
||||
path = tools_file() if path is None else path
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise ToolPathError(f"{FILE_NAME} cannot be read ({exc}) - {PREFLIGHT}") from exc
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ToolPathError(f"{FILE_NAME} is not valid JSON ({exc}) - {PREFLIGHT}") from exc
|
||||
if not isinstance(data, dict) or data.get("schema") != SCHEMA or not isinstance(data.get("tools"), dict):
|
||||
raise ToolPathError(
|
||||
f"{FILE_NAME} is not in the shape this version reads (schema {SCHEMA} with a "
|
||||
f"`tools` object) - {PREFLIGHT}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def resolve(name: str) -> str:
|
||||
"""The program to start for `name`: its recorded path, or `name` itself
|
||||
when no file has been written."""
|
||||
data = load()
|
||||
if data is None:
|
||||
return name
|
||||
path = data["tools"].get(name)
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ToolPathError(f"`{name}` is not recorded in {FILE_NAME} - {PREFLIGHT}")
|
||||
if not os.path.isfile(path):
|
||||
raise ToolPathError(f"`{name}` is recorded at {path}, which no longer exists - {PREFLIGHT}")
|
||||
return path
|
||||
|
||||
|
||||
def git() -> str:
|
||||
return resolve("git")
|
||||
|
||||
|
||||
def rg() -> str:
|
||||
return resolve("rg")
|
||||
Executable
+520
@@ -0,0 +1,520 @@
|
||||
#!/bin/sh
|
||||
# Preflight: check that this machine has what the stack needs, record where each
|
||||
# tool lives, and set up tools/.venv - before any `wikitool` command can run.
|
||||
#
|
||||
# tools/preflight.sh check, record, set up
|
||||
# tools/preflight.sh --set rg=/opt/rg/rg use a path the user named
|
||||
#
|
||||
# Exit 0: everything is in place and .wikitool-tools.json is complete.
|
||||
# Exit 42: the user has to act. The output says what, why, the command that fixes
|
||||
# it and what happens next; show it verbatim and wait (AGENTS.md § Tool
|
||||
# error contract). Never install anything on the user's behalf.
|
||||
# Exit 1: this script was called wrongly, or the tree next to it is incomplete.
|
||||
#
|
||||
# POSIX sh on purpose: it has to run before Python is known to exist, under dash,
|
||||
# bash and the Git Bash that Claude Code uses on Windows. The PowerShell
|
||||
# counterpart is tools/preflight.ps1; what both check is tools/prerequisites.txt.
|
||||
# The procedure around this script: instructions/preflight.md.
|
||||
#
|
||||
# Two variables exist for the test suite and are read nowhere else:
|
||||
# CHEMENU_PREFLIGHT_PLATFORM (windows|macos|linux) and
|
||||
# CHEMENU_PREFLIGHT_LONGPATHS (0|1) stand in for `uname` and the registry.
|
||||
|
||||
set -u
|
||||
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT=$(dirname -- "$DIR")
|
||||
MANIFEST="$DIR/prerequisites.txt"
|
||||
TOOLS_FILE="$ROOT/.wikitool-tools.json"
|
||||
VENV="$DIR/.venv"
|
||||
REQUIREMENTS="$DIR/requirements.txt"
|
||||
STAMP="$VENV/.chemenu-requirements.sha256"
|
||||
SELF="tools/preflight.sh"
|
||||
|
||||
PYPROBE='import sys; print("%d.%d" % sys.version_info[:2]); print(sys.executable)'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: tools/preflight.sh [--set <tool>=<path>]...
|
||||
|
||||
Checks the tools this stack needs (tools/prerequisites.txt), records their paths
|
||||
in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42
|
||||
means the user has to act - the output says how.
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- arguments ----------------------------------------------------------------
|
||||
|
||||
SETS=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--set)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "preflight: --set needs <tool>=<path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
SETS="$SETS
|
||||
$2"
|
||||
shift
|
||||
;;
|
||||
--set=*) SETS="$SETS
|
||||
${1#--set=}" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "preflight: unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "preflight: $MANIFEST is missing - this script has to run from inside an unpacked stack tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- platform -----------------------------------------------------------------
|
||||
|
||||
if [ -n "${CHEMENU_PREFLIGHT_PLATFORM:-}" ]; then
|
||||
PLATFORM=$CHEMENU_PREFLIGHT_PLATFORM
|
||||
else
|
||||
case "$(uname -s 2>/dev/null)" in
|
||||
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
|
||||
Darwin) PLATFORM=macos ;;
|
||||
*) PLATFORM=linux ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# A path as the rest of the machine spells it: under Git Bash `/c/Tools/rg.exe`
|
||||
# becomes `C:\Tools\rg.exe`, which is what Python and PowerShell read back.
|
||||
native_path() {
|
||||
if [ "$PLATFORM" = windows ] && command -v cygpath >/dev/null 2>&1; then
|
||||
cygpath -w "$1"
|
||||
else
|
||||
printf '%s\n' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- problems and the guidance block --------------------------------------------
|
||||
|
||||
PROBLEMS=0
|
||||
GUIDE=""
|
||||
BAD_SET=0
|
||||
|
||||
# problem <what> <why> <fix, may span lines>
|
||||
problem() {
|
||||
PROBLEMS=$((PROBLEMS + 1))
|
||||
fix=$(printf '%s\n' "$3" | sed '2,$s/^/ /')
|
||||
GUIDE="$GUIDE
|
||||
$PROBLEMS) $1
|
||||
Why: $2
|
||||
Fix: $fix
|
||||
Next: Tell the agent once this is done - it runs this check again.
|
||||
"
|
||||
}
|
||||
|
||||
stop() {
|
||||
if [ "$PROBLEMS" -eq 1 ]; then
|
||||
noun="1 thing needs"
|
||||
else
|
||||
noun="$PROBLEMS things need"
|
||||
fi
|
||||
echo ""
|
||||
echo "STOP - $noun your attention before this wiki can run."
|
||||
echo "(Agent: show this output to the user exactly as it is, then wait. Do not install"
|
||||
echo "anything yourself and do not work around it.)"
|
||||
printf '%s' "$GUIDE"
|
||||
exit 42
|
||||
}
|
||||
|
||||
# --- the manifest ---------------------------------------------------------------
|
||||
|
||||
manifest_field() { # <kind> <name> <field number, 1-based>
|
||||
while IFS='|' read -r kind name rest; do
|
||||
case "$kind" in ''|'#'*) continue ;; esac
|
||||
if [ "$kind" = "$1" ] && [ "$name" = "$2" ]; then
|
||||
printf '%s|%s|%s\n' "$kind" "$name" "$rest" | cut -d'|' -f"$3"
|
||||
return 0
|
||||
fi
|
||||
done < "$MANIFEST"
|
||||
return 1
|
||||
}
|
||||
|
||||
TOOLS=""
|
||||
while IFS='|' read -r kind name minimum platforms rest; do
|
||||
case "$kind" in ''|'#'*) continue ;; esac
|
||||
[ "$kind" = tool ] || continue
|
||||
case "$name" in *[!a-z0-9]*|'') echo "preflight: bad tool name '$name' in $MANIFEST" >&2; exit 1 ;; esac
|
||||
if [ "$platforms" = all ] || [ "$platforms" = "$PLATFORM" ]; then
|
||||
TOOLS="$TOOLS $name"
|
||||
fi
|
||||
done < "$MANIFEST"
|
||||
|
||||
# The install command for this machine's package manager, or every known one.
|
||||
install_hint() { # <tool>
|
||||
choco=$(manifest_field tool "$1" 7)
|
||||
winget=$(manifest_field tool "$1" 8)
|
||||
brew=$(manifest_field tool "$1" 9)
|
||||
apt=$(manifest_field tool "$1" 10)
|
||||
pacman=$(manifest_field tool "$1" 11)
|
||||
hint=""
|
||||
case "$PLATFORM" in
|
||||
windows)
|
||||
if command -v choco >/dev/null 2>&1 && [ "$choco" != - ]; then
|
||||
hint="$choco (in a terminal opened as administrator)"
|
||||
elif command -v winget >/dev/null 2>&1 && [ "$winget" != - ]; then
|
||||
hint=$winget
|
||||
fi ;;
|
||||
macos)
|
||||
if command -v brew >/dev/null 2>&1 && [ "$brew" != - ]; then hint=$brew; fi ;;
|
||||
*)
|
||||
if command -v apt-get >/dev/null 2>&1 && [ "$apt" != - ]; then
|
||||
hint=$apt
|
||||
elif command -v pacman >/dev/null 2>&1 && [ "$pacman" != - ]; then
|
||||
hint=$pacman
|
||||
fi ;;
|
||||
esac
|
||||
if [ -n "$hint" ]; then
|
||||
printf '%s\n' "$hint"
|
||||
return
|
||||
fi
|
||||
printf 'Install it with the package manager this computer uses, for example:\n'
|
||||
for entry in "choco:$choco" "winget:$winget" "brew:$brew" "apt:$apt" "pacman:$pacman"; do
|
||||
case "$entry" in *:-) continue ;; esac
|
||||
printf ' %s\n' "${entry#*:}"
|
||||
done
|
||||
}
|
||||
|
||||
# --- version helpers ------------------------------------------------------------
|
||||
|
||||
# major.minor of the first version-looking token in $1 ("git version 2.47.1" -> 2.47)
|
||||
version_of() {
|
||||
printf '%s\n' "$1" | sed -n '1{s/^[^0-9]*\([0-9][0-9]*\)\(\.[0-9][0-9]*\)\{0,1\}.*/\1\2/p;}'
|
||||
}
|
||||
|
||||
version_ge() { # <have> <want>, both major[.minor]
|
||||
have=$1 want=$2
|
||||
case "$have" in *.*) ;; *) have="$have.0" ;; esac
|
||||
case "$want" in *.*) ;; *) want="$want.0" ;; esac
|
||||
hm=${have%%.*} hn=${have#*.}
|
||||
wm=${want%%.*} wn=${want#*.}
|
||||
[ "$hm" -gt "$wm" ] || { [ "$hm" -eq "$wm" ] && [ "$hn" -ge "$wn" ]; }
|
||||
}
|
||||
|
||||
# --- finding tools --------------------------------------------------------------
|
||||
|
||||
# The Microsoft Store's app-execution aliases: a python.exe that opens the Store
|
||||
# instead of running anything. Never executed, never recorded.
|
||||
is_store_alias() {
|
||||
case "$1" in *[Ww]indows[Aa]pps/*|*[Ww]indows[Aa]pps\\*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Every executable called <name> on PATH, in PATH order, store aliases dropped.
|
||||
on_path() {
|
||||
set -f
|
||||
old_ifs=$IFS
|
||||
IFS=:
|
||||
for d in $PATH; do
|
||||
[ -n "$d" ] || d=.
|
||||
for f in "$d/$1" "$d/$1.exe"; do
|
||||
if [ -f "$f" ] && [ -x "$f" ] && ! is_store_alias "$f"; then
|
||||
printf '%s\n' "$f"
|
||||
fi
|
||||
done
|
||||
done
|
||||
IFS=$old_ifs
|
||||
set +f
|
||||
}
|
||||
|
||||
usable() { # <path>
|
||||
[ -n "$1" ] && [ -f "$1" ] && [ -x "$1" ] && ! is_store_alias "$1"
|
||||
}
|
||||
|
||||
# Runs a Python candidate; prints "<major.minor>\n<sys.executable>".
|
||||
probe_python() { # <path> [extra argument, e.g. -3 for py]
|
||||
if [ $# -gt 1 ]; then
|
||||
"$1" "$2" -c "$PYPROBE" 2>/dev/null | tr -d '\r'
|
||||
else
|
||||
"$1" -c "$PYPROBE" 2>/dev/null | tr -d '\r'
|
||||
fi
|
||||
}
|
||||
|
||||
# The value recorded for <name> in .wikitool-tools.json, unescaped.
|
||||
recorded() {
|
||||
[ -f "$TOOLS_FILE" ] || return 1
|
||||
value=$(sed -n "s/^ *\"$1\": *\"\(.*\)\",\{0,1\} *\$/\1/p" "$TOOLS_FILE" | head -n 1)
|
||||
[ -n "$value" ] || return 1
|
||||
printf '%s\n' "$value" | sed 's/\\"/"/g; s/\\\\/\\/g'
|
||||
}
|
||||
|
||||
set_value() { # <name> -> the path given with --set, if any
|
||||
printf '%s\n' "$SETS" | sed -n "s/^$1=//p" | head -n 1
|
||||
}
|
||||
|
||||
# --- --set: every named path has to work, or nothing is written -------------------
|
||||
|
||||
for entry in $(printf '%s\n' "$SETS" | sed -n 's/^\([^=]*\)=.*/\1/p'); do
|
||||
case " $TOOLS " in
|
||||
*" $entry "*) ;;
|
||||
*) echo "preflight: --set names '$entry', which is not a tool this platform needs:$TOOLS" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- python -------------------------------------------------------------------------
|
||||
|
||||
PY="" PY_VERSION="" PY_OLD=""
|
||||
PY_MIN=$(manifest_field tool python 3)
|
||||
|
||||
try_python() { # <path> [extra] -> sets PY/PY_VERSION on success, PY_OLD when too old
|
||||
out=$(probe_python "$@") || return 1
|
||||
version=$(printf '%s\n' "$out" | sed -n 1p)
|
||||
executable=$(printf '%s\n' "$out" | sed -n 2p)
|
||||
[ -n "$version" ] && [ -n "$executable" ] || return 1
|
||||
if version_ge "$version" "$PY_MIN"; then
|
||||
PY=$executable PY_VERSION=$version
|
||||
return 0
|
||||
fi
|
||||
PY_OLD="$version $1"
|
||||
return 1
|
||||
}
|
||||
|
||||
python_extra() { # py and py.exe are the launcher: they need -3
|
||||
case "${1##*/}" in py|py.exe) printf '%s\n' -3 ;; esac
|
||||
}
|
||||
|
||||
given=$(set_value python)
|
||||
if [ -n "$given" ]; then
|
||||
extra=$(python_extra "$given")
|
||||
if ! usable "$given" || ! try_python "$given" $extra; then
|
||||
problem "The path given for Python does not work: $given" \
|
||||
"every wikitool command runs on Python $PY_MIN or newer, and this path did not start one" \
|
||||
"Check the path - it has to be the python executable itself, version $PY_MIN or newer.
|
||||
Then run: $SELF --set python=<full path to python>"
|
||||
BAD_SET=1
|
||||
fi
|
||||
else
|
||||
previous=$(recorded python) || previous=""
|
||||
if [ -n "$previous" ] && usable "$previous"; then
|
||||
try_python "$previous" || true
|
||||
fi
|
||||
if [ -z "$PY" ]; then
|
||||
if [ "$PLATFORM" = windows ]; then
|
||||
order="python: py:-3 python3:"
|
||||
else
|
||||
order="python3: python:"
|
||||
fi
|
||||
for candidate in $order; do
|
||||
name=${candidate%%:*}
|
||||
extra=${candidate#*:}
|
||||
found=$(on_path "$name")
|
||||
[ -n "$found" ] || continue
|
||||
# A here-document, not a pipe: `break 2` has to leave the outer loop
|
||||
# of this shell, and a path may contain spaces ("Program Files").
|
||||
while IFS= read -r path; do
|
||||
if [ -n "$extra" ]; then
|
||||
try_python "$path" "$extra" && break 2
|
||||
else
|
||||
try_python "$path" && break 2
|
||||
fi
|
||||
done <<EOF
|
||||
$found
|
||||
EOF
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- the other tools --------------------------------------------------------------
|
||||
|
||||
REPORT=""
|
||||
report() { # <status> <name> <version> <path>
|
||||
REPORT="$REPORT$(printf '%-8s %-7s %-8s %s' "$1" "$2" "$3" "$4")
|
||||
"
|
||||
}
|
||||
|
||||
if [ -n "$PY" ]; then
|
||||
report OK python "$PY_VERSION" "$PY"
|
||||
eval "FOUND_python=\$PY"
|
||||
elif [ "$BAD_SET" -eq 0 ]; then
|
||||
label=$(manifest_field tool python 5)
|
||||
why=$(manifest_field tool python 6)
|
||||
if [ -n "$PY_OLD" ]; then
|
||||
report TOO_OLD python "${PY_OLD%% *}" "${PY_OLD#* }"
|
||||
problem "$label ${PY_OLD%% *} is too old - this stack needs $PY_MIN or newer." "$why" \
|
||||
"$(install_hint python)
|
||||
Or, if a newer one is already installed, give its full path:
|
||||
$SELF --set python=<full path to python>"
|
||||
else
|
||||
report MISSING python - -
|
||||
problem "$label $PY_MIN or newer was not found." "$why" \
|
||||
"$(install_hint python)
|
||||
Or, if it is installed somewhere this check did not look, give its full path:
|
||||
$SELF --set python=<full path to python>"
|
||||
fi
|
||||
fi
|
||||
|
||||
for tool in $TOOLS; do
|
||||
[ "$tool" = python ] && continue
|
||||
label=$(manifest_field tool "$tool" 5)
|
||||
why=$(manifest_field tool "$tool" 6)
|
||||
minimum=$(manifest_field tool "$tool" 3)
|
||||
given=$(set_value "$tool")
|
||||
path=""
|
||||
if [ -n "$given" ]; then
|
||||
if usable "$given"; then
|
||||
path=$given
|
||||
else
|
||||
problem "The path given for $label does not work: $given" "$why" \
|
||||
"Check the path - it has to be the $tool executable itself.
|
||||
Then run: $SELF --set $tool=<full path to $tool>"
|
||||
BAD_SET=1
|
||||
continue
|
||||
fi
|
||||
else
|
||||
previous=$(recorded "$tool") || previous=""
|
||||
if [ -n "$previous" ] && usable "$previous"; then
|
||||
path=$previous
|
||||
else
|
||||
path=$(on_path "$tool" | head -n 1)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$path" ]; then
|
||||
report MISSING "$tool" - -
|
||||
problem "$label was not found." "$why" \
|
||||
"$(install_hint "$tool")
|
||||
Or, if it is installed somewhere this check did not look, give its full path:
|
||||
$SELF --set $tool=<full path to $tool>"
|
||||
continue
|
||||
fi
|
||||
version=$(version_of "$("$path" --version 2>/dev/null | tr -d '\r')")
|
||||
if [ "$minimum" != - ] && { [ -z "$version" ] || ! version_ge "$version" "$minimum"; }; then
|
||||
report TOO_OLD "$tool" "${version:-unknown}" "$path"
|
||||
problem "$label ${version:-of unknown version} is too old - this stack needs $minimum or newer." "$why" \
|
||||
"$(install_hint "$tool")"
|
||||
continue
|
||||
fi
|
||||
native=$(native_path "$path")
|
||||
report OK "$tool" "${version:--}" "$native"
|
||||
eval "FOUND_$tool=\$native"
|
||||
done
|
||||
|
||||
# --- install folder length (Windows, long paths off) ------------------------------
|
||||
|
||||
longpaths_enabled() {
|
||||
if [ -n "${CHEMENU_PREFLIGHT_LONGPATHS:-}" ]; then
|
||||
[ "$CHEMENU_PREFLIGHT_LONGPATHS" = 1 ]
|
||||
return
|
||||
fi
|
||||
# MSYS would rewrite the `/v` into a path without the exclusion. An unreadable
|
||||
# key counts as "off": the limit then protects a machine it did not have to.
|
||||
answer=$(MSYS2_ARG_CONV_EXCL='*' reg query 'HKLM\SYSTEM\CurrentControlSet\Control\FileSystem' \
|
||||
/v LongPathsEnabled 2>/dev/null) || return 1
|
||||
case "$answer" in *0x1*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Length in UTF-16 code units, which is what MAX_PATH counts.
|
||||
utf16_length() {
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
bytes=$(printf '%s' "$1" | iconv -f UTF-8 -t UTF-16LE 2>/dev/null | wc -c | tr -d ' ')
|
||||
if [ "${bytes:-0}" -gt 0 ]; then
|
||||
echo $((bytes / 2))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo ${#1}
|
||||
}
|
||||
|
||||
if [ "$PLATFORM" = windows ] && ! longpaths_enabled; then
|
||||
limit=$(manifest_field limit install_dir_max 3)
|
||||
folder=$(native_path "$ROOT")
|
||||
length=$(utf16_length "$folder")
|
||||
if [ "$length" -gt "$limit" ]; then
|
||||
problem "The folder this wiki is installed in is too long ($length characters, at most $limit): $folder" \
|
||||
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" \
|
||||
"Move the wiki to a shorter folder, for example C:\\Chemenu, and run this check there.
|
||||
Alternatively, someone with administrator rights can turn on long paths in Windows."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- record what was found ----------------------------------------------------------
|
||||
|
||||
json_string() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
write_tools_file() { # <true|false>
|
||||
tmp="$TOOLS_FILE.tmp.$$"
|
||||
{
|
||||
printf '{\n "schema": 1,\n "complete": %s,\n "tools": {' "$1"
|
||||
separator=""
|
||||
for tool in $TOOLS; do
|
||||
eval "value=\${FOUND_$tool:-}"
|
||||
[ -n "$value" ] || continue
|
||||
printf '%s\n "%s": "%s"' "$separator" "$tool" "$(json_string "$value")"
|
||||
separator=","
|
||||
done
|
||||
printf '\n }\n}\n'
|
||||
} > "$tmp" && mv -f "$tmp" "$TOOLS_FILE"
|
||||
}
|
||||
|
||||
printf '%s' "$REPORT"
|
||||
|
||||
if [ "$BAD_SET" -eq 1 ]; then
|
||||
stop # nothing is written for a path that does not work
|
||||
fi
|
||||
write_tools_file false
|
||||
if [ "$PROBLEMS" -gt 0 ]; then
|
||||
stop
|
||||
fi
|
||||
|
||||
# --- tools/.venv ----------------------------------------------------------------------
|
||||
|
||||
venv_python() {
|
||||
if [ -x "$VENV/bin/python" ]; then
|
||||
printf '%s\n' "$VENV/bin/python"
|
||||
elif [ -f "$VENV/Scripts/python.exe" ]; then
|
||||
printf '%s\n' "$VENV/Scripts/python.exe"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
last_lines() {
|
||||
printf '%s\n' "$1" | tail -n 5 | sed 's/^/ /'
|
||||
}
|
||||
|
||||
VPY=$(venv_python) || VPY=""
|
||||
if [ -z "$VPY" ] || ! "$VPY" -m pip --version >/dev/null 2>&1; then
|
||||
if ! output=$("$PY" -m venv --clear "$VENV" 2>&1) || ! VPY=$(venv_python); then
|
||||
fix="Python said:
|
||||
$(last_lines "$output")"
|
||||
if [ "$PLATFORM" = linux ] && command -v apt-get >/dev/null 2>&1; then
|
||||
fix="sudo apt install python3-venv
|
||||
$fix"
|
||||
fi
|
||||
problem "The wiki's own Python environment (tools/.venv) could not be created." \
|
||||
"wikitool runs in that environment, so that nothing it installs touches the rest of the computer" \
|
||||
"$fix"
|
||||
stop
|
||||
fi
|
||||
fi
|
||||
|
||||
want=$("$VPY" -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' \
|
||||
"$REQUIREMENTS" 2>/dev/null | tr -d '\r')
|
||||
have=$(cat "$STAMP" 2>/dev/null) || have=""
|
||||
if [ -z "$want" ] || [ "$want" != "$have" ]; then
|
||||
if ! output=$("$VPY" -m pip install --disable-pip-version-check --quiet -r "$REQUIREMENTS" 2>&1); then
|
||||
problem "The libraries wikitool needs could not be installed into tools/.venv." \
|
||||
"they are downloaded once from the internet; without them no wikitool command starts" \
|
||||
"Check that this computer can reach the internet (a proxy or a security program can block it; if so, ask whoever looks after this computer).
|
||||
pip said:
|
||||
$(last_lines "$output")"
|
||||
stop
|
||||
fi
|
||||
printf '%s\n' "$want" > "$STAMP"
|
||||
fi
|
||||
echo "OK venv - $(native_path "$VENV")"
|
||||
|
||||
write_tools_file true
|
||||
echo ""
|
||||
echo "Preflight passed. Tool paths are recorded in .wikitool-tools.json; tools/wikitool is ready."
|
||||
exit 0
|
||||
@@ -0,0 +1,22 @@
|
||||
# What this stack needs on the machine, read by tools/preflight.sh, tools/preflight.ps1
|
||||
# and `wikitool doctor` - one list, so the three cannot disagree. The procedure around it
|
||||
# is instructions/preflight.md.
|
||||
#
|
||||
# Line format, fields separated by `|` so a POSIX shell reads it with `IFS='|' read`:
|
||||
#
|
||||
# limit|<name>|<value>
|
||||
# tool|<name>|<minimum version, or ->|<all or windows>|<label>|<why it is needed>|<choco>|<winget>|<brew>|<apt>|<pacman>
|
||||
#
|
||||
# A `-` in an install field means that package manager has no entry for this tool.
|
||||
# `name` is the key the tool is recorded under in .wikitool-tools.json.
|
||||
|
||||
# The longest install folder - the directory holding tools/ - that still fits Windows'
|
||||
# MAX_PATH of 259 when long paths are off: 95 + 1 separator + the 160 characters
|
||||
# `titles.PATH_BUDGET` gives a file below the instance root, with 3 to spare.
|
||||
# Change it only together with that budget; a test holds the two to the sum.
|
||||
limit|install_dir_max|95
|
||||
|
||||
tool|python|3.11|all|Python|every wikitool command runs on it|choco install python|winget install --id Python.Python.3.13 -e|brew install python|sudo apt install python3 python3-venv|sudo pacman -S python
|
||||
tool|git|-|all|Git|the wiki is a git repository, and publishing commits through git|choco install git|winget install --id Git.Git -e|brew install git|sudo apt install git|sudo pacman -S git
|
||||
tool|rg|-|all|ripgrep (rg)|search and several checks read the wiki through it|choco install ripgrep|winget install --id BurntSushi.ripgrep.MSVC -e|brew install ripgrep|sudo apt install ripgrep|sudo pacman -S ripgrep
|
||||
tool|pwsh|7|windows|PowerShell 7 (pwsh)|the agent harnesses on Windows run their commands in it|choco install powershell-core|winget install --id Microsoft.PowerShell -e|-|-|-
|
||||
@@ -0,0 +1,14 @@
|
||||
"""What tools/wikitool and tools/wikitool.ps1 run with the venv's Python.
|
||||
|
||||
Running a file puts its own directory at the front of `sys.path`, so `chemenu`
|
||||
imports from here without `PYTHONPATH` - whose separator is `:` on POSIX and `;`
|
||||
on Windows, one more thing for two launchers to get right.
|
||||
|
||||
The name deliberately does not start with `wikitool.`: the Windows Python
|
||||
installer can add `.PY` to `PATHEXT`, and a `tools/wikitool.py` could then be
|
||||
what PowerShell runs for `tools/wikitool`.
|
||||
"""
|
||||
from chemenu.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# What the harness hooks call: tools/trace_ingest.py, run by the venv's Python.
|
||||
#
|
||||
# tools/trace-hook --source claude-code --event prompt.submitted
|
||||
#
|
||||
# A hook command is a fixed string in a harness's config file (.claude/settings.json,
|
||||
# .github/hooks/wiki-trace.json, .vibe/hooks.toml) and cannot read
|
||||
# .wikitool-tools.json. The venv's Python can stand in for the recorded one: the
|
||||
# preflight creates it from exactly that interpreter, and it sits at a fixed
|
||||
# place - only its layout differs (bin/ everywhere but Windows, Scripts\ there,
|
||||
# which is also what Git Bash sees). trace_ingest.py uses the standard library
|
||||
# only, so the venv adds nothing it needs; it just makes the interpreter findable.
|
||||
#
|
||||
# Relying on the script's own shebang (`python3`) is what this replaces: in
|
||||
# Git Bash on Windows, `python3` is the Microsoft Store alias.
|
||||
#
|
||||
# No venv yet - before the preflight has run - means no trace, silently. A hook
|
||||
# must never fail the call it observes.
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 0
|
||||
if [ -x "$DIR/.venv/bin/python" ]; then
|
||||
exec "$DIR/.venv/bin/python" "$DIR/trace_ingest.py" "$@"
|
||||
elif [ -f "$DIR/.venv/Scripts/python.exe" ]; then
|
||||
exec "$DIR/.venv/Scripts/python.exe" "$DIR/trace_ingest.py" "$@"
|
||||
fi
|
||||
exit 0
|
||||
+39
-17
@@ -1,25 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entry point for the wikitool CLI. Activates the local venv and runs the
|
||||
# chemenu package as a module so agents can invoke it via a stable path:
|
||||
#!/bin/sh
|
||||
# Entry point for the wikitool CLI, so agents and people invoke it through one
|
||||
# stable path on every platform:
|
||||
#
|
||||
# tools/wikitool <command> [options]
|
||||
#
|
||||
# Setup (one time):
|
||||
# cd tools
|
||||
# python3 -m venv .venv
|
||||
# .venv/bin/pip install -r requirements.txt
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# This is the POSIX half - Linux, macOS and Git Bash on Windows. PowerShell 7
|
||||
# resolves the same string to tools/wikitool.ps1 first.
|
||||
#
|
||||
# Nothing here sets up an environment. tools/preflight.sh does that, once per
|
||||
# checkout and again after every stack update: it records the tool paths in
|
||||
# .wikitool-tools.json and creates tools/.venv. Until it has passed, this script
|
||||
# stops with exit 42 and names it - the preflight is the one step that must not
|
||||
# be skipped or improvised around (instructions/preflight.md).
|
||||
set -eu
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT=$(dirname -- "$DIR")
|
||||
|
||||
if [ ! -x "$DIR/.venv/bin/python" ]; then
|
||||
echo "wikitool: venv not found at $DIR/.venv" >&2
|
||||
echo "Run: cd tools && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
|
||||
exit 1
|
||||
# Both venv layouts: bin/ everywhere but Windows, Scripts\ there - which is also
|
||||
# what a venv created by a native Windows Python looks like from Git Bash.
|
||||
if [ -x "$DIR/.venv/bin/python" ]; then
|
||||
PY="$DIR/.venv/bin/python"
|
||||
elif [ -f "$DIR/.venv/Scripts/python.exe" ]; then
|
||||
PY="$DIR/.venv/Scripts/python.exe"
|
||||
else
|
||||
PY=""
|
||||
fi
|
||||
|
||||
if [ -z "$PY" ] || ! grep -q '"complete": true' "$ROOT/.wikitool-tools.json" 2>/dev/null; then
|
||||
cat >&2 <<'EOF'
|
||||
STOP - this checkout is not set up yet (or no longer after an update).
|
||||
Run the preflight first; it checks what this computer has, records the tool
|
||||
paths and creates tools/.venv:
|
||||
|
||||
tools/preflight.sh
|
||||
|
||||
It exits 0 when everything is in place. If it exits 42, show its output to the
|
||||
user as it is and wait - it says what to do. See instructions/preflight.md.
|
||||
EOF
|
||||
exit 42
|
||||
fi
|
||||
|
||||
# Do NOT cd into $DIR: that would resolve relative CLI arguments (e.g.
|
||||
# --markdown "kb/Lint Report.md") against tools/ instead of the caller's cwd.
|
||||
# Add $DIR to PYTHONPATH instead so `chemenu` is importable regardless of
|
||||
# where this script is invoked from.
|
||||
export PYTHONPATH="$DIR${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec "$DIR/.venv/bin/python" -m chemenu.cli "$@"
|
||||
# Running a script puts its own directory on sys.path, which is how `chemenu`
|
||||
# becomes importable without PYTHONPATH (whose separator differs on Windows).
|
||||
exec "$PY" "$DIR/run_wikitool.py" "$@"
|
||||
Reference in new issue
Block a user