From 94de994380b3a96b11da50a732b91d0a912ae549 Mon Sep 17 00:00:00 2001 From: Sasha <118575614+weboko@users.noreply.github.com> Date: Thu, 14 May 2026 01:32:04 +0200 Subject: [PATCH] feat: add scaffold ai skills (#104) Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 2 +- Cargo.toml | 1 + DOGFOODING.md | 71 +++++ README.md | 2 +- skills/basecamp/SKILL.md | 175 +++++++++++ skills/lez-framework-template/SKILL.md | 232 +++++++++++++++ skills/lez-template/SKILL.md | 194 +++++++++++++ skills/lgs-cli/SKILL.md | 178 ++++++++++++ src/cli.rs | 2 +- src/commands/init.rs | 248 +++++++++------- src/commands/new.rs | 3 + src/commands/wallet_support.rs | 128 ++++---- src/template/mod.rs | 1 + src/template/skills.rs | 388 +++++++++++++++++++++++++ tests/cli.rs | 92 ++++-- 15 files changed, 1518 insertions(+), 199 deletions(-) create mode 100644 skills/basecamp/SKILL.md create mode 100644 skills/lez-framework-template/SKILL.md create mode 100644 skills/lez-template/SKILL.md create mode 100644 skills/lgs-cli/SKILL.md create mode 100644 src/template/skills.rs diff --git a/.gitignore b/.gitignore index d23a8ab..8c4da08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /target -.claude +.claude/ docs/specs/ docs/ideas/ graphify-out/ diff --git a/Cargo.toml b/Cargo.toml index 43a30cd..8647fd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ include = [ "/src/**", "/templates/**", "/templates/**/.*", + "/skills/**", "/tests/**", "/Cargo.toml", "/Cargo.lock", diff --git a/DOGFOODING.md b/DOGFOODING.md index 686d99a..a6becfa 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -82,6 +82,7 @@ The `lgs` binary is a short alias for `logos-scaffold` produced by the same crat | L4 | `lez-framework` | Core | LEZ deploy and counter interaction | `deploy`, `cargo run --bin run_lez_counter` | | E1 | N/A | Core | CLI discoverability and error quality | `--help`, `help`, `--version`, unknown commands, out-of-project errors | | E2 | N/A | Advanced | Project creation with advanced flags and invalid inputs | `new --template`, `new --vendor-deps`, `new --cache-root` | +| E3 | N/A | Core | AI skills materialized into generated and adopted projects | `new`, `new --template lez-framework`, `init`, `init` re-run | | B1 | external module project | Core | Basecamp + lgpm setup and idempotent re-run | `init`, `basecamp setup`, `basecamp doctor`, `basecamp docs` | | B2 | external module project | Core | Module capture, install, and single-instance launch | `basecamp modules`, `basecamp modules --show`, `basecamp install`, `basecamp launch alice` | | B3 | external module project | Core | Two-instance p2p dogfooding | `basecamp launch alice`, `basecamp launch bob` (parallel) | @@ -816,6 +817,75 @@ grep -n "^\[wallet\]\|^home_dir\|^binary" dogfood-cache/scaffold.toml - Clean up the generated projects after this scenario to avoid consuming disk space with multiple scaffolded projects. - The `--lez-path` flag is optional to test here because it requires a real LEZ checkout. Only probe it if one is available. +## E3. AI Skills Materialized Into Every Project + +### Goal + +Validate that `lgs new` and `lgs init` both drop the canonical AI skill set +into a generated project so that Claude Code, Cursor, and Codex pick them up +without manual configuration. Skills are version-controlled in the generated +project (no `.gitignore` exclusion). + +### Preconditions + +- Latest scaffold binary built from the repo root (`"$SCAFFOLD_BIN"`). +- Scratch workspace exists. + +### Commands / Actions + +From the scratch workspace: + +```bash +cd "$SCRATCH_ROOT" +"$SCAFFOLD_BIN" new dogfood-skills-default +"$SCAFFOLD_BIN" new dogfood-skills-lez --template lez-framework + +mkdir dogfood-skills-init && cd dogfood-skills-init +"$SCAFFOLD_BIN" init +shasum AGENTS.md .claude/skills/lgs-cli/SKILL.md .cursor/rules/lgs-cli.mdc +"$SCAFFOLD_BIN" init # re-init must succeed and not change skill content +shasum AGENTS.md .claude/skills/lgs-cli/SKILL.md .cursor/rules/lgs-cli.mdc +``` + +Inspect the generated layout in each of the three projects: + +```bash +find dogfood-skills-default/.claude/skills dogfood-skills-default/.cursor/rules -type f | sort +find dogfood-skills-lez/.claude/skills dogfood-skills-lez/.cursor/rules -type f | sort +ls dogfood-skills-default/AGENTS.md dogfood-skills-lez/AGENTS.md dogfood-skills-init/AGENTS.md +``` + +### Expected Success Signals + +- Every generated project (default template, lez-framework template, and `init`-adopted bare directory) contains exactly four `.claude/skills//SKILL.md` files: `lgs-cli`, `lez-template`, `lez-framework-template`, `basecamp`. +- The same four skills appear under `.cursor/rules/.mdc`. +- `AGENTS.md` exists at every project root, lists all four skills with their descriptions, and links to `.claude/skills//SKILL.md`. +- Re-running `init` on an already-migrated project succeeds (no longer bails) and prints `AI skills refreshed under .claude/skills/, .cursor/rules/, AGENTS.md.` The `shasum` output before and after a re-init is byte-identical for all three skill files. +- `.claude/skills//SKILL.md` is byte-identical to the canonical source under `/skills//SKILL.md` (run `diff` if validating against a built-from-source binary). +- `.cursor/rules/.mdc` frontmatter contains `description:` and `alwaysApply: false`, and does **not** contain a `name:` field. The body after the closing `---` is identical to the SKILL.md body. +- The generated `.gitignore` does not exclude `.claude/`, `.cursor/`, or `AGENTS.md`. + +### Failure Signals / Common Pitfalls + +- A skill missing from one of the three locations in any generated project is a regression — every project gets the same four-skill set per the v0.1 contract. +- A `.cursor/rules/.mdc` that still carries the `name:` line from the source SKILL.md is a regression in the frontmatter rewrite. +- A re-`init` that errors with "already at schema" is a stale build — that bail was removed when skill refresh became part of init's contract. +- A re-`init` that mutates skill content without a corresponding canonical-source change is a regression in idempotency. +- Skills appearing in `.gitignore` is a regression — they are version-controlled by design. +- Hand-edited team skills under `.claude/skills//` that get clobbered by `init` are a regression — `apply_skills` only owns the four shipped names. + +### Evidence to Capture + +- File listings under `.claude/skills/`, `.cursor/rules/`, and the existence of `AGENTS.md` for each of the three project flavors. +- One `.cursor/rules/.mdc` head excerpt showing the rewritten frontmatter. +- `AGENTS.md` excerpt showing the four-row table. +- `shasum` pairs from the re-`init` idempotency check. + +### Execution Notes + +- This scenario does not require `setup`, `localnet`, or any network access — it validates only the materialization contract. +- Pair with E2 when validating template-related changes; pair with B1 when validating `init` behavior alongside basecamp adoption. + ## B1. Basecamp Setup From a Module Project ### Goal @@ -1099,6 +1169,7 @@ ls .scaffold/basecamp/portable 2>/dev/null || find .scaffold -maxdepth 4 -name ' - Changes to LEZ template scaffolding or generated outputs: rerun `L1`, `L2`, `L3`, and `L4`. - Changes to CLI argument parsing, help text, or error messages: rerun `E1`. - Changes to `create`/`new` flags or template selection logic: rerun `E2`. +- Changes to AI skill materialization (`apply_skills`, the canonical `skills/` source, frontmatter rewrite, `AGENTS.md` template, or `init` re-run semantics): rerun `E3`. - Changes to `basecamp setup` (pin sync, lgpm build, profile seeding, idempotency) or `basecamp doctor`: rerun `B1`. - Changes to `[modules]` derivation, dependency resolution, sibling `--override-input` handling, or `basecamp install` invocation of `lgpm`: rerun `B2`. - Changes to `basecamp launch` (kill-and-scrub semantics, XDG isolation, port-override env vars, p2p surface): rerun `B3`. diff --git a/README.md b/README.md index 8cc29e1..b12ebf7 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Each subcommand documents copy-paste examples under `--help`. Global `-q` / `--q ## Command Semantics - `create` and `new` are aliases. -- `init` writes `scaffold.toml` (schema v0.2.0) with defaults into the current directory so an existing project can use the scaffold workflow. It creates `.scaffold/{state,logs}` and appends `.scaffold` to `.gitignore`. When `scaffold.toml` already exists at an older schema, `init` migrates it in place via `toml_edit` so comments, key ordering, and unrelated sections survive the rewrite — old `[basecamp].pin` / `.source` / `.lgpm_flake` move to `[repos.basecamp]` / `[repos.lgpm]`; old `[basecamp.modules.*]` move to top-level `[modules.*]`; legacy `url` fields on `[repos.{lez,spel}]` are dropped. Migrations write a `scaffold.toml.bak` next to the original by default (skip with `--no-backup`); preview either form with `--dry-run`. Already-migrated configs are refused. Run `setup` next. +- `init` writes `scaffold.toml` (schema v0.2.0) with defaults into the current directory so an existing project can use the scaffold workflow. It creates `.scaffold/{state,logs}` and appends `.scaffold` to `.gitignore`. When `scaffold.toml` already exists at an older schema, `init` migrates it in place via `toml_edit` so comments, key ordering, and unrelated sections survive the rewrite — old `[basecamp].pin` / `.source` / `.lgpm_flake` move to `[repos.basecamp]` / `[repos.lgpm]`; old `[basecamp.modules.*]` move to top-level `[modules.*]`; legacy `url` fields on `[repos.{lez,spel}]` are dropped. Migrations write a `scaffold.toml.bak` next to the original by default (skip with `--no-backup`); preview either form with `--dry-run`. Already-current configs succeed, leave `scaffold.toml` unchanged, and refresh the shipped AI skills. Run `setup` next after a fresh init or migration. - `setup` syncs LEZ and `spel` to their pinned commits (read from `[repos.lez]` / `[repos.spel]`), builds the standalone `sequencer_service`, `wallet`, and `spel` binaries locally, and seeds a deterministic default wallet from preconfigured public accounts when none is set. All binaries are project-local and are not installed to PATH — use `logos-scaffold wallet ...` / `logos-scaffold spel -- ...` to interact with them. By default `[repos.lez].path` / `[repos.spel].path` are empty in `scaffold.toml`; the on-disk location is resolved at runtime from `/repos//`, so the file is portable across machines and CI. `--vendor-deps` projects keep relative `.scaffold/repos/{lez,spel}` literals; an explicit absolute `path` set in `scaffold.toml` is honored as-is. - `build [project-path]` runs `setup` and then `cargo build --workspace`. - `deploy [program-name]` deploys one or all guest programs discovered in `methods/guest/src/bin/*.rs` using prebuilt `.bin` artifacts. After each successful submission it prints `program_id: ` (the risc0 image ID, computed locally from the submitted ELF) and includes it in `--program-path … --json` output. Use `--json` for machine-readable output (recommended for automation). diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md new file mode 100644 index 0000000..2af7152 --- /dev/null +++ b/skills/basecamp/SKILL.md @@ -0,0 +1,175 @@ +--- +name: basecamp +description: Use when running any `lgs basecamp …` subcommand or working on a Logos module project (a project that builds `.lgx` artefacts via `flake.nix#packages..lgx`). Covers `setup` / `modules` / `install` / `launch` / `build-portable` / `doctor` / `docs`, the `[basecamp.modules]` schema, per-profile XDG isolation under `.scaffold/basecamp/profiles/{alice,bob}/`, clean-slate launch semantics, two-instance p2p dogfooding, and AppImage-targeted portable builds. Activates additionally on presence of `[basecamp.modules]` in `scaffold.toml` or `.scaffold/basecamp/profiles/`. Independent of template skills. +--- + +# Basecamp Integration + +Basecamp is the runtime host for Logos `.lgx` modules — a separate process plus per-profile state, with `lgpm` as the module package manager. `lgs basecamp …` orchestrates the full lifecycle from a module project on disk: pin the basecamp + lgpm binaries, capture which modules to install, build them, install them into pre-seeded `alice` / `bob` profiles, and launch profile-isolated instances side-by-side for p2p dogfooding. For driving the `lgs` CLI itself, use the `lgs-cli` skill. + +## When to Use + +This skill activates whenever **any** of these is true: + +- The user invokes any `lgs basecamp …` subcommand. +- The user describes building a Logos module / `.lgx` / basecamp app, or working in a module project (a `flake.nix` exposing `packages..lgx`). +- `[basecamp.modules.*]` entries already exist in `scaffold.toml`. +- `.scaffold/basecamp/profiles/` already exists (basecamp setup has been run in this project before). + +Basecamp activates **independently** of `lez-template` / `lez-framework-template`. A project can be both a templated LEZ project and a basecamp-hosted module project; both skills then apply. Basecamp can also stand alone in an external module project with no LEZ template at all (the canonical case in DOGFOODING B-series — `tictactoe` and similar). + +The canonical compatibility doc — including the full `[basecamp.modules]` schema and dependency-resolution rules — is `docs/basecamp-module-requirements.md`, mirrored to consumers via `lgs basecamp docs`. Treat it as the source of truth and reference it instead of duplicating its contents. + +## Module Project Requirements (Hard Contract) + +For `lgs basecamp …` to do anything useful, the project on disk must satisfy: + +1. **`scaffold.toml` at the project root.** Run `lgs init` once if missing. +2. **`lgs basecamp setup` has been run** (one-time per project; idempotent on unchanged pin). Pins basecamp + lgpm, builds them via Nix, seeds `.scaffold/basecamp/profiles/{alice,bob}/`. +3. **At least one `flake.nix`** — at the project root or in immediate sub-directories — exposing `packages..lgx`. This is the convention from `logos-module-builder` (tag `tutorial-v1`). +4. **For `build-portable`:** the same flakes also expose `packages..lgx-portable`. There is no silent fallback from `#lgx-portable` to `#lgx` — variant choice is the user's; if `#lgx-portable` is missing, the command fails with a targeted hint. + +A flake that exposes only `#lgx-portable` and not `#lgx` fails the regular install path explicitly; opt in with `--flake #lgx-portable` on `basecamp modules` instead. + +## Command Surface + +| Command | Purpose | +|---|---| +| `lgs basecamp setup` | One-time: pin basecamp + lgpm, build, seed `alice` / `bob` profiles. Idempotent on unchanged pin. | +| `lgs basecamp modules [--show] [--flake REF]… [--path PATH]…` | **Sole writer of `[basecamp.modules.*]` in `scaffold.toml`.** Auto-discovers project sub-flakes that expose `#lgx`, or takes explicit `--flake` / `--path` sources. Resolves `metadata.json` `dependencies` recursively. Manual edits in `[basecamp.modules]` are preserved across re-runs. `--show` prints the captured set without mutating state. | +| `lgs basecamp install [--print-output]` | Build every captured source (`role = "project"` and `role = "dependency"`) and shell out to `lgpm` to install into both profiles. Logs to `.scaffold/logs/-install.log`; `--print-output` (or `LOGOS_SCAFFOLD_PRINT_OUTPUT=1`) streams nix output instead. If `[basecamp.modules]` is empty, transparently invokes `modules` in auto-discover mode first. | +| `lgs basecamp launch [--no-clean]` | Profile is `alice` or `bob`. **Default**: kills any prior `logos_host` / `logos-basecamp` descendants for that profile, scrubs the profile's XDG dirs, replays each captured source's install, sets `LOGOS_PROFILE` + `XDG_{CONFIG,DATA,CACHE}_HOME`, and `exec`s basecamp. **`--no-clean`** is the only escape hatch — skip scrub + replay, exec against existing profile state. | +| `lgs basecamp build-portable` | Build `.#lgx-portable` for `role = "project"` entries only, in dependency order (leaves first); `role = "dependency"` entries are skipped (target AppImage provides them). Symlinks artefacts into `.scaffold/basecamp/portable/-.lgx` for hand-loading via the AppImage's "install lgx" file picker. Wipes-and-recreates the portable dir each run. | +| `lgs basecamp doctor [--json]` | Basecamp-specific health: captured-modules summary, manifest variant check per profile, drift between `[basecamp.modules]` and on-disk profile state. | +| `lgs basecamp docs` | Print `docs/basecamp-module-requirements.md` (embedded at compile time). Runs **outside** a scaffold project too — useful for retrieving the contract before `lgs init`. | + +`modules` and `launch` are the two with non-obvious semantics. The other commands are mostly mechanical. + +## `[basecamp.modules]` Schema + +Each entry is a TOML sub-section keyed by `module_name`: + +```toml +[basecamp.modules.tictactoe] +flake = "path:/abs/path/to/tictactoe#lgx" +role = "project" + +[basecamp.modules.delivery_module] +flake = "github:logos-co/logos-delivery-module/#lgx" +role = "dependency" +``` + +- **`module_name` (the key)** matches the identifier other sources' `metadata.json` `dependencies` array uses to refer to this module. If `tictactoe_ui`'s manifest declares `"dependencies": ["tictactoe", "delivery_module"]`, both names must appear as keys here. +- **`role = "project"`** — a module the developer is building locally. `build-portable` attr-swaps these to `#lgx-portable`. +- **`role = "dependency"`** — a runtime companion. `install` / `launch` load them; `build-portable` skips them. + +`basecamp modules` derives `module_name` differently per source kind: + +- `path:` flake refs → read `/metadata.json.name`. Exact, no guessing. +- `.lgx` file paths → read sibling `metadata.json` if present, else fall back to filename stem with a one-line assumption note. +- `github:` / other remote refs → derive from repo slug (strip `logos-` prefix, `-` → `_`) with a one-line assumption note. Edit the TOML if the guess is wrong; re-runs are byte-identical and never overwrite existing keys. + +For each project source's declared `dependencies`, the resolution order is: already-keyed → no-op; basecamp **preinstalls** (`capability_module`, `package_manager`, `counter`, `webview_app`, and their `_ui` siblings) → silent skip; declaring source's own `flake.lock` → use the locked rev rewritten to `#lgx`; scaffold-default `BASECAMP_DEPENDENCIES` table → fallback; **unresolved** → fail fast naming the dep and the two user-side fixes (capture as a project source, or add an explicit `[basecamp.modules.]` with `role = "dependency"`). No silent drop. + +**Implication for module authors:** declare each runtime dep as a flake input in your module's `flake.nix`, even if you don't link against it — that's the cleanest way to give scaffold an authoritative pin without hitting the scaffold default. + +## Sibling Sub-Flake Overrides + +Multi-flake projects (e.g. `tictactoe` core plus `tictactoe-ui-cpp` / `tictactoe-ui-qml` siblings) need each sub-flake's `path:../` inputs to resolve against the working tree, not the locked `github:` pin. Scaffold parses each sub-flake's `flake.nix` for `.url = "path:../"` declarations and emits `--override-input path:` at both probe and build time. The input *name* used in the override comes from `flake.nix`, not the sibling directory name on disk. + +Only `path:../` inputs are rewritten. `path:./sub`, `github:`, `git+`, etc. pass through untouched. The parser is **line-level** — multi-line `inputs.x = { url = "…"; flake = false; };` declarations with `url` on its own line are not detected. Flatten such declarations to single-line form when they fail to override. + +## Profiles & XDG Isolation + +Basecamp state is **always project-local** under `/.scaffold/basecamp/`. Never user home (`~/.local/share/Logos/`, `~/Library/Application Support/Logos/`) — writes outside `.scaffold/basecamp/` are bugs. + +| Path | Purpose | +|---|---| +| `.scaffold/basecamp/profiles/alice/`, `.../bob/` | Per-profile XDG roots. `launch ` sets `XDG_{CONFIG,DATA,CACHE}_HOME` to the profile root. | +| `.scaffold/basecamp/portable/-.lgx` | Symlinks to `.#lgx-portable` builds, ordered by dependency topology. Wiped each `build-portable`. | +| `.scaffold/state/basecamp.state` | Pinned basecamp + lgpm binary paths; pin-derived metadata. | +| `.scaffold/logs/-setup-*.log`, `-install.log` | Build logs. | + +Each `launch` also sets `LOGOS_PROFILE=` for child processes. The two-instance dogfooding flow (B3) relies on this isolation: `alice` and `bob` running in parallel see independent identity keys, message history, and storage. + +## Clean-Slate Semantics (B4 Guard) + +`lgs basecamp launch alice` (default, no `--no-clean`): + +1. Kill any prior `logos_host` / `logos-basecamp` descendants for the alice profile. +2. `rm -rf` the profile's XDG dirs — **strictly bounded to** `/.scaffold/basecamp/profiles/alice/`. A `launch` that scrubs anything outside that root is a severe safety regression. +3. Replay every captured source's install (build → `lgpm install`) into the freshly-scrubbed profile. +4. `exec` basecamp with the profile env set. + +`--no-clean` is the only escape hatch — skip steps 1–3, exec against whatever is on disk. + +**Empty `[basecamp.modules]` + default `launch`** intentionally **bails before scrubbing** (the regression guard from `fix(basecamp): bail on empty [basecamp.modules] in launch without --no-clean`). The empty-install + scrubbed-profile combo is precisely what the bail prevents. To launch with no modules (rare, mostly for inspecting basecamp itself), pass `--no-clean`. + +## Two-Instance P2P Dogfooding (B3) + +The canonical basecamp use case. Two terminals, both rooted at the module project: + +```bash +# Terminal 1 +lgs basecamp launch alice + +# Terminal 2 +lgs basecamp launch bob +``` + +Each window opens against its own `.scaffold/basecamp/profiles/{alice,bob}/`, with `LOGOS_PROFILE` set respectively. Per-profile port-override env vars are set on `launch` to avoid collision on Qt remote-objects and similar non-module ports. A p2p interaction (chat, delivery, storage) triggered from `alice` should be observable in `bob` within the module's expected latency. + +If two windows open but share identity keys / message history, that is a clean-slate regression — capture it. A non-module port collision (Qt remote objects, etc.) is an upstream finding against the colliding component, not something to patch around in scaffold. Running two `launch alice` invocations in parallel is undefined in v1. + +## `build-portable` (B5) + +Targets the AppImage release path: build `.#lgx-portable` artefacts that can be hand-loaded via the AppImage's "install lgx" file picker, not loaded into the scaffold-managed profiles. + +Behavior: builds **only** `role = "project"` entries (the dev's local modules), in topological dependency order so basecamp can resolve each module's deps before loading it. `role = "dependency"` entries are skipped — the target AppImage provides its own copies. Symlinks land in `.scaffold/basecamp/portable/` as `-.lgx` (`NN` is the load-order index). The directory is wiped-and-recreated each run, so removing a module via `basecamp modules` doesn't leave stale symlinks. + +Silent fallback from `#lgx-portable` to `#lgx` is a contract violation — the variant choice belongs to the user. Missing `#lgx-portable` attribute → fail with a targeted hint naming the missing attr. + +## Common Errors + +| Symptom | Root cause | Fix | +|---|---|---| +| `basecamp not set up yet` hint on `install` / `launch` | `lgs basecamp setup` never ran in this project. | `lgs basecamp setup` (one-time). | +| `basecamp install` fails inside `nix build` with `no 'main' field in metadata.json` | A sub-flake transitively pulls a newer `logos-module-builder` (typically off `main`) that's incompatible with `basecamp v0.1.1`. The stale entry silently wins through the sub-flake's `flake.lock`. | In the offending sub-flake's `flake.nix`: add `inputs..inputs.logos-module-builder.follows = "logos-module-builder";` for each dep that itself declares `logos-module-builder`, then `nix flake update`. Verify only one `logos-module-builder` node remains in `flake.lock`. | +| `basecamp modules` fails with an unresolved-dep error | A `metadata.json` `dependencies` entry isn't already keyed in `[basecamp.modules]`, isn't a basecamp preinstall, isn't in the source's `flake.lock`, and isn't in the scaffold default table. | Either add the dep as a flake input in the source's `flake.nix` (preferred — gives an authoritative pin), or hand-add `[basecamp.modules.]` with `flake = "#lgx"` and `role = "dependency"`. | +| `basecamp install` succeeds but `doctor` flags drift | `[basecamp.modules]` was edited or sources changed without a re-install. | `lgs basecamp install`. | +| Flake exposes only `#lgx-portable` and not `#lgx` | Project author opted into portable-only output. | `lgs basecamp modules --flake #lgx-portable` to opt in explicitly; or expose `#lgx` upstream. | +| `build-portable` fails with missing `#lgx-portable` attr | A captured `role = "project"` flake doesn't expose the portable variant. | Add the `lgx-portable` output to that flake, or remove the entry from `[basecamp.modules]` if not actually a project source. | +| Sibling `path:../` inputs not overridden | Multi-line `inputs. = { url = "…"; … };` declaration with `url` on its own line. The line-level parser doesn't detect it. | Flatten to single-line `.url = "path:../";`. | +| Auto-discovered `module_name` is wrong (e.g. for `github:` refs) | Heuristic-derived from repo slug. | Edit the entry directly in `scaffold.toml` — `basecamp modules` is idempotent and never overwrites existing keys. | +| `basecamp launch` writes outside `.scaffold/basecamp/profiles//` | Severe regression. | Stop and capture the offending path before continuing. Do not retry. | + +## Diagnostics & Logs + +- `lgs basecamp doctor` (+ `--json`) — captured-modules summary, manifest variant check per seeded profile, drift between `[basecamp.modules]` and on-disk profile state. +- `.scaffold/logs/-setup-*.log` — `basecamp setup` build logs. +- `.scaffold/logs/-install.log` — `basecamp install` per-source nix build logs (one file per run). +- `--print-output` flag on `install` (or `LOGOS_SCAFFOLD_PRINT_OUTPUT=1` env) — stream nix output directly to the terminal instead of writing to a log file. Useful for CI where you want the full transcript in stdout. +- `lgs report --tail 500` (general scaffold report) — bundles relevant `.scaffold/logs/` and state for issue reports. Always inspect the archive (`tar -tzf `) before sharing publicly. + +## DOGFOODING Cross-Reference + +Canonical scenarios in `DOGFOODING.md`: + +- **B1** — basecamp + lgpm setup and idempotent re-run. +- **B2** — module capture, install, single-instance launch (`alice`). +- **B3** — two-instance p2p (`alice` + `bob` parallel terminals). +- **B4** — clean-slate scrub semantics on relaunch; `--no-clean` escape hatch; empty-`[basecamp.modules]` bail guard. +- **B5** — `build-portable` artefact production for AppImage hand-loading. + +When reproducing a basecamp failure, name the matching scenario. + +## Key Rules + +- **Never edit `[basecamp.modules]` while `lgs basecamp modules` is running.** Outside that window, hand-edits are preserved across re-runs — manual entries always win over derived pins. +- **Basecamp state is always project-local under `.scaffold/basecamp/`.** Writes to `~/.local/share/Logos/` or `~/Library/Application Support/Logos/` are bugs. +- **Nothing basecamp builds lands on `PATH`.** `lgs` invokes the project-local binaries directly via `.scaffold/state/basecamp.state`. If `lgpm` or `basecamp` ends up on PATH, that's a regression. +- **`basecamp setup` is idempotent on unchanged pin.** A re-run that rebuilds when the pin hasn't changed is a regression. +- **`build-portable` is the only path that touches `#lgx-portable`.** `install` / `launch` always use `#lgx`. Silent variant fallback in either direction is a contract violation. +- **Module authors should declare runtime deps as flake inputs**, even non-link ones — gives scaffold an authoritative pin via step 3 of dep resolution. +- **`logos-module-builder` `follows` wiring is mandatory** in any sub-flake that pulls in a module which itself depends on `logos-module-builder`. Missing `follows` is the single most common `install` failure mode. +- **`lgs basecamp docs` runs outside a scaffold project.** Use it to retrieve the compatibility contract before `lgs init` when bootstrapping a new module project. diff --git a/skills/lez-framework-template/SKILL.md b/skills/lez-framework-template/SKILL.md new file mode 100644 index 0000000..a2bc4dc --- /dev/null +++ b/skills/lez-framework-template/SKILL.md @@ -0,0 +1,232 @@ +--- +name: lez-framework-template +description: Use when working inside a project scaffolded with scaffold's lez-framework template — Anchor-on-Solana parallel for LEZ with #[lez_program] / #[instruction] / #[account(...)] macros and auto-generated IDL. Identify by scaffold.toml framework = "lez-framework", crates/lez-client-gen/, idl/, and #[lez_program] in the source. +--- + +# LEZ-Framework Template Development + +This skill activates when the agent is working *inside* a project scaffolded with `lgs new --template lez-framework`. The template uses [LEZ Framework](https://github.com/jimmy-claw/lez-framework) for an ergonomic developer experience similar to Anchor on Solana. For driving the `lgs` CLI itself, use the `lgs-cli` skill. + +## When to Use + +Identify a lez-framework project by **all** of: + +- `scaffold.toml` contains `framework = "lez-framework"`. +- `crates/lez-client-gen/` exists (host-side client generator crate). +- `idl/` directory exists with one `.json` per program (e.g. `idl/lez_counter.json`). +- `methods/guest/src/bin/.rs` and `src/bin/run_.rs` exist (same convention as the default template). +- `src/lib.rs` declares `#[lez_program] mod { ... }` with `#[instruction]` handlers. + +If any of these are missing (especially `framework = "lez-framework"` in `scaffold.toml`), switch to `lez-template` instead. + +## Why This Template Exists + +The `default` template is the bare LEZ standalone surface: you write everything (instruction dispatch, account derivation, IDL by hand, client stubs by hand). The `lez-framework` template is a declarative wrapper that: + +- Eliminates instruction-dispatch boilerplate (`#[lez_program]` / `#[instruction]`). +- Annotates account constraints and PDA derivation declaratively (`#[account(…)]`). +- Generates IDL JSON at compile time (exposed as `PROGRAM_IDL_JSON` and persisted under `idl/`). +- Generates host-side client bindings under `src/generated/` from the IDL. + +It's the right choice when you'd otherwise be writing repetitive boilerplate. Drop down to `default` only for primitives the framework hasn't surfaced or for extreme guest-binary size constraints. + +## What This Template Produces + +File-tree highlights from `templates/lez-framework/`: + +``` +/ +├── Cargo.toml # main workspace; includes crates/ +├── rust-toolchain.toml +├── scaffold.toml # framework = "lez-framework" +├── .scaffold/ +├── src/ +│ ├── lib.rs # #[lez_program] mod ... + runner_support +│ ├── generated/ # client bindings (output of `lgs build client`) +│ └── bin/ +│ └── run_lez_counter.rs # host-side runner with init/increment subcommands +├── crates/ +│ └── lez-client-gen/ # host crate that turns idl/*.json → src/generated/ +├── idl/ +│ └── lez_counter.json # auto-generated IDL (don't hand-edit) +└── methods/ + └── guest/src/bin/lez_counter.rs # risc0 guest binary +``` + +## Macro Vocabulary + +From `templates/lez-framework/src/lib.rs` — the `lez_counter` reference example: + +```rust +use lez_framework::prelude::*; +use lez_framework::error::{LezError, LezResult}; +use lez_framework_core::types::LezOutput; +use nssa_core::program::AccountPostState; +use nssa_core::account::AccountWithMetadata; + +#[lez_program] +mod lez_counter { + #[allow(unused_imports)] + use super::*; + + #[instruction] + pub fn initialize( + #[account(init, pda = literal("counter"))] + counter: AccountWithMetadata, + #[account(signer)] + authority: AccountWithMetadata, + ) -> LezResult { + Ok(LezOutput::states_only(vec![ + AccountPostState::new_claimed(counter.account.clone()), + AccountPostState::new(authority.account.clone()), + ])) + } + + #[instruction] + pub fn increment( + #[account(mut, pda = literal("counter"))] + counter: AccountWithMetadata, + #[account(signer)] + authority: AccountWithMetadata, + amount: u64, + ) -> LezResult { + let mut counter_post = counter.account.clone(); + counter_post.balance += amount as u128; + + Ok(LezOutput::states_only(vec![ + AccountPostState::new(counter_post), + AccountPostState::new(authority.account.clone()), + ])) + } +} +``` + +| Annotation | Generates | +|---|---| +| `#[lez_program] mod { … }` | Program-level scaffolding: instruction enum, dispatch, IDL constant `PROGRAM_IDL_JSON`. The mod name **is** the program name (must match `methods/guest/src/bin/.rs`). | +| `#[instruction] pub fn (…)` | Instruction enum variant with PascalCase name (`initialize` → `Initialize`); discriminator computed from the name; argument schema (non-account params) is the variant payload. | +| `#[account(init, pda = literal(""))]` | Account claimed (zero-balance, default visibility) at the literal PDA; `claim_if_default = true`. | +| `#[account(mut, pda = literal(""))]` | Existing PDA account; mutable; `claim_if_default = false`. | +| `#[account(signer)]` | Authority account; `auth = true`. | +| `LezResult` / `Ok(LezOutput::states_only(vec![…]))` | Return type for instruction handlers. Use `AccountPostState::new(…)` for unchanged authorities and `AccountPostState::new_claimed(…)` when claiming a freshly-derived account. | + +Non-account function parameters (e.g. `amount: u64` on `increment`) become args in the IDL. + +## IDL Pipeline + +The IDL is the contract between the program and any client. It's written to `idl/.json` at compile time (visible in tests via `PROGRAM_IDL_JSON`). Schema (from `idl/lez_counter.json`): + +```json +{ + "spec": "lssa-idl/0.1.0", + "metadata": { "name": "lez_counter", "version": "0.1.0" }, + "program": { "name": "lez_counter" }, + "instructions": [ + { + "name": "initialize", + "variant": "Initialize", + "discriminator": [220, 59, 207, 236, 108, 250, 47, 100], + "accounts": [ + { "name": "counter", "ty": "AccountWithMetadata", + "auth": false, "claim_if_default": true, "mutable": false, "visibility": ["public"] }, + { "name": "authority", "ty": "AccountWithMetadata", + "auth": true, "claim_if_default": false, "mutable": false, "visibility": ["public"] } + ], + "args": [], + "execution": { "private_owned": false, "public": true } + } + ], + "errors": [], + "types": [] +} +``` + +Regenerate the IDL whenever the program surface changes: + +```bash +lgs build idl # writes idl/.json from the macro-extracted PROGRAM_IDL_JSON +``` + +Look for `Wrote IDL ...` lines in the command output to confirm regeneration. Missing markers or empty IDL is a regression. + +## Client Generation + +```bash +lgs build client # regenerates IDL first, then runs crates/lez-client-gen + # to produce host-side bindings under src/generated/ +``` + +Client artefacts under `src/generated/` reflect the current contents of `idl/`. Don't hand-edit them — treat the macro layer as the source of truth and regenerate. + +## Build / Deploy + +For the LEZ template, `lgs build` automatically runs IDL regeneration and client generation as part of the pipeline (per DOGFOODING scenario L1). You usually don't need to call `build idl` / `build client` manually except when iterating on IDL alone. + +```bash +lgs setup +lgs localnet start +lgs build # cargo build + IDL regen + client gen +lgs deploy # auto-discovers methods/guest/src/bin/lez_counter.rs +``` + +## Reference Example: Running `lez_counter` + +The runner at `src/bin/run_lez_counter.rs` exposes `init` and `increment` subcommands: + +```bash +export NSSA_WALLET_HOME_DIR="$(pwd)/.scaffold/wallet" +lgs wallet -- account new public # capture the base58 account id + +cargo run --bin run_lez_counter -- init --to +cargo run --bin run_lez_counter -- increment --counter --authority --amount 5 +``` + +> **Caveat (per DOGFOODING scenario L4):** as of writing, the `run_lez_counter` runner contains `TODO` placeholders for actual transaction submission. Don't be surprised if subcommands accept input but only print diagnostic messages without submitting. When transaction submission is implemented, follow the same `verification hint:` pattern as default-template runners (`lgs wallet -- account get --account-id `). + +## Differences vs. `default` Template + +| Concern | `default` | `lez-framework` | +|---|---|---| +| Instruction dispatch | hand-written | generated by `#[lez_program]` | +| Account derivation | hand-written | declarative `#[account(…)]` | +| IDL | none | auto-generated under `idl/` | +| Client bindings | hand-written runners | generated under `src/generated/` from IDL | +| Workspace | excludes `methods/` | includes `crates/`; `methods/` still its own crate | +| `lgs build` | cargo build + auto-build `methods/` | adds IDL regen + client gen | +| Recommended for | low-level / size-critical zk programs | most projects (less boilerplate, type-safe clients) | + +## Common Gotchas + +- **Mod name must match the guest binary.** `#[lez_program] mod lez_counter` requires `methods/guest/src/bin/lez_counter.rs`. Renaming one without the other breaks `lgs deploy` discovery. +- **Don't hand-edit `idl/*.json`** — the next `lgs build` (or `lgs build idl`) overwrites it from the macro-derived `PROGRAM_IDL_JSON`. Edit the source instead and regenerate. +- **Don't hand-edit `src/generated/`** — regenerated by `lgs build client`. +- **Account derivation order matters.** `#[account(init, pda = …)]` accounts that depend on others should appear after their inputs in the handler signature. +- **Visibility defaults to `public`.** If you need private execution, configure `execution.private_owned = true` (currently driven by macro inputs not shown in the counter example). +- **`crates/` workspace members.** If `Cargo.toml` doesn't include `crates/lez-client-gen`, `lgs build client` will not find the generator. Check the workspace `members` array. + +## Adding a New Instruction + +1. Add a `#[instruction] pub fn (…) -> LezResult { … }` inside the `#[lez_program]` mod in `src/lib.rs`. Mirror the account-annotation patterns from `initialize` / `increment`. +2. `lgs build idl` to regenerate `idl/.json`. Verify the new instruction appears with the expected `accounts` / `args` / `discriminator`. +3. `lgs build client` to regenerate host-side bindings. +4. Extend `src/bin/run_.rs` with a new subcommand that uses the regenerated client. +5. Test: `lgs build && lgs deploy && cargo run --bin run_ -- …`. + +## Adding a New Program + +A project can host multiple `#[lez_program]` modules, each one its own program with its own guest binary: + +1. New `methods/guest/src/bin/.rs`. +2. New `#[lez_program] mod { … }` in `src/lib.rs` (or a sibling `.rs` file pulled in via `mod`). +3. New `src/bin/run_.rs`. +4. `lgs build` regenerates IDL + clients across all programs; `lgs deploy` discovers the new one automatically. + +## Key Rules + +- **The `#[lez_program] mod` name is the program name.** Keep it in lockstep with `methods/guest/src/bin/.rs`. +- **`idl/` and `src/generated/` are derived.** Never hand-edit them; regenerate via `lgs build idl` / `lgs build client`. +- **Account annotations are the contract.** `pda = literal(…)`, `init`, `mut`, `signer` flags drive both the IDL and runtime behavior. +- **Use `LezOutput::states_only(…)`** as the default success return; reach for richer variants only when needed. +- **Run `cargo test` to dump the IDL** — the test `__lssa_idl_print` prints `PROGRAM_IDL_JSON` between `--- LSSA IDL BEGIN/END ---` markers, useful for debugging IDL regressions. +- **Don't bypass the framework for ad-hoc dispatch.** If you find yourself writing manual instruction matching, drop down to the `default` template instead. +- **`NSSA_WALLET_HOME_DIR` is required for direct `cargo run`.** Same as the default template. \ No newline at end of file diff --git a/skills/lez-template/SKILL.md b/skills/lez-template/SKILL.md new file mode 100644 index 0000000..4eae9cd --- /dev/null +++ b/skills/lez-template/SKILL.md @@ -0,0 +1,194 @@ +--- +name: lez-template +description: Use when working inside a project scaffolded with the bare LEZ template (`lgs new` default — raw Rust + risc0 guest programs, no framework macros). Identify by scaffold.toml without `framework = "lez-framework"`, presence of `methods/guest/src/bin/*.rs`, and absence of `idl/` + `crates/lez-client-gen/`. +--- + +# Bare LEZ Template (`lgs new` Default) + +This skill activates when the agent is working *inside* a project scaffolded with `lgs new ` (or `lgs new --template default`). It is the bare LEZ standalone template: a raw Rust workspace plus a risc0 guest crate, no macros. For driving the `lgs` CLI itself, use the `lgs-cli` skill. + +## When to Use + +Identify a default-template project by **all** of: + +- `scaffold.toml` exists at the project root and **does not** contain `framework = "lez-framework"`. +- `methods/guest/src/bin/*.rs` exists — one file per risc0 guest program. +- The project does **not** have `crates/lez-client-gen/` or `idl/` directories (those are lez-framework-specific). +- `src/lib.rs` defines a `runner_support` module with `parse_account_id` / `load_program` helpers (see `templates/default/src/lib.rs`). + +If the project has `crates/lez-client-gen/` and `idl/`, switch to `lez-framework-template` instead. + +## What This Template Produces + +File-tree highlights from `templates/default/` (paths relative to the project root): + +``` +/ +├── Cargo.toml # workspace; usually excludes methods/ from members +├── rust-toolchain.toml +├── .env.local # local env defaults +├── scaffold.toml # written by `lgs new` / `lgs init` +├── .scaffold/ # state, logs, wallet/, repos/, reports/ +│ └── commands.md # canned reference (verbatim below) +├── src/ +│ ├── lib.rs # `runner_support` helpers +│ └── bin/ # example runners (host-side clients) +│ ├── run_hello_world.rs +│ ├── run_hello_world_private.rs +│ ├── run_hello_world_with_authorization.rs +│ ├── run_hello_world_with_move_function.rs +│ ├── run_hello_world_through_tail_call.rs +│ ├── run_hello_world_through_tail_call_private.rs +│ └── run_hello_world_with_authorization_through_tail_call_with_pda.rs +└── methods/ # risc0 guest crate (excluded from main workspace) + └── guest/ + └── src/bin/.rs +``` + +The parent `Cargo.toml` typically excludes `methods/` from workspace members so the guest crate can build with its own toolchain. `lgs build` knows about this and auto-compiles `methods/Cargo.toml` when present. + +## Risc0 Guest Discovery Convention + +`lgs deploy` (and the deploy auto-discovery code path) walks `methods/guest/src/bin/*.rs` to enumerate programs. The basename of each `.rs` file is the program name. For each, the corresponding compiled binary lives at: + +``` +target/riscv-guest/example_program_deployment_methods/example_program_deployment_programs/riscv32im-risc0-zkvm-elf/release/.bin +``` + +The `EXAMPLE_PROGRAMS_BUILD_DIR` env var conventionally captures this absolute path so example runners can pass `--program-path "$EXAMPLE_PROGRAMS_BUILD_DIR/.bin"` for non-default builds. + +**One file = one program.** Adding a new program is `methods/guest/src/bin/.rs` + a host-side runner in `src/bin/run_.rs`. + +## Build Pipeline + +```bash +lgs build # runs `setup` first, then cargo build --workspace + # auto-compiles methods/Cargo.toml if present +lgs build my-project # explicit project path +``` + +Key behavior (FURPS Functionality #5): `build` auto-compiles `methods/Cargo.toml` even when the parent workspace excludes the guest crate. No manual `cargo build --manifest-path methods/Cargo.toml` needed. + +Success criteria: `methods/target/.../release/.bin` artefacts exist for every guest in `methods/guest/src/bin/`. + +## Deploy Pipeline + +```bash +lgs deploy # auto-discover all guest programs +lgs deploy hello_world # deploy a single program by name +lgs deploy --program-path "" # explicit path; works without methods/ +lgs deploy --program-path "" --json +``` + +`lgs deploy` prints `program_id: ` (the risc0 image ID, computed locally from the submitted ELF) on every successful submission. + +JSON output shapes (FURPS Functionality #9): + +- `--program-path … --json` — bare object: `{"status":"submitted","program":...,"tx"?:...,"program_id"?:...}`. Absent values are omitted, not `null`. +- Auto-discovery `--json` — silently accepted but ignored. (Use `lgs deploy --program-path` if you need JSON for a single program.) + +Failure modes: + +- Unknown program name → lists discovered programs. +- Missing binary → points back at `lgs build`. +- Localnet unreachable → sequencer-unavailable hint, not a vague wallet error. + +## Example Runners + +The template ships seven host-side runners under `src/bin/`. Each uses `runner_support::parse_account_id` and `runner_support::load_program` from `src/lib.rs`. They expect `NSSA_WALLET_HOME_DIR` to be set when invoked directly via `cargo run` (scaffold `wallet --` passthrough sets it automatically; direct `cargo run` does not). + +```bash +export NSSA_WALLET_HOME_DIR="$(pwd)/.scaffold/wallet" + +cargo run --bin run_hello_world -- +cargo run --bin run_hello_world_private -- +lgs wallet -- account sync-private # after private-account writes + +cargo run --bin run_hello_world_with_authorization -- + +cargo run --bin run_hello_world_with_move_function -- write-public "" +cargo run --bin run_hello_world_with_move_function -- write-private "" +cargo run --bin run_hello_world_with_move_function -- move-data-public-to-private + +cargo run --bin run_hello_world_through_tail_call -- +cargo run --bin run_hello_world_through_tail_call_private -- + +cargo run --bin run_hello_world_with_authorization_through_tail_call_with_pda +``` + +Each runner prints, on success: + +``` +submitted transaction: status=<...> tx_hash=<...> +verification hint: lgs wallet -- account get --account-id +``` + +Optional path overrides for custom builds: + +```bash +export EXAMPLE_PROGRAMS_BUILD_DIR="$(pwd)/target/riscv-guest/example_program_deployment_methods/example_program_deployment_programs/riscv32im-risc0-zkvm-elf/release" + +cargo run --bin run_hello_world -- \ + --program-path "$EXAMPLE_PROGRAMS_BUILD_DIR/hello_world.bin" \ + + +cargo run --bin run_hello_world_through_tail_call_private -- \ + --simple-tail-call-path "$EXAMPLE_PROGRAMS_BUILD_DIR/simple_tail_call.bin" \ + --hello-world-path "$EXAMPLE_PROGRAMS_BUILD_DIR/hello_world.bin" \ + +``` + +## Iteration Loop + +1. Edit guest at `methods/guest/src/bin/.rs`. +2. `lgs build` (auto-compiles guest crate). +3. `lgs deploy [program]` to push the new ELF; capture the printed `program_id`. +4. Edit (or add) the host runner at `src/bin/run_.rs`. +5. `cargo run --bin run_ -- ` to invoke against the running localnet. +6. `lgs wallet -- account get --account-id ` to verify state mutation. + +If you hit the wallet `from_env()` panic on direct `cargo run`, you forgot `export NSSA_WALLET_HOME_DIR="$(pwd)/.scaffold/wallet"`. + +## Account Creation + +```bash +lgs wallet -- account new public # → "Public/" +lgs wallet -- account new private # → "Private/" +lgs wallet -- account list +lgs wallet -- account get --account-id +lgs wallet -- account sync-private # after private-account writes +``` + +Runners take the **base58 portion** of the account ID as the positional arg, not the `Public/` or `Private/` prefix. `runner_support::parse_account_id` strips the prefix automatically, so passing the full `Public/` form also works. + +## `.scaffold/commands.md` Quick Reference + +Verbatim from `templates/default/.scaffold/commands.md` (shipped into every default-template project): + +```markdown +# Command References + +- standalone sequencer: `RUST_LOG=info target/release/sequencer_service sequencer/service/configs/debug/sequencer_config.json` +- lez standalone docs: `https://github.com/logos-blockchain/logos-execution-zone/tree/main?tab=readme-ov-file#standalone-mode` +- wallet commands: `logos-scaffold wallet -- ` +- localnet json status: `logos-scaffold localnet status --json` +- doctor json status: `logos-scaffold doctor --json` +- diagnostics bundle for issue reports: `logos-scaffold report --tail 500` +``` + +## When to Switch Templates + +If you find yourself writing repetitive instruction-dispatch + account-derivation boilerplate by hand, consider the LEZ Framework template (`lgs new --template lez-framework`), which adds Anchor-style `#[lez_program]` / `#[instruction]` / `#[account(…)]` macros and auto-generates IDL JSON. See the `lez-framework-template` skill. + +Drop down to this `default` template when you need primitives the framework hasn't surfaced or when guest-binary size is critical. + +## Key Rules + +- **One guest program per `methods/guest/src/bin/.rs`.** The basename is the program name `lgs deploy` recognises. +- **One host runner per program, named `src/bin/run_.rs`.** Reuse `runner_support::parse_account_id` and `runner_support::load_program`. +- **`NSSA_WALLET_HOME_DIR` must be set for direct `cargo run`.** Use `export NSSA_WALLET_HOME_DIR="$(pwd)/.scaffold/wallet"` once per shell. +- **Account IDs are passed as CLI args**, never hardcoded. Use `lgs wallet -- account new {public,private}` to create fresh ones. +- **Don't add Qt / UI / QML deps.** This template is for zk programs; UI work belongs in a separate Logos module project (different repo, different toolchain). +- **Parent `Cargo.toml` should keep `methods/` excluded** from workspace members; `lgs build` handles its compilation separately. +- **Don't hand-edit `target/` or `.scaffold/`**. Treat them as generated output. +- **JSON deploys require `--program-path`.** Discovery-path `--json` is silently accepted; if you need structured output, deploy one program at a time. \ No newline at end of file diff --git a/skills/lgs-cli/SKILL.md b/skills/lgs-cli/SKILL.md new file mode 100644 index 0000000..0d9d5eb --- /dev/null +++ b/skills/lgs-cli/SKILL.md @@ -0,0 +1,178 @@ +--- +name: lgs-cli +description: Use for the `lgs` / `logos-scaffold` CLI as a whole — bootstrap a project, run setup/build/deploy/localnet/wallet/doctor/report, diagnose CLI errors, or adopt scaffold in an existing project. Covers the full CLI surface, the `.scaffold/` state layout, and error → recovery patterns. Entry point that routes into `lez-template`, `lez-framework-template`, or `basecamp` once project context is identified. +--- + +# Using `logos-scaffold` + +`logos-scaffold` (alias `lgs` — functionally identical) is a Rust CLI for bootstrapping LEZ (Logos Execution Zone) `program_deployment` projects in standalone mode. This skill is the entry point any time the user asks Claude to use scaffold itself — either to create / drive a project, or to recover from a failure. + +## When to Use + +- The user wants to create a new LEZ project (`lgs new`, `lgs create`). +- The user wants to run any `lgs` / `logos-scaffold` subcommand against an existing project (setup, build, deploy, localnet, wallet, spel, basecamp, doctor, report). +- A scaffold command failed and needs diagnosis (use the playbook + error table below). +- The user wants to adopt scaffold in an existing Rust/LEZ project (`lgs init`) or migrate an older `scaffold.toml`. + +Once a project exists on disk, also pull in the matching template / integration skill: + +- `lez-template` — bare LEZ standalone (Rust + risc0). Identify by absence of `framework = "lez-framework"` in `scaffold.toml` and presence of `methods/guest/src/bin/*.rs`. +- `lez-framework-template` — declarative macros (Anchor parallel). Identify by `framework = "lez-framework"` in `scaffold.toml` plus `crates/lez-client-gen/` and `idl/`. +- `basecamp` — `lgs basecamp …` lifecycle for Logos module projects (capture / install / launch with profile-isolated state). Activates additionally on any `lgs basecamp` invocation, presence of `[basecamp.modules]` in `scaffold.toml`, or `.scaffold/basecamp/profiles/`. Independent of the template skills — can layer onto either template or stand alone in an external module project. + +## Command Map + +`lgs` and `logos-scaffold` are interchangeable. Group by purpose: + +| Group | Command | Purpose | +|---|---|---| +| Project | `new ` / `create ` | Scaffold a new project. Flags: `--template {default,lez-framework}`, `--vendor-deps`, `--lez-path`, `--cache-root`. | +| Project | `init` | Adopt scaffold in an existing project (writes `scaffold.toml`, creates `.scaffold/`, appends to `.gitignore`). Re-run to migrate older schemas or refresh shipped AI skills in place. | +| Project | `setup` | Sync LEZ + spel to pinned commits, build `sequencer_service` / `wallet` / `spel` locally, seed default wallet. Project-local; no PATH installs. | +| Project | `build [project-path]` | Runs `setup` then `cargo build --workspace`; auto-compiles `methods/Cargo.toml` if present. | +| Project | `deploy [program-name]` | Deploys one or all guest programs discovered in `methods/guest/src/bin/*.rs`. Prints `program_id` (risc0 image ID) on success. `--json` only structured when combined with `--program-path`. | +| Runtime | `localnet start [--timeout-sec N]` | Spawn sequencer; waits for pid alive + 127.0.0.1:3040 reachable. | +| Runtime | `localnet stop` | Stop tracked sequencer. | +| Runtime | `localnet status [--json]` | Distinguishes managed / stale / foreign listener. | +| Runtime | `localnet logs [--tail N]` | Tail `.scaffold/logs/sequencer.log`. | +| Runtime | `wallet list [--long]` | List known wallet accounts. | +| Runtime | `wallet topup [
] [--dry-run]` | Auth-transfer init (if needed) + Piñata claim. Uses project default if address omitted. | +| Runtime | `wallet default set ` | Persist project default wallet to `.scaffold/state/wallet.state`. | +| Runtime | `wallet -- ` | Raw passthrough to project-local wallet binary; preserves project wallet env. | +| Runtime | `spel -- ` | Raw passthrough to project-vendored `spel` binary. | +| Modules | `basecamp setup` | One-time: pin basecamp + lgpm, build, seed `alice` / `bob` profiles. | +| Modules | `basecamp modules [--show] [--flake REF]… [--path PATH]…` | Sole writer of `[basecamp.modules.]` in `scaffold.toml`. | +| Modules | `basecamp install [--print-output]` | Build captured sources and install via `lgpm` into both profiles. | +| Modules | `basecamp launch [--no-clean]` | Scrub profile, replay modules, exec basecamp. Profiles: `alice`, `bob`. | +| Modules | `basecamp build-portable` | Build `.#lgx-portable` for `role = "project"` entries; symlink under `.scaffold/basecamp/portable/`. | +| Modules | `basecamp doctor [--json]` | Basecamp-specific health (modules, variant check, dep drift, discovery drift). | +| Modules | `basecamp docs` | Print canonical `docs/basecamp-module-requirements.md`. | +| Diagnostics | `doctor [--json]` | Top-level health checks + actionable next steps. | +| Diagnostics | `report [--out PATH] [--tail N]` | Sanitised `.tar.gz` diagnostics bundle. | +| System | `completions ` | Print shell completion script (covers both `lgs` and `logos-scaffold`). | +| System | `help` / ` --help` | Help. Safe — `--help` does not create files. | + +## First Success Path + +From a scratch directory (per `templates/default/README.md` and DOGFOODING.md scenario D1): + +```bash +lgs new my-app +cd my-app +lgs setup +lgs localnet start +lgs build +lgs deploy +lgs wallet topup +lgs wallet -- check-health +``` + +Checkpoint at any time: + +```bash +lgs localnet status +lgs doctor +``` + +## Adopting an Existing Project + +```bash +cd my-existing-project +lgs init # writes scaffold.toml, creates .scaffold/, appends .gitignore +lgs setup +``` + +`init` does not touch `Cargo.toml` or `src/`. Re-run `init` to migrate older `scaffold.toml` schemas (legacy `[basecamp]` keys move to `[repos.basecamp]` / `[modules.*]`; legacy `url` on `[repos.{lez,spel}]` is dropped) or to refresh the shipped AI skills. Already-current configs succeed and leave `scaffold.toml` unchanged. + +## `.scaffold/` Layout + +Everything scaffold writes lives under `.scaffold/` inside the project. Treat this directory as the source of truth for runtime state, not the user's home. + +| Path | Purpose | +|---|---| +| `.scaffold/state/localnet.state` | Sequencer PID. Stale entries are how `localnet status` reports `ownership: stale_state`. | +| `.scaffold/state/wallet.state` | Project default wallet address. Excluded from `report` archives. | +| `.scaffold/state/basecamp.state` | Basecamp + lgpm binary paths and pin-derived metadata. | +| `.scaffold/logs/sequencer.log` | Sequencer stdout/stderr. Tail with `lgs localnet logs --tail N`. | +| `.scaffold/logs/-install.log` | `basecamp install` per-source nix build logs. | +| `.scaffold/logs/-setup-*.log` | `basecamp setup` build logs. | +| `.scaffold/wallet/` | Project wallet home (`NSSA_WALLET_HOME_DIR`). **Never** included in `report` archives — contains keys. | +| `.scaffold/basecamp/profiles/{alice,bob}/` | Per-profile XDG roots for basecamp. | +| `.scaffold/basecamp/portable/-.lgx` | Symlinks to `.#lgx-portable` builds for AppImage hand-loading. Wiped each `build-portable`. | +| `.scaffold/repos/{lez,spel}/` | Vendored repo checkouts (only when project was created with `--vendor-deps`). | +| `.scaffold/reports/report-.tar.gz` | Output of `lgs report`. | +| `.scaffold/commands.md` | Canned reference scaffold ships into projects (sequencer command, status / doctor JSON commands, etc.). | + +Non-vendored projects share a cache root: `/repos///...` (configurable at `lgs new` via `--cache-root`). + +## Debugging Playbook + +Apply in order. Stop as soon as the issue is identified. + +1. **`lgs doctor`** — prints actionable checks and next steps. Use `--json` for parsing. Doctor inspects: required binaries (git/rustc/cargo/lsof/ps/kill, docker or podman), LEZ + spel repo presence and pin alignment, sequencer + wallet + spel binaries, port 3040 reachability, runtime state file, wallet network config, wallet `--version` and `check-health`. +2. **`lgs localnet status [--json]`** — distinguishes `managed`, `stale_state`, `foreign` listener, and missing. +3. **`lgs localnet logs --tail 200`** — tail recent sequencer output. +4. **`cat .scaffold/logs/sequencer.log`** — full log if `--tail` isn't enough. +5. **`lgs report --tail 500`** — produce a shareable `.tar.gz` under `.scaffold/reports/`. **Always inspect the archive before sharing publicly** (`tar -tzf ` to list contents). + +## Error → Recovery Table + +| Symptom | Root cause | Fix | +|---|---|---| +| `localnet status` reports `ownership: stale_state` | Tracked PID no longer running. | `lgs localnet stop` then `lgs localnet start`. | +| `cannot start localnet: port 3040 already in use (pid=...)` | Foreign listener on 3040. | Identify holder, `kill `, retry `localnet start`. | +| `sequencer process exited before becoming ready (pid=)` | Sequencer crashed at startup. | `lgs localnet logs --tail 200`; investigate root cause before retrying. | +| `localnet start timed out after s` | Slow startup (e.g. risc0 dev mode). | Increase `--timeout-sec`; check logs. | +| `missing sequencer binary at ; run \`logos-scaffold setup\`` | Setup never ran or binaries got cleaned. | `lgs setup`. | +| `Not a logos-scaffold project ... Run logos-scaffold create ` | Project-scoped command run outside a project. | `cd` into the project root, or run `lgs init` to adopt the current dir. | +| `scaffold.toml` schema mismatch (e.g. missing `[repos.spel]`) | Project predates current schema. | `lgs init` (idempotent migration); then `lgs setup`. | +| Doctor warns LEZ pin drift | `[repos.lez].pin` differs from scaffold default. | Either update `scaffold.toml` to the default and `lgs setup`, or accept the divergence. | +| Doctor warns spel/LEZ protocol mismatch | `spel-cli/Cargo.toml` vendors a different LEZ than scaffold. | Bump `[repos.spel].pin` to a commit whose spel-cli pins matching LEZ. | +| `wallet -- check-health` fails | Sequencer down or `NSSA_WALLET_HOME_DIR` not set for direct `cargo run`. | `lgs localnet start`; for direct runners: `export NSSA_WALLET_HOME_DIR=$(pwd)/.scaffold/wallet`. | +| `basecamp not set up yet` hint | `lgs basecamp setup` never ran in this project. | `lgs basecamp setup` (one-time per project). | +| `basecamp install` fails with `no \`main\` field in metadata.json` | Sub-flake transitively pulls a newer `logos-module-builder`. | Add `inputs..inputs.logos-module-builder.follows = "logos-module-builder";` in the offending sub-flake; `nix flake update`. See `docs/basecamp-module-requirements.md`. | +| `basecamp build-portable` fails: `.#lgx-portable` not exposed | Flake only exposes `.#lgx`. | Add the `lgx-portable` output, or pass `--flake #lgx-portable` to opt in explicitly. | +| Working tree dirty in vendored repo (lez / spel) | Manual edits in cached checkout. | Commit, stash, or reset the change in `.scaffold/repos//`. | + +## Environment Overrides + +| Variable | Purpose | +|---|---| +| `LOGOS_SCAFFOLD_WALLET_PASSWORD` | Override the default wallet password. Forwarded through `wallet --` passthrough. | +| `NSSA_WALLET_HOME_DIR` | Wallet home dir; required for direct `cargo run --bin run_*`. Scaffold wallet commands set it automatically. | +| `LOGOS_SCAFFOLD_PRINT_OUTPUT` | Equivalent to `--print-output`; streams nix output instead of writing to `.scaffold/logs/`. | +| `EXAMPLE_PROGRAMS_BUILD_DIR` | Override the default risc0 guest build dir for explicit `--program-path` invocations. | + +## JSON Outputs + +Use `--json` whenever piping to other tools: + +```bash +lgs localnet status --json # { tracked_pid, listener_present, ownership, ready } +lgs doctor --json # { status, summary, checks, next_steps } +lgs deploy --program-path "" --json # { status, program, tx?, program_id? } +lgs basecamp doctor --json +``` + +`lgs deploy --json` only produces structured JSON when combined with `--program-path`. On the discovery path, `--json` is silently accepted but ignored. + +## DOGFOODING Cross-Reference + +The canonical scenarios in `DOGFOODING.md`: + +- **D1–D6** — default template (bootstrap, localnet/doctor, deploy variants, wallet, report, runner interaction). +- **L1–L4** — lez-framework template (bootstrap, IDL regen, client regen, deploy + counter). +- **E1–E2** — CLI surface (help/version/error quality, advanced `new` flags). +- **B1–B5** — basecamp (setup, modules+install+launch, p2p, clean-slate, build-portable). + +When reproducing a failure, name the matching scenario in the bug report. + +## Key Rules + +- **Never** `rm -rf .scaffold/wallet/` — it contains keys; `lgs report` deliberately excludes it. +- **Never** edit `[basecamp.modules]` while `lgs basecamp modules` is running. Otherwise hand-edits in that section are preserved across re-runs. +- **Always** `lgs localnet stop` before any destructive reset; otherwise scaffold may leave a stale PID. +- **Always** inspect `.scaffold/reports/*.tar.gz` (`tar -tzf `) before sharing publicly. Sanitisation is best-effort, not absolute. +- **Prefer** project-local binaries via `lgs wallet -- ...` and `lgs spel -- ...` over global installs. Nothing scaffold builds is added to PATH on purpose. +- **Don't** assume `--json` is structured for every command; it's structured only where the table above says so (status, doctor, deploy w/ `--program-path`, basecamp doctor). +- Project-scoped commands run outside a project root produce a clear `Not a logos-scaffold project ...` error — don't try to work around it; `cd` into the project or `lgs init`. diff --git a/src/cli.rs b/src/cli.rs index 3ecb2fd..da8ba33 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -134,7 +134,7 @@ struct SelfTestRunLoggedArgs { /// Step label passed to `run_logged`. Appears in progress / failure lines. #[arg(long, default_value = "self-test step")] step: String, - /// Run `/bin/false` instead of `/bin/true` — exercises the failure bail. + /// Run `false` instead of `true` — exercises the failure bail. #[arg(long)] fail: bool, /// Set `LOGOS_SCAFFOLD_PRINT_OUTPUT=1` for this call — exercises the diff --git a/src/commands/init.rs b/src/commands/init.rs index 3a2eebd..d003ebf 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -17,6 +17,7 @@ use crate::migrate::migrate_to_v0_2_0; use crate::model::{Config, FrameworkConfig, FrameworkIdlConfig, LocalnetConfig, RunConfig}; use crate::state::write_text_atomic; use crate::template::project::ensure_scaffold_in_gitignore; +use crate::template::skills::apply_skills; use crate::DynResult; pub(crate) fn cmd_init(bin_name: &str, dry_run: bool, no_backup: bool) -> DynResult<()> { @@ -46,22 +47,97 @@ pub(crate) fn cmd_init_at( })?; let report = migrate_to_v0_2_0(&mut doc)?; - if report.changes.is_empty() { - // No migration needed. If the project is missing the .scaffold/ - // directories that fresh-init creates, this is a previously - // wedged init (the file landed but a later step failed). Finish - // the init instead of refusing — without this, the user can - // only recover by hand-removing scaffold.toml. - if scaffold_dirs_present(target) { - bail!( - "scaffold.toml at {} is already at schema v{} — nothing to migrate", + let migrated = !report.changes.is_empty(); + + if migrated { + let backup_path = scaffold_path.with_extension("toml.bak"); + // If a previous migration (or a hand-curated backup) already wrote + // scaffold.toml.bak, `fs::copy` would silently overwrite it and the + // user would lose the older backup with no warning. Refuse instead + // and surface both ways out: rename/delete the existing .bak, or + // pass --no-backup to skip the backup entirely. + let backup_collision = !no_backup && backup_path.exists(); + if dry_run { + println!( + "dry-run: would migrate scaffold.toml at {} to schema v{} (no changes made)", target.display(), SCAFFOLD_TOML_SCHEMA_VERSION, ); + if !no_backup { + if backup_collision { + println!( + "dry-run: WOULD ABORT — backup target already exists at {}. \ + Move/delete it, or re-run with --no-backup to skip the backup.", + backup_path.display(), + ); + } else { + println!( + "dry-run: would write backup of current scaffold.toml to {}", + backup_path.display(), + ); + } + } + for change in &report.changes { + println!(" - {change}"); + } + if let Some(hint) = &report.hand_edit_hint { + println!(" ! {hint}"); + } + println!( + "Re-run without --dry-run to apply, or `{bin_name} init --no-backup` to skip the .bak." + ); + return Ok(()); } + + if backup_collision { + bail!( + "refusing to overwrite existing scaffold.toml.bak at {}.\n\ + Move or delete it first, or re-run with --no-backup to migrate without writing a backup.\n\ + Preview the migration with `{bin_name} init --dry-run`.", + backup_path.display(), + ); + } + + // Create the .scaffold/ directories before rewriting scaffold.toml. + // The fresh-init branch does the same — both paths should leave the + // project in the same fully-initialized state, otherwise a re-run on + // a project that was upgraded by migration alone would look wedged. + create_scaffold_dirs(target)?; + + // Write the backup before rewriting scaffold.toml, so a crash mid + // write can't leave both the original and the migration unrecoverable. + if !no_backup { + fs::copy(&scaffold_path, &backup_path).with_context(|| { + format!( + "writing backup of scaffold.toml to {} before migrating", + backup_path.display() + ) + })?; + } + write_text_atomic(&scaffold_path, &doc.to_string())?; + ensure_scaffold_in_gitignore(target)?; + println!( + "scaffold.toml in {} migrated to schema v{}.", + target.display(), + SCAFFOLD_TOML_SCHEMA_VERSION, + ); + if !no_backup { + println!(" backup: {}", backup_path.display()); + } + for change in report.changes { + println!(" - {change}"); + } + if let Some(hint) = report.hand_edit_hint { + println!(" ! {hint}"); + } + } else { + // Already at current schema. Re-run is the user-facing entry point + // for refreshing the shipped AI skills, and also recovers from a + // wedged init where scaffold.toml landed but the .scaffold/ dirs + // never got created. if dry_run { println!( - "dry-run: scaffold.toml at {} is already at schema v{}; would create missing .scaffold/state and .scaffold/logs directories (no changes made)", + "dry-run: scaffold.toml at {} is already at schema v{}; would ensure .scaffold/state and .scaffold/logs and refresh AI skills (no changes made)", target.display(), SCAFFOLD_TOML_SCHEMA_VERSION, ); @@ -74,95 +150,18 @@ pub(crate) fn cmd_init_at( create_scaffold_dirs(target)?; ensure_scaffold_in_gitignore(target)?; println!( - "scaffold.toml at {} is already at schema v{}; created missing .scaffold/ \ - directories to complete a previously-interrupted init.", + "scaffold.toml at {} is already at schema v{}.", target.display(), SCAFFOLD_TOML_SCHEMA_VERSION, ); - return Ok(()); } - let backup_path = scaffold_path.with_extension("toml.bak"); - // If a previous migration (or a hand-curated backup) already wrote - // scaffold.toml.bak, `fs::copy` would silently overwrite it and the - // user would lose the older backup with no warning. Refuse instead - // and surface both ways out: rename/delete the existing .bak, or - // pass --no-backup to skip the backup entirely. - let backup_collision = !no_backup && backup_path.exists(); - if dry_run { - println!( - "dry-run: would migrate scaffold.toml at {} to schema v{} (no changes made)", - target.display(), - SCAFFOLD_TOML_SCHEMA_VERSION, - ); - if !no_backup { - if backup_collision { - println!( - "dry-run: WOULD ABORT — backup target already exists at {}. \ - Move/delete it, or re-run with --no-backup to skip the backup.", - backup_path.display(), - ); - } else { - println!( - "dry-run: would write backup of current scaffold.toml to {}", - backup_path.display(), - ); - } - } - for change in report.changes { - println!(" - {change}"); - } - if let Some(hint) = report.hand_edit_hint { - println!(" ! {hint}"); - } - println!( - "Re-run without --dry-run to apply, or `{bin_name} init --no-backup` to skip the .bak." - ); - return Ok(()); - } + apply_skills(target)?; + println!("AI skills refreshed under .claude/skills/, .cursor/rules/, AGENTS.md."); - if backup_collision { - bail!( - "refusing to overwrite existing scaffold.toml.bak at {}.\n\ - Move or delete it first, or re-run with --no-backup to migrate without writing a backup.\n\ - Preview the migration with `{bin_name} init --dry-run`.", - backup_path.display(), - ); + if migrated { + println!("Run `{bin_name} setup` to clone and build per the new schema."); } - - // Create the .scaffold/ directories before rewriting scaffold.toml. - // The fresh-init branch does the same — both paths should leave the - // project in the same fully-initialized state, otherwise a re-run on - // a project that was upgraded by migration alone would look wedged. - create_scaffold_dirs(target)?; - - // Write the backup before rewriting scaffold.toml, so a crash mid - // write can't leave both the original and the migration unrecoverable. - if !no_backup { - fs::copy(&scaffold_path, &backup_path).with_context(|| { - format!( - "writing backup of scaffold.toml to {} before migrating", - backup_path.display() - ) - })?; - } - write_text_atomic(&scaffold_path, &doc.to_string())?; - ensure_scaffold_in_gitignore(target)?; - println!( - "scaffold.toml in {} migrated to schema v{}.", - target.display(), - SCAFFOLD_TOML_SCHEMA_VERSION, - ); - if !no_backup { - println!(" backup: {}", backup_path.display()); - } - for change in report.changes { - println!(" - {change}"); - } - if let Some(hint) = report.hand_edit_hint { - println!(" ! {hint}"); - } - println!("Run `{bin_name} setup` to clone and build per the new schema."); return Ok(()); } @@ -190,11 +189,13 @@ pub(crate) fn cmd_init_at( create_scaffold_dirs(target)?; write_text_atomic(&scaffold_path, &serialize_config(&cfg)?)?; ensure_scaffold_in_gitignore(target)?; + apply_skills(target)?; println!( "scaffold.toml created at {}. Run '{bin_name} setup' to clone LEZ and build dependencies.", scaffold_path.display() ); + println!("AI skills installed under .claude/skills/, .cursor/rules/, and AGENTS.md."); println!( "If this project is building modules for basecamp, run '{bin_name} basecamp setup' to pin + build basecamp + lgpm and seed alice/bob profiles." ); @@ -210,10 +211,6 @@ fn create_scaffold_dirs(target: &Path) -> DynResult<()> { Ok(()) } -fn scaffold_dirs_present(target: &Path) -> bool { - target.join(".scaffold/state").is_dir() && target.join(".scaffold/logs").is_dir() -} - fn fresh_default_config() -> Config { Config { version: SCAFFOLD_TOML_SCHEMA_VERSION.to_string(), @@ -297,12 +294,53 @@ mod tests { } #[test] - fn init_refuses_when_already_at_v0_2_0() { + fn init_is_idempotent_when_already_at_v0_2_0_and_refreshes_skills() { let temp = tempdir().expect("tempdir"); let target = temp.path(); cmd_init_at(target, "lgs", false, false).expect("init"); - let err = cmd_init_at(target, "lgs", false, false).expect_err("should refuse"); - assert!(err.to_string().contains("already at schema"), "{err}"); + + // Re-running init on an already-migrated project must succeed; it is + // the user-facing entry point for refreshing AI skill files alongside + // any pending schema migration. + cmd_init_at(target, "lgs", false, false).expect("re-init must succeed and refresh skills"); + assert!( + target.join(".claude/skills/lgs-cli/SKILL.md").is_file(), + "claude skill must be present after re-init" + ); + assert!( + target.join(".cursor/rules/lgs-cli.mdc").is_file(), + "cursor rule must be present after re-init" + ); + assert!( + target.join("AGENTS.md").is_file(), + "AGENTS.md must be present after re-init" + ); + } + + #[test] + fn init_writes_skills_on_fresh_project() { + let temp = tempdir().expect("tempdir"); + let target = temp.path(); + cmd_init_at(target, "lgs", false, false).expect("init"); + + for name in [ + "lgs-cli", + "lez-template", + "lez-framework-template", + "basecamp", + ] { + assert!( + target + .join(format!(".claude/skills/{name}/SKILL.md")) + .is_file(), + "missing claude skill: {name}" + ); + assert!( + target.join(format!(".cursor/rules/{name}.mdc")).is_file(), + "missing cursor rule: {name}" + ); + } + assert!(target.join("AGENTS.md").is_file()); } #[test] @@ -589,7 +627,7 @@ home_dir = ".scaffold/wallet" let target = temp.path(); // Seed a regular file at `.scaffold` so `create_dir_all(".scaffold/state")` fails. // Dir creation is the first filesystem mutation in fresh-init; if it fails the - // user must be left with no scaffold.toml — otherwise a retry will refuse to run. + // user must be left with no scaffold.toml so a retry starts from a clean state. fs::write(target.join(".scaffold"), b"not a dir").expect("seed"); let err = cmd_init_at(target, "lgs", false, false).expect_err("dir creation should fail"); @@ -761,13 +799,13 @@ home_dir = ".scaffold/wallet" } #[test] - fn init_still_refuses_when_already_initialized_and_dirs_present() { - // The wedged-state recovery must not weaken the existing refuse-on-double-init - // guard. With `.scaffold/` directories present, a second init still bails. + fn init_rerun_succeeds_when_already_initialized_and_dirs_present() { let temp = tempdir().expect("tempdir"); let target = temp.path(); cmd_init_at(target, "lgs", false, false).expect("init"); - let err = cmd_init_at(target, "lgs", false, false).expect_err("second init should refuse"); - assert!(err.to_string().contains("already at schema"), "{err}"); + cmd_init_at(target, "lgs", false, false).expect("second init should refresh skills"); + assert!(target.join(".scaffold/state").is_dir()); + assert!(target.join(".scaffold/logs").is_dir()); + assert!(target.join(".claude/skills/lgs-cli/SKILL.md").is_file()); } } diff --git a/src/commands/new.rs b/src/commands/new.rs index 0f31da4..d897ab6 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -18,6 +18,7 @@ use crate::repo::{sync_repo_to_pin_at_path_with_opts, RepoSyncOptions}; use crate::state::write_text; use crate::template::copy::{copy_dir_contents, patch_simple_tail_call_program_id}; use crate::template::project::{apply_overlay, OverlayRenderContext}; +use crate::template::skills::apply_skills; use crate::DynResult; #[derive(Debug)] @@ -154,6 +155,7 @@ pub(crate) fn cmd_new(cmd: NewCommand) -> DynResult<()> { cleanup_lez_hello_artifacts(&target)?; } write_text(&target.join("scaffold.toml"), &serialize_config(&cfg)?)?; + apply_skills(&target)?; let old_getting_started = target.join("GETTING_STARTED.md"); if old_getting_started.exists() { @@ -167,6 +169,7 @@ pub(crate) fn cmd_new(cmd: NewCommand) -> DynResult<()> { ); println!("Pinned lez: {}", cfg.lez.pin); println!("Template variant: {}", cfg.framework.kind); + println!("AI skills installed under .claude/skills/, .cursor/rules/, and AGENTS.md."); Ok(()) } diff --git a/src/commands/wallet_support.rs b/src/commands/wallet_support.rs index 32c351c..fb24190 100644 --- a/src/commands/wallet_support.rs +++ b/src/commands/wallet_support.rs @@ -466,8 +466,10 @@ fn one_line(text: &str) -> String { #[cfg(test)] mod tests { - use super::WALLET_CONFIG_PRIMARY; use std::fs; + use std::io::{Read, Write}; + use std::net::TcpStream; + use std::time::Duration; use tempfile::tempdir; @@ -475,10 +477,64 @@ mod tests { extract_tx_identifier, first_public_wallet_address, is_already_initialized_failure, is_uninitialized_account_output, normalize_address_ref, read_default_wallet_address, resolve_wallet_address, wallet_state_path, write_default_wallet_address, + WALLET_CONFIG_PRIMARY, }; const ACCOUNT_ID: &str = "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV"; + fn spawn_json_rpc_response(body: &'static str) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let url = format!("http://{addr}"); + + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + read_http_request(&mut stream); + + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).expect("write"); + stream.flush().expect("flush"); + }); + + (url, handle) + } + + fn read_http_request(stream: &mut TcpStream) { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("set read timeout"); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + + loop { + let n = stream.read(&mut buf).expect("read request"); + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + + if let Some(header_end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + } + #[test] fn normalize_accepts_raw_account_id() { let normalized = normalize_address_ref(ACCOUNT_ID).expect("normalize"); @@ -677,27 +733,7 @@ details: [1, 2, 3] #[test] fn rpc_get_last_block_id_parses_valid_response() { - use std::io::{Read, Write}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("local addr"); - let url = format!("http://{addr}"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let mut buf = [0_u8; 4096]; - let _ = stream.read(&mut buf); - - let body = r#"{"jsonrpc":"2.0","result":42,"id":1}"#; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes()).expect("write"); - stream.flush().expect("flush"); - }); + let (url, handle) = spawn_json_rpc_response(r#"{"jsonrpc":"2.0","result":42,"id":1}"#); let block = super::rpc_get_last_block_id(&url).expect("rpc_get_last_block_id should succeed"); @@ -717,28 +753,7 @@ details: [1, 2, 3] #[test] fn rpc_get_last_block_id_returns_error_on_malformed_response() { - use std::io::{Read, Write}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("local addr"); - let url = format!("http://{addr}"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let mut buf = [0_u8; 4096]; - let _ = stream.read(&mut buf); - - // Response with non-numeric `result` - let body = r#"{"jsonrpc":"2.0","result":{},"id":1}"#; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes()).expect("write"); - stream.flush().expect("flush"); - }); + let (url, handle) = spawn_json_rpc_response(r#"{"jsonrpc":"2.0","result":{},"id":1}"#); let result = super::rpc_get_last_block_id(&url); assert!(result.is_err()); @@ -752,28 +767,9 @@ details: [1, 2, 3] #[test] fn rpc_get_last_block_id_returns_error_on_method_not_found() { - use std::io::{Read, Write}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("local addr"); - let url = format!("http://{addr}"); - - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let mut buf = [0_u8; 4096]; - let _ = stream.read(&mut buf); - - let body = - r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - stream.write_all(response.as_bytes()).expect("write"); - stream.flush().expect("flush"); - }); + let (url, handle) = spawn_json_rpc_response( + r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#, + ); let result = super::rpc_get_last_block_id(&url); let err_msg = result diff --git a/src/template/mod.rs b/src/template/mod.rs index 6c51570..7f90ecf 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod copy; pub(crate) mod project; +pub(crate) mod skills; diff --git a/src/template/skills.rs b/src/template/skills.rs new file mode 100644 index 0000000..5044fca --- /dev/null +++ b/src/template/skills.rs @@ -0,0 +1,388 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, bail, Context}; +use include_dir::{include_dir, Dir}; + +use crate::state::write_text; +use crate::DynResult; + +static SKILLS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills"); + +const AGENTS_HEADER: &str = "# AI Agent Skills + +This project ships skills for AI coding assistants. They are regenerated by\n\ +`logos-scaffold new` and `logos-scaffold init`; commit them so your team gets\n\ +the same guidance. + +- **Claude Code** reads `.claude/skills//SKILL.md` directly. +- **Cursor** reads `.cursor/rules/.mdc`. +- **Codex / other agents** can open the matching skill below when its trigger applies. + +| Skill | When it applies | +|---|---| +"; + +const AGENTS_FOOTER: &str = "\nOpen the matching skill file when its activation criteria apply.\n"; + +/// Materialize the canonical scaffold AI skills into `target`: +/// +/// - `/.claude/skills//SKILL.md` — verbatim canonical source +/// (Claude Code's native skill format). +/// - `/.cursor/rules/.mdc` — Cursor Project Rule with frontmatter +/// rewritten (`name:` dropped; `alwaysApply: false` added). +/// - `/AGENTS.md` — pointer file at the project root listing every +/// skill with its description (Codex / cross-tool convention). +/// +/// Idempotent: re-running overwrites each shipped skill file with its current +/// canonical content. Skills the user has added under `.claude/skills//` +/// or `.cursor/rules/.mdc` are not touched. +pub(crate) fn apply_skills(target: &Path) -> DynResult<()> { + let mut entries: Vec<(String, String)> = Vec::new(); + + for skill_dir in SKILLS_DIR.dirs() { + let name = skill_dir + .path() + .file_name() + .ok_or_else(|| anyhow!("invalid skill dir path: {}", skill_dir.path().display()))? + .to_string_lossy() + .into_owned(); + + let skill_md = skill_dir + .files() + .find(|f| f.path().file_name() == Some(std::ffi::OsStr::new("SKILL.md"))) + .ok_or_else(|| anyhow!("skill `{name}` is missing SKILL.md"))?; + + let raw = skill_md + .contents_utf8() + .ok_or_else(|| anyhow!("SKILL.md for skill `{name}` is not valid UTF-8"))?; + + let parsed = parse_skill_frontmatter(raw) + .with_context(|| format!("parsing frontmatter for skill `{name}`"))?; + + let claude_path = target + .join(".claude") + .join("skills") + .join(&name) + .join("SKILL.md"); + if let Some(parent) = claude_path.parent() { + fs::create_dir_all(parent)?; + } + write_text(&claude_path, raw)?; + + let mdc = render_cursor_mdc(&parsed.description, &parsed.body); + let cursor_path = target + .join(".cursor") + .join("rules") + .join(format!("{name}.mdc")); + if let Some(parent) = cursor_path.parent() { + fs::create_dir_all(parent)?; + } + write_text(&cursor_path, &mdc)?; + + entries.push((name, parsed.description)); + } + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut agents = String::from(AGENTS_HEADER); + for (name, description) in &entries { + agents.push_str(&format!( + "| [`{name}`](.claude/skills/{name}/SKILL.md) | {description} |\n" + )); + } + agents.push_str(AGENTS_FOOTER); + write_text(&target.join("AGENTS.md"), &agents)?; + + Ok(()) +} + +#[derive(Debug)] +struct ParsedSkill { + description: String, + body: String, +} + +fn parse_skill_frontmatter(raw: &str) -> DynResult { + let trimmed = raw.trim_start_matches('\u{feff}'); + let after_open = trimmed + .strip_prefix("---\n") + .or_else(|| trimmed.strip_prefix("---\r\n")) + .ok_or_else(|| anyhow!("SKILL.md missing leading `---` frontmatter delimiter"))?; + + let (frontmatter, body) = split_frontmatter(after_open) + .ok_or_else(|| anyhow!("SKILL.md missing closing `---` for frontmatter"))?; + + let mut description: Option = None; + let mut buf = String::new(); + let mut in_description = false; + for line in frontmatter.lines() { + if let Some(rest) = line.strip_prefix("description:") { + if description.is_some() { + bail!("SKILL.md frontmatter has multiple `description:` lines"); + } + buf = rest.trim().to_string(); + in_description = true; + } else if in_description && (line.starts_with(' ') || line.starts_with('\t')) { + if !buf.is_empty() { + buf.push(' '); + } + buf.push_str(line.trim()); + } else { + if in_description { + description = Some(std::mem::take(&mut buf)); + in_description = false; + } + } + } + if in_description { + description = Some(buf); + } + + let description = + description.ok_or_else(|| anyhow!("SKILL.md frontmatter missing `description:` field"))?; + if description.is_empty() { + bail!("SKILL.md frontmatter has empty `description:`"); + } + + Ok(ParsedSkill { + description, + body: body.to_string(), + }) +} + +fn split_frontmatter(after_open: &str) -> Option<(&str, &str)> { + let mut offset = 0; + for line in after_open.split_inclusive('\n') { + let line_without_lf = line.strip_suffix('\n').unwrap_or(line); + let line_text = line_without_lf + .strip_suffix('\r') + .unwrap_or(line_without_lf); + if line_text == "---" { + return Some((&after_open[..offset], &after_open[offset + line.len()..])); + } + offset += line.len(); + } + + None +} + +fn render_cursor_mdc(description: &str, body: &str) -> String { + format!("---\ndescription: {description}\nalwaysApply: false\n---\n{body}") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::{apply_skills, parse_skill_frontmatter, render_cursor_mdc, SKILLS_DIR}; + use tempfile::{tempdir, TempDir}; + + fn mk_temp_dir() -> TempDir { + tempdir().expect("failed to create temporary test directory") + } + + fn shipped_skill_names() -> Vec { + let mut names: Vec = SKILLS_DIR + .dirs() + .map(|d| { + d.path() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + .filter(|s| !s.is_empty()) + .collect(); + names.sort(); + names + } + + #[test] + fn ships_the_expected_four_skills() { + let names = shipped_skill_names(); + assert_eq!( + names, + vec![ + "basecamp".to_string(), + "lez-framework-template".to_string(), + "lez-template".to_string(), + "lgs-cli".to_string(), + ], + "shipped skills set drifted; update the test if intentional" + ); + } + + #[test] + fn apply_skills_writes_claude_cursor_and_agents_layouts() { + let temp = mk_temp_dir(); + let target = temp.path(); + apply_skills(target).expect("apply_skills"); + + for name in shipped_skill_names() { + let claude = target.join(format!(".claude/skills/{name}/SKILL.md")); + assert!( + claude.exists(), + "missing claude skill: {}", + claude.display() + ); + + let cursor = target.join(format!(".cursor/rules/{name}.mdc")); + assert!(cursor.exists(), "missing cursor rule: {}", cursor.display()); + } + + let agents = target.join("AGENTS.md"); + assert!(agents.exists(), "AGENTS.md should be at project root"); + let agents_text = fs::read_to_string(&agents).expect("read AGENTS.md"); + for name in shipped_skill_names() { + assert!( + agents_text.contains(&format!("[`{name}`]")), + "AGENTS.md is missing skill `{name}`; got:\n{agents_text}" + ); + } + } + + #[test] + fn claude_skill_md_is_byte_identical_to_source() { + let temp = mk_temp_dir(); + let target = temp.path(); + apply_skills(target).expect("apply_skills"); + + let written = fs::read_to_string(target.join(".claude/skills/lgs-cli/SKILL.md")) + .expect("read written SKILL.md"); + let source = include_str!("../../skills/lgs-cli/SKILL.md"); + assert_eq!( + written, source, + ".claude/skills//SKILL.md must be byte-identical to the canonical source" + ); + } + + #[test] + fn cursor_mdc_drops_name_field_and_adds_always_apply() { + let temp = mk_temp_dir(); + let target = temp.path(); + apply_skills(target).expect("apply_skills"); + + let mdc = + fs::read_to_string(target.join(".cursor/rules/lgs-cli.mdc")).expect("read lgs-cli.mdc"); + // First line opens frontmatter; we expect description + alwaysApply + // before the closing delimiter, with no `name:` field. + let (frontmatter, _body) = mdc + .strip_prefix("---\n") + .and_then(|rest| rest.split_once("\n---\n")) + .expect("mdc has well-formed frontmatter"); + assert!( + frontmatter.starts_with("description:"), + "mdc frontmatter must lead with description; got:\n{frontmatter}" + ); + assert!( + frontmatter.contains("alwaysApply: false"), + "mdc frontmatter must include alwaysApply: false; got:\n{frontmatter}" + ); + assert!( + !frontmatter.contains("name:"), + "mdc frontmatter must not include the source `name:` field; got:\n{frontmatter}" + ); + } + + #[test] + fn apply_skills_is_idempotent() { + let temp = mk_temp_dir(); + let target = temp.path(); + apply_skills(target).expect("first apply"); + let claude_first = fs::read_to_string(target.join(".claude/skills/lgs-cli/SKILL.md")) + .expect("read claude after first"); + let mdc_first = fs::read_to_string(target.join(".cursor/rules/lgs-cli.mdc")) + .expect("read mdc after first"); + let agents_first = fs::read_to_string(target.join("AGENTS.md")).expect("read agents first"); + + apply_skills(target).expect("second apply"); + let claude_second = fs::read_to_string(target.join(".claude/skills/lgs-cli/SKILL.md")) + .expect("read claude after second"); + let mdc_second = fs::read_to_string(target.join(".cursor/rules/lgs-cli.mdc")) + .expect("read mdc after second"); + let agents_second = + fs::read_to_string(target.join("AGENTS.md")).expect("read agents second"); + + assert_eq!(claude_first, claude_second); + assert_eq!(mdc_first, mdc_second); + assert_eq!(agents_first, agents_second); + } + + #[test] + fn apply_skills_preserves_user_added_skill_files() { + let temp = mk_temp_dir(); + let target = temp.path(); + let custom_claude = target.join(".claude/skills/team-custom/SKILL.md"); + let custom_cursor = target.join(".cursor/rules/team-custom.mdc"); + fs::create_dir_all(custom_claude.parent().unwrap()).expect("mkdir claude"); + fs::create_dir_all(custom_cursor.parent().unwrap()).expect("mkdir cursor"); + fs::write(&custom_claude, "team-only canonical").expect("write team claude skill"); + fs::write(&custom_cursor, "team-only cursor rule").expect("write team cursor rule"); + + apply_skills(target).expect("apply_skills"); + + assert_eq!( + fs::read_to_string(&custom_claude).expect("read custom claude"), + "team-only canonical" + ); + assert_eq!( + fs::read_to_string(&custom_cursor).expect("read custom cursor"), + "team-only cursor rule" + ); + } + + #[test] + fn parse_skill_frontmatter_extracts_description_and_body() { + let raw = "---\nname: foo\ndescription: hello world\n---\n# Body\n\ntext\n"; + let parsed = parse_skill_frontmatter(raw).expect("parse"); + assert_eq!(parsed.description, "hello world"); + assert_eq!(parsed.body, "# Body\n\ntext\n"); + } + + #[test] + fn parse_skill_frontmatter_handles_multiline_description() { + let raw = "---\nname: foo\ndescription: first\n continued line\nother: x\n---\nbody\n"; + let parsed = parse_skill_frontmatter(raw).expect("parse"); + assert_eq!(parsed.description, "first continued line"); + } + + #[test] + fn parse_skill_frontmatter_rejects_missing_delimiter() { + let raw = "name: foo\ndescription: bar\n"; + let err = parse_skill_frontmatter(raw).expect_err("should reject"); + assert!( + err.to_string().contains("missing leading"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_skill_frontmatter_rejects_non_delimiter_close_line() { + let raw = "---\nname: foo\ndescription: bar\n---foo\n# Body\n"; + let err = parse_skill_frontmatter(raw).expect_err("should reject"); + assert!( + err.to_string().contains("missing closing"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_skill_frontmatter_rejects_missing_description() { + let raw = "---\nname: foo\n---\nbody\n"; + let err = parse_skill_frontmatter(raw).expect_err("should reject"); + assert!( + err.to_string().contains("missing `description:`"), + "unexpected error: {err}" + ); + } + + #[test] + fn render_cursor_mdc_preserves_body_verbatim() { + let mdc = render_cursor_mdc("desc text", "# Body\n\nLine.\n"); + assert_eq!( + mdc, + "---\ndescription: desc text\nalwaysApply: false\n---\n# Body\n\nLine.\n" + ); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index eae1caf..631682b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -22,6 +22,15 @@ const DEFAULT_WALLET_PASSWORD: &str = "logos-scaffold-v0"; const GUEST_BIN_REL_PATH: &str = "target/riscv-guest/example_program_deployment_methods/example_program_deployment_programs/riscv32im-risc0-zkvm-elf/release"; +#[cfg(unix)] +fn true_bin() -> &'static str { + if Path::new("/bin/true").exists() { + "/bin/true" + } else { + "/usr/bin/true" + } +} + /// Minimal valid `scaffold.toml` content for tests that only need the project /// context to exist (no basecamp section). Older tests in this file inline /// the same content; new tests should prefer this helper. @@ -883,9 +892,11 @@ fn localnet_start_fails_when_process_exits_before_ready() { let lez_path = temp.path().join("lez"); let sequencer_bin = lez_path.join("target/release/sequencer_service"); let config_path = lez_path.join("sequencer/service/configs/debug/sequencer_config.json"); + let localnet_port = unused_local_port(); fs::create_dir_all(sequencer_bin.parent().expect("parent")).expect("create dirs"); fs::create_dir_all(config_path.parent().expect("parent")).expect("create config dir"); - fs::write(&config_path, r#"{"port": 3040}"#).expect("write sequencer config"); + fs::write(&config_path, format!(r#"{{"port": {localnet_port}}}"#)) + .expect("write sequencer config"); fs::write(&sequencer_bin, "#!/bin/sh\nexit 1\n").expect("write fake sequencer"); #[cfg(unix)] @@ -898,7 +909,7 @@ fn localnet_start_fails_when_process_exits_before_ready() { fs::set_permissions(&sequencer_bin, perms).expect("chmod"); } - write_scaffold_toml(temp.path(), &lez_path); + write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(localnet_port), Some(true)); Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold")) .current_dir(temp.path()) @@ -910,10 +921,7 @@ fn localnet_start_fails_when_process_exits_before_ready() { .failure() .stderr( predicate::str::contains("sequencer process exited before becoming ready") - .or(predicate::str::contains("localnet start timed out after")) - .or(predicate::str::contains( - "cannot start localnet: port 3040 is already in use", - )), + .or(predicate::str::contains("localnet start timed out after")), ); assert!( @@ -2523,13 +2531,15 @@ fn basecamp_launch_rejects_unknown_profile() { fs::write(project.join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML).expect("write scaffold.toml"); // Fake a completed setup so we get past the first gate and reach profile validation. - // Paths are /bin/echo / /bin/sh — they exist on Linux and macOS, and launch never actually - // reaches `exec` because the profile check fails first. + // Use an existing true binary so the launch path reaches profile validation. let state_dir = project.join(".scaffold/state"); fs::create_dir_all(&state_dir).expect("mkdir state"); fs::write( state_dir.join("basecamp.state"), - "pin=deadbeef\nbasecamp_bin=/bin/echo\nlgpm_bin=/bin/sh\n", + &format!( + "pin=deadbeef\nbasecamp_bin={}\nlgpm_bin=/bin/echo\n", + true_bin() + ), ) .expect("write state"); @@ -2572,7 +2582,10 @@ fn basecamp_launch_bails_when_no_modules_captured() { fs::create_dir_all(&state_dir).expect("mkdir state"); fs::write( state_dir.join("basecamp.state"), - "pin=deadbeef\nbasecamp_bin=/bin/echo\nlgpm_bin=/bin/sh\n", + &format!( + "pin=deadbeef\nbasecamp_bin={}\nlgpm_bin=/bin/echo\n", + true_bin() + ), ) .expect("write state"); fs::create_dir_all(project.join(".scaffold/basecamp/profiles/alice")).expect("mkdir profile"); @@ -2701,7 +2714,7 @@ fn basecamp_launch_dry_run_prints_plan_without_scrubbing() { fn basecamp_launch_no_clean_bypasses_empty_modules_check() { // --no-clean is the documented escape hatch for keeping whatever's already // installed in the profile. It must skip the empty-modules check entirely. - // We don't run launch to completion in the assertion path, but the + // We don't run launch to completion (basecamp_bin is a true binary), but the // command should get past the modules check and fail later — not fail on // an "install modules first" hint. let temp = tempdir().expect("tempdir"); @@ -2746,7 +2759,10 @@ port_stride = 10 fs::create_dir_all(&state_dir).expect("mkdir state"); fs::write( state_dir.join("basecamp.state"), - "pin=deadbeef\nbasecamp_bin=/bin/sh\nlgpm_bin=/bin/echo\n", + &format!( + "pin=deadbeef\nbasecamp_bin={}\nlgpm_bin=/bin/echo\n", + true_bin() + ), ) .expect("write state"); fs::create_dir_all(project.join(".scaffold/basecamp/profiles/alice")).expect("mkdir profile"); @@ -3522,30 +3538,56 @@ fn init_creates_scaffold_toml_and_dirs() { } #[test] -fn init_refuses_already_at_v0_2_0_scaffold_toml() { +fn init_refreshes_skills_when_already_at_v0_2_0_scaffold_toml() { let temp = tempdir().expect("tempdir"); let scaffold_path = temp.path().join("scaffold.toml"); - // First, run init to lay down a fresh v0.2.0 file. + // First, run init to lay down a fresh v0.2.0 file plus the AI skill set. Command::new(assert_cmd::cargo::cargo_bin!("lgs")) .current_dir(temp.path()) .arg("init") .assert() .success(); - let original = fs::read_to_string(&scaffold_path).expect("read scaffold.toml"); + let original_scaffold = fs::read_to_string(&scaffold_path).expect("read scaffold.toml"); - // Second invocation must refuse with the "already migrated" hint. + // Second invocation must succeed (no migration needed) and refresh the + // shipped skill set, but it must not rewrite scaffold.toml. Command::new(assert_cmd::cargo::cargo_bin!("lgs")) .current_dir(temp.path()) .arg("init") .assert() - .failure() - .stderr(predicate::str::contains("already at schema")); + .success() + .stdout(predicate::str::contains("already at schema")) + .stdout(predicate::str::contains("AI skills refreshed")); - let after = fs::read_to_string(&scaffold_path).expect("read scaffold.toml"); + let after_scaffold = fs::read_to_string(&scaffold_path).expect("read scaffold.toml"); assert_eq!( - after, original, + after_scaffold, original_scaffold, "init must not overwrite an already-migrated scaffold.toml" ); + + for skill in [ + "lgs-cli", + "lez-template", + "lez-framework-template", + "basecamp", + ] { + assert!( + temp.path() + .join(format!(".claude/skills/{skill}/SKILL.md")) + .is_file(), + "claude skill `{skill}` must be present after re-init" + ); + assert!( + temp.path() + .join(format!(".cursor/rules/{skill}.mdc")) + .is_file(), + "cursor rule `{skill}` must be present after re-init" + ); + } + assert!( + temp.path().join("AGENTS.md").is_file(), + "AGENTS.md must be present after re-init" + ); } #[test] @@ -3639,15 +3681,15 @@ home_dir = ".scaffold/wallet" "[repos.lgpm].attr must carry the flake attr; got:\n{after}" ); - // Re-running any non-init command must now succeed at the parse step - // (we only check parsing here; downstream commands will fail for other - // reasons in a stub setup). + // Re-running init on the now-migrated config must succeed (skills get + // refreshed) and report that no migration was needed. Command::new(assert_cmd::cargo::cargo_bin!("lgs")) .current_dir(temp.path()) .arg("init") .assert() - .failure() - .stderr(predicate::str::contains("already at schema")); + .success() + .stdout(predicate::str::contains("already at schema")) + .stdout(predicate::str::contains("AI skills refreshed")); } #[test]