Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c18a2ae
feat(rust): scaffold Rust workspace + CLI skeleton for the ask port
amondnet Jul 3, 2026
32dda2f
feat(npm): add copy-over shim wrapper for the Rust ask binary
amondnet Jul 3, 2026
82a475d
ci(release): add Rust binary + cargo publish workflows for the ask port
amondnet Jul 3, 2026
dcfc869
feat(rust): port spec parsing (parseSpec/slugifyNpmName) with parity …
amondnet Jul 3, 2026
9bf5203
docs(rust-port): mark phases 0-2 + spec module complete in track plan
amondnet Jul 3, 2026
8a1c157
feat(rust): port ask.json schema (LibraryEntry union + validation)
amondnet Jul 3, 2026
e97823f
feat(rust): port resolved.json cache schema (ResolvedEntry/ResolvedJson)
amondnet Jul 3, 2026
23f9aee
feat(rust): port io module (ask.json/resolved.json I/O + content hash…
amondnet Jul 3, 2026
5a74661
feat(rust): port markers module (marker-block inject/remove/has)
amondnet Jul 3, 2026
4148ea0
feat(rust): port registry spec parsing + ecosystem detection
amondnet Jul 3, 2026
766ca1d
feat(rust): port lockfile readers (bun/npm/package.json + parse-helpers)
amondnet Jul 3, 2026
1e27111
feat(rust): port yarn.lock parser into lockfiles chain
amondnet Jul 3, 2026
35eeaeb
feat(rust): port pnpm-lock.yaml parser — lockfile chain now at full p…
amondnet Jul 3, 2026
8fc5c6d
feat(rust): port parse_repo_url (resolvers/utils) with regex
amondnet Jul 3, 2026
6122b5c
feat(rust): add injectable HTTP client abstraction (ureq + mock)
amondnet Jul 3, 2026
1f075ba
feat(rust): port registry HTTP lookup (RegistrySource + resolve_from_…
amondnet Jul 3, 2026
4b029db
feat(rust): port ecosystem resolvers (npm/pypi/pub/maven) over HttpCl…
amondnet Jul 3, 2026
6af2b73
feat(rust): port the global store (paths, atomic writes, locking, ver…
amondnet Jul 3, 2026
e8ffb01
feat(rust): port source adapters (github, npm, llms-txt) + shared col…
amondnet Jul 3, 2026
77a844c
feat(rust): port storage (save_docs materialization + list_docs)
amondnet Jul 3, 2026
f88b486
feat(rust): port skill + AGENTS.md generation
amondnet Jul 3, 2026
d2e65b0
feat(rust): port install orchestrator + ignore-file management
amondnet Jul 3, 2026
e7ca29a
feat(rust): port list model/renderer + wire ask install & ask list
amondnet Jul 3, 2026
18eecbb
test(rust): add TS<->Rust differential parity harness for install
amondnet Jul 3, 2026
21a6d93
chore(rust): sync lockfiles
amondnet Jul 3, 2026
7b734ab
feat(rust): port ensure_checkout, the shared lazy-fetch spine
amondnet Jul 3, 2026
aa2a189
feat(rust): port ask src + ask fetch on the ensure_checkout spine
amondnet Jul 3, 2026
900f4e0
feat(rust): port ask docs + find_doc_like_paths on the spine
amondnet Jul 3, 2026
d051c71
feat(rust): port ask remove + extend parity harness with remove seque…
amondnet Jul 3, 2026
ff084af
feat(rust): port ask search + resolve_csp (optional csp delegation)
amondnet Jul 3, 2026
0109f90
feat(rust): port ask cache (ls/gc/clean) + store/cache.rs
amondnet Jul 3, 2026
3d3342e
feat(rust): port `ask add` command with offline-first docs-path disco…
amondnet Jul 3, 2026
607bc4e
feat(rust): port `ask skills` vendoring/symlink/lock subsystem
amondnet Jul 3, 2026
15485d5
ci(release): cut over @pleaseai/ask + crates.io publish to Rust binaries
amondnet Jul 3, 2026
34a3a27
chore(claude): enable github-actions and plannotator plugins
amondnet Jul 3, 2026
fbd224d
chore(ci): scope read permission to job level in release workflows
amondnet Jul 3, 2026
73ae9a9
fix: apply cubic review — Windows/security/parity fixes across the port
amondnet Jul 3, 2026
a8316bd
fix: add containment guard to non-unix symlink copy in cp_dir_atomic
amondnet Jul 3, 2026
e39309b
fix: platform-aware home dir for cache gc scan root (Windows)
amondnet Jul 4, 2026
a9f693a
fix: apply CodeRabbit review — error propagation, exit code, atomicit…
amondnet Jul 4, 2026
fb4a73e
fix: guard copy_dir_recursive against symlink cycles and escapes
amondnet Jul 4, 2026
5fde4a6
chore: add run-ask-rust skill with smoke driver
amondnet Jul 4, 2026
189fa6f
fix: dangling-symlink link cleanup + guard escaped file symlinks in v…
amondnet Jul 4, 2026
1230770
docs(skill): drop brittle hard-coded test count from run-ask-rust run…
amondnet Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
"cloudflare@cloudflare": true,
"typescript-lsp@code-intelligence": true,
"eslint-lsp@code-intelligence": true,
"gardening@passionfactory": true
"gardening@passionfactory": true,
"github-actions@passionfactory": true,
"plannotator@passionfactory": true
},
"extraKnownMarketplaces": {
"passionfactory": {
Expand Down
115 changes: 115 additions & 0 deletions .claude/skills/run-ask-rust/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: run-ask-rust
description: Build, run, test, and smoke-test the Rust port of the ask CLI (crates/ask, binary `ask`). Use when asked to run the Rust CLI, verify a command works end-to-end, run the smoke driver, run cargo tests, or check TS↔Rust parity with the differential harness.
---

# Run the ask CLI (Rust port)

The Rust port of `@pleaseai/ask` lives in `crates/ask` (crate name **`ask-please`**,
binary **`ask`**, lib name `ask`). It is a plain CLI — no server, no GUI. The
agent path is the committed smoke driver, which builds the binary and drives the
full user flow (add → fetch → src/docs/search → remove) in an isolated temp
project + temp `ASK_HOME` with per-step assertions.

All paths below are relative to the repo root (the directory with `Cargo.toml`).

## Prerequisites

- Rust toolchain — pinned by `rust-toolchain.toml` (1.94.1); `cargo` on PATH is enough.
- `git` on PATH (`ask fetch` shells out to `git clone --depth 1`).
- Network access for the fetch/src/docs steps (use `SMOKE_OFFLINE=1` without it).
- `bun` only for the parity harness (it builds the TS oracle), not for the Rust CLI.

## Build

```bash
cargo build --release --locked -p ask-please
./target/release/ask --version # → ask 0.4.8
```

Takes ~70s clean (release profile has lto + codegen-units=1). For iteration,
`cargo build -p ask-please` (debug) is much faster; the parity harness uses
`target/debug/ask`.

## Run (agent path) — smoke driver

```bash
.claude/skills/run-ask-rust/smoke.sh # cargo build + 11-step full flow
.claude/skills/run-ask-rust/smoke.sh --no-build # reuse existing target/release/ask
SMOKE_OFFLINE=1 .claude/skills/run-ask-rust/smoke.sh --no-build # 6 steps, no network
```

Success ends with `SMOKE PASS (11 steps)` (6 offline). Steps covered: `--version`,
`add` (github spec → ask.json + AGENTS.md marker + `.claude/skills/<name>-docs/SKILL.md`),
`list`, `fetch` (real shallow clone of `tj/commander.js@v12.1.0`), `src --json`,
`docs`, `search` (no-csp fallback recipe), `add --docs-paths` (object entry),
`remove`, `add npm:...` (package.json range fallback), `cache ls --json`.
Everything runs in a `mktemp -d` — nothing touches your real `~/.ask` or project.

## Run (manual, ad-hoc)

To poke a single command, isolate the store with `ASK_HOME` and work in a temp dir:

```bash
export ASK_HOME=$(mktemp -d)
cd $(mktemp -d) && echo '{"name":"demo","version":"0.0.0"}' > package.json
/path/to/repo/target/release/ask add 'github:tj/commander.js@v12.1.0'
/path/to/repo/target/release/ask fetch 'github:tj/commander.js@v12.1.0'
/path/to/repo/target/release/ask docs 'github:tj/commander.js@v12.1.0'
```

## Direct invocation (most PRs touch internals)

Logic lives in the lib (`crates/ask/src/`), fully unit-tested offline (HTTP via
`MockClient`, git via local bare repos). For a PR touching one module, the right
handle is the module's test filter, not the binary:

```bash
cargo test -p ask-please # full suite (<1s after build)
cargo test -p ask-please lockfiles:: # one module
```

## Parity harness (the real oracle)

Rust unit tests share a mental model with the code; the TS build is the external
oracle. **Re-run after changing any command's file output:**

```bash
bash scripts/parity-diff.sh # builds TS (bun) + Rust debug, 9 cases
bash scripts/parity-diff.sh --no-build # reuse dist/ + target/debug
```

Success ends with `ALL PARITY CASES IDENTICAL`. It byte-diffs generated files
only (never stdout — consola vs eprintln legitimately differ). Requires `bun`.

## Gotchas

- **Crate name ≠ binary name.** `cargo run -p ask-please -- <args>`; the binary
is `target/{debug,release}/ask`. `cargo run -p ask` fails.
- **`ask install`/`ask add` never download docs** (lazy-first design). `ask list`
shows `Version: unresolved … (not installed — run ask install)` even right
after a successful `add` — that's normal. Actual fetching happens in the lazy
commands (`fetch`/`src`/`docs`).
- **`ask add npm:<pkg>` needs the dep declared.** Version comes from
lockfile → package.json range fallback; if the package isn't in either you get
`not found in any lockfile — skipping` (exit still 0, entry still added).
- **github specs need an explicit tag ref** (`@v12.1.0`); mutable refs like
`main` are rejected by strict validation at install time.
- **Stream split:** progress/warnings go to **stderr**, data (`src --json`,
`docs`, `cache ls --json`, recipe from `search`) to **stdout**. When asserting
on progress lines, capture `2>&1`.
- **Don't pipe ask straight into `grep -q` under `set -o pipefail`** — grep
exits on first match and SIGPIPE (141) kills the pipeline intermittently.
Capture into a variable first (the smoke driver does this everywhere).
- **`ask search` without csp is exit 0** — it prints the checkout path + a
shell-quoted `csp search …` recipe instead of failing.
- **Parity harness entrypoint trap:** the TS side runs `packages/cli/dist/cli.js`
(runMain), NOT `dist/index.js` (silent no-op). Already encoded in the script.

## Troubleshooting

- `FAIL: npm resolve` in an older smoke run / `file exists: package.json` in an
interactive zsh → zsh noclobber; use `printf > file` from bash or `>|` in zsh.
- `error: package 'ask' not found` → use `-p ask-please`.
- Fetch hangs or fails → network/GitHub reachability; re-run with
`SMOKE_OFFLINE=1` to confirm the rest of the CLI is healthy.
109 changes: 109 additions & 0 deletions .claude/skills/run-ask-rust/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
#
# Smoke driver for the Rust port of the ask CLI (crates/ask, binary `ask`).
#
# Builds the release binary (unless --no-build), then drives the full user
# flow end-to-end in an ISOLATED temp project + temp ASK_HOME:
# add(github) -> list -> fetch(network clone) -> src --json -> docs ->
# search(no-csp fallback) -> add --docs-paths -> remove -> add(npm)
# Every step asserts exit code AND a key artifact/output.
#
# Usage:
# .claude/skills/run-ask-rust/smoke.sh # build + full run
# .claude/skills/run-ask-rust/smoke.sh --no-build # reuse target/release/ask
# SMOKE_OFFLINE=1 .claude/skills/run-ask-rust/smoke.sh --no-build
# # skip network steps
set -euo pipefail
# NOTE: every assertion captures output into a variable before grep. Piping
# straight into `grep -q` under pipefail intermittently kills ask with SIGPIPE
# (grep exits on first match) and fails the step even when the output matched.

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
ASK="$ROOT/target/release/ask"
SPEC='github:tj/commander.js@v12.1.0' # small repo with a real docs/ dir

build=1
[[ "${1:-}" == "--no-build" ]] && build=0
if [[ "$build" == 1 ]]; then
echo "==> cargo build --release --locked -p ask-please"
(cd "$ROOT" && cargo build --release --locked -p ask-please)
fi
[[ -x "$ASK" ]] || { echo "FAIL: $ASK missing — build first"; exit 1; }

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export ASK_HOME="$WORK/askhome"
PROJ="$WORK/proj"
mkdir -p "$PROJ"
cd "$PROJ"

pass=0
step() { echo "==> $1"; }

Check warning on line 41 in .claude/skills/run-ask-rust/smoke.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=pleaseai_ask&issues=AZ8rO0QTBXZ8RIc5R9VC&open=AZ8rO0QTBXZ8RIc5R9VC&pullRequest=119
ok() { pass=$((pass + 1)); echo " ok"; }
fail() { echo " FAIL: $1"; exit 1; }

Check warning on line 43 in .claude/skills/run-ask-rust/smoke.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=pleaseai_ask&issues=AZ8rO0QTBXZ8RIc5R9VD&open=AZ8rO0QTBXZ8RIc5R9VD&pullRequest=119

step "version"
out="$("$ASK" --version)"
grep -Eq '^ask [0-9]' <<<"$out" || fail "version string"
ok

step "add $SPEC"
printf '%s\n' '{"name":"demo","version":"0.0.0"}' > package.json
"$ASK" add "$SPEC" || fail "add exit"
grep -q "$SPEC" ask.json || fail "spec not in ask.json"
grep -q 'BEGIN:ask-docs-auto-generated' AGENTS.md || fail "AGENTS.md marker"
[[ -f .claude/skills/commander.js-docs/SKILL.md ]] || fail "skill file"
ok

step "list"
out="$("$ASK" list 2>&1)"
grep -q 'commander.js' <<<"$out" || fail "list output"
ok

if [[ "${SMOKE_OFFLINE:-0}" != 1 ]]; then
step "fetch (network: shallow git clone)"
"$ASK" fetch "$SPEC" || fail "fetch exit"
[[ -d "$ASK_HOME/github/github.com/tj/commander.js/v12.1.0" ]] || fail "checkout dir"
ok

step "src --json"
out="$("$ASK" src "$SPEC" --json)"
grep -q '"checkoutDir"' <<<"$out" || fail "src json"
ok

step "docs (prints candidate doc paths)"
out="$("$ASK" docs "$SPEC")"
grep -q '/commander.js/v12.1.0' <<<"$out" || fail "docs path"
ok

step "search (csp absent -> path + recipe, exit 0)"
out="$(PATH=/usr/bin:/bin "$ASK" search "$SPEC" 'parse options' 2>&1)"
grep -q 'csp search' <<<"$out" || fail "search recipe"
ok

step "add --docs-paths docs (object entry)"
"$ASK" add "$SPEC" --docs-paths docs || fail "add override exit"
grep -q '"docsPaths"' ask.json || fail "docsPaths not persisted"
ok
fi

step "remove"
"$ASK" remove "$SPEC" || fail "remove exit"
grep -q '"libraries": \[\]' ask.json || fail "ask.json not emptied"
[[ ! -e .claude/skills/commander.js-docs ]] || fail "skill not removed"
ok

step "add npm:commander (version from package.json range)"
printf '%s\n' \
'{"name":"demo","version":"0.0.0","dependencies":{"commander":"^12.1.0"}}' \
> package.json
out="$("$ASK" add npm:commander 2>&1)"
grep -q 'commander@\^12.1.0' <<<"$out" || fail "npm resolve"
ok

step "cache ls --json"
out="$("$ASK" cache ls --json)"
grep -q '"askHome"' <<<"$out" || fail "cache json"
ok

echo "SMOKE PASS ($pass steps)"
70 changes: 70 additions & 0 deletions .github/workflows/release-cargo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# crates.io publish for the `ask-please` crate (track rust-port-20260704, Phase 2).
#
# Publishes the workspace crate (binary `ask`) to crates.io so `cargo install
# ask-please` works. Runs manually (workflow_dispatch, dry-run by default) and as
# a reusable workflow (workflow_call) to be invoked by release.yml on the CLI
# release_created event at cut-over.
#
# `cargo publish -p ask-please` resolves the workspace-inherited version/deps into
# concrete values in the published manifest, so no pre-flattening is needed.

name: Release (cargo)

on:
workflow_dispatch:
inputs:
dry_run:
description: 'Verify packaging without publishing (cargo publish --dry-run).'
required: false
default: true
type: boolean
workflow_call:
inputs:
dry_run:
description: 'Verify packaging without publishing.'
required: false
default: false
type: boolean
secrets:
CARGO_REGISTRY_TOKEN:
required: false

concurrency:
group: release-cargo
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Package (always) and publish (unless dry-run / already published)
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
VERSION="$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; m=json.load(sys.stdin); print(next(p["version"] for p in m["packages"] if p["name"]=="ask-please"))')"
echo "ask-please version: $VERSION"

# Always validate packaging.
cargo publish -p ask-please --locked --dry-run

if [ "$DRY_RUN" = "true" ]; then
echo "dry-run: skipping publish"
exit 0
fi

# Idempotent: skip if this version is already on crates.io (a re-run
# after a partial release must not hard-fail).
if curl -sf "https://index.crates.io/as/k-/ask-please" | grep -q "\"vers\":\"$VERSION\""; then
echo "ask-please@$VERSION already published — skipping"
exit 0
fi

cargo publish -p ask-please --locked
Loading