feat: add scaffold ai skills (#104)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sasha
2026-05-14 01:32:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 4adde37bb5
commit 94de994380
15 changed files with 1518 additions and 199 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
/target
.claude
.claude/
docs/specs/
docs/ideas/
graphify-out/
+1
View File
@@ -11,6 +11,7 @@ include = [
"/src/**",
"/templates/**",
"/templates/**/.*",
"/skills/**",
"/tests/**",
"/Cargo.toml",
"/Cargo.lock",
+71
View File
@@ -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/<name>/SKILL.md` files: `lgs-cli`, `lez-template`, `lez-framework-template`, `basecamp`.
- The same four skills appear under `.cursor/rules/<name>.mdc`.
- `AGENTS.md` exists at every project root, lists all four skills with their descriptions, and links to `.claude/skills/<name>/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/<name>/SKILL.md` is byte-identical to the canonical source under `<scaffold-repo>/skills/<name>/SKILL.md` (run `diff` if validating against a built-from-source binary).
- `.cursor/rules/<name>.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/<name>.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/<other>/` 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/<name>.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`.
+1 -1
View File
@@ -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 `<cache_root>/repos/<name>/<pin>`, 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: <hex>` (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).
+175
View File
@@ -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.<system>.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.<system>.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.<system>.lgx`. This is the convention from `logos-module-builder` (tag `tutorial-v1`).
4. **For `build-portable`:** the same flakes also expose `packages.<system>.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 <ref>#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/<ts>-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 <profile> [--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/<NN>-<module_name>.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/<rev>#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 `<path>/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.<name>]` 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:../<sibling>` inputs to resolve against the working tree, not the locked `github:` pin. Scaffold parses each sub-flake's `flake.nix` for `<name>.url = "path:../<sibling>"` declarations and emits `--override-input <input_name> path:<abs>` 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:../<sibling>` 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 `<project>/.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 <profile>` sets `XDG_{CONFIG,DATA,CACHE}_HOME` to the profile root. |
| `.scaffold/basecamp/portable/<NN>-<name>.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/<ts>-setup-*.log`, `<ts>-install.log` | Build logs. |
Each `launch` also sets `LOGOS_PROFILE=<name>` 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** `<project>/.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 13, 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 `<NN>-<module_name>.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.<dep>.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.<name>]` with `flake = "<ref>#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 <ref>#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:../<sibling>` inputs not overridden | Multi-line `inputs.<name> = { url = "…"; … };` declaration with `url` on its own line. The line-level parser doesn't detect it. | Flatten to single-line `<name>.url = "path:../<sibling>";`. |
| 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/<profile>/` | 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/<ts>-setup-*.log``basecamp setup` build logs.
- `.scaffold/logs/<ts>-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 <path>`) 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.
+232
View File
@@ -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 <name> --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 `<program>.json` per program (e.g. `idl/lez_counter.json`).
- `methods/guest/src/bin/<program>.rs` and `src/bin/run_<program>.rs` exist (same convention as the default template).
- `src/lib.rs` declares `#[lez_program] mod <program> { ... }` 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/`:
```
<project>/
├── 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 <name> { … }` | Program-level scaffolding: instruction enum, dispatch, IDL constant `PROGRAM_IDL_JSON`. The mod name **is** the program name (must match `methods/guest/src/bin/<name>.rs`). |
| `#[instruction] pub fn <handler>(…)` | 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("<seed>"))]` | Account claimed (zero-balance, default visibility) at the literal PDA; `claim_if_default = true`. |
| `#[account(mut, pda = literal("<seed>"))]` | 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/<program>.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/<program>.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 <account-id>
cargo run --bin run_lez_counter -- increment --counter <account-id> --authority <account-id> --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 <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 <name>(…) -> 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/<program>.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_<program>.rs` with a new subcommand that uses the regenerated client.
5. Test: `lgs build && lgs deploy && cargo run --bin run_<program> -- <new-subcommand> …`.
## 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/<new_program>.rs`.
2. New `#[lez_program] mod <new_program> { … }` in `src/lib.rs` (or a sibling `.rs` file pulled in via `mod`).
3. New `src/bin/run_<new_program>.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/<name>.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.
+194
View File
@@ -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 <name>` (or `lgs new <name> --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):
```
<project>/
├── 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/<program>.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/<program>.bin
```
The `EXAMPLE_PROGRAMS_BUILD_DIR` env var conventionally captures this absolute path so example runners can pass `--program-path "$EXAMPLE_PROGRAMS_BUILD_DIR/<program>.bin"` for non-default builds.
**One file = one program.** Adding a new program is `methods/guest/src/bin/<name>.rs` + a host-side runner in `src/bin/run_<name>.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/<program>.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 "<path>" # explicit path; works without methods/
lgs deploy --program-path "<path>" --json
```
`lgs deploy` prints `program_id: <hex>` (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 <name> --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 -- <public_account_id>
cargo run --bin run_hello_world_private -- <private_account_id>
lgs wallet -- account sync-private # after private-account writes
cargo run --bin run_hello_world_with_authorization -- <public_account_id>
cargo run --bin run_hello_world_with_move_function -- write-public <public_account_id> "<text>"
cargo run --bin run_hello_world_with_move_function -- write-private <private_account_id> "<text>"
cargo run --bin run_hello_world_with_move_function -- move-data-public-to-private <public> <private>
cargo run --bin run_hello_world_through_tail_call -- <public_account_id>
cargo run --bin run_hello_world_through_tail_call_private -- <private_account_id>
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 <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" \
<public_account_id>
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" \
<private_account_id>
```
## Iteration Loop
1. Edit guest at `methods/guest/src/bin/<program>.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_<program>.rs`.
5. `cargo run --bin run_<program> -- <args>` to invoke against the running localnet.
6. `lgs wallet -- account get --account-id <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/<base58>"
lgs wallet -- account new private # → "Private/<base58>"
lgs wallet -- account list
lgs wallet -- account get --account-id <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/<base58>` 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 -- <args>`
- 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 <name> --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/<name>.rs`.** The basename is the program name `lgs deploy` recognises.
- **One host runner per program, named `src/bin/run_<name>.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.
+178
View File
@@ -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 <name>` / `create <name>` | 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 [<address>] [--dry-run]` | Auth-transfer init (if needed) + Piñata claim. Uses project default if address omitted. |
| Runtime | `wallet default set <address-ref>` | Persist project default wallet to `.scaffold/state/wallet.state`. |
| Runtime | `wallet -- <args>` | Raw passthrough to project-local wallet binary; preserves project wallet env. |
| Runtime | `spel -- <args>` | 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.<name>]` in `scaffold.toml`. |
| Modules | `basecamp install [--print-output]` | Build captured sources and install via `lgpm` into both profiles. |
| Modules | `basecamp launch <profile> [--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 <bash\|zsh>` | Print shell completion script (covers both `lgs` and `logos-scaffold`). |
| System | `help` / `<cmd> --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/<ts>-install.log` | `basecamp install` per-source nix build logs. |
| `.scaffold/logs/<ts>-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/<NN>-<name>.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-<unix-ts>.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: `<cache_root>/repos/<name>/<pin>/...` (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 <path>` 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 <pid>`, retry `localnet start`. |
| `sequencer process exited before becoming ready (pid=<pid>)` | Sequencer crashed at startup. | `lgs localnet logs --tail 200`; investigate root cause before retrying. |
| `localnet start timed out after <N>s` | Slow startup (e.g. risc0 dev mode). | Increase `--timeout-sec`; check logs. |
| `missing sequencer binary at <path>; run \`logos-scaffold setup\`` | Setup never ran or binaries got cleaned. | `lgs setup`. |
| `Not a logos-scaffold project ... Run logos-scaffold create <name>` | 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.<dep>.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 <ref>#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/<name>/`. |
## 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 "<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`:
- **D1D6** — default template (bootstrap, localnet/doctor, deploy variants, wallet, report, runner interaction).
- **L1L4** — lez-framework template (bootstrap, IDL regen, client regen, deploy + counter).
- **E1E2** — CLI surface (help/version/error quality, advanced `new` flags).
- **B1B5** — 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 <path>`) 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`.
+1 -1
View File
@@ -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
+143 -105
View File
@@ -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());
}
}
+3
View File
@@ -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(())
}
+62 -66
View File
@@ -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::<usize>().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
+1
View File
@@ -1,2 +1,3 @@
pub(crate) mod copy;
pub(crate) mod project;
pub(crate) mod skills;
+388
View File
@@ -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/<name>/SKILL.md` directly.
- **Cursor** reads `.cursor/rules/<name>.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`:
///
/// - `<target>/.claude/skills/<name>/SKILL.md` — verbatim canonical source
/// (Claude Code's native skill format).
/// - `<target>/.cursor/rules/<name>.mdc` — Cursor Project Rule with frontmatter
/// rewritten (`name:` dropped; `alwaysApply: false` added).
/// - `<target>/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/<other>/`
/// or `.cursor/rules/<other>.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<ParsedSkill> {
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<String> = 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<String> {
let mut names: Vec<String> = 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/<name>/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"
);
}
}
+67 -25
View File
@@ -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]