@@ -8,11 +8,11 @@
Napper is a free, open-source API testing tool that runs from the command line and integrates natively with VS Code. Define HTTP requests as plain text `.nap` files, add declarative assertions, chain them into test suites, and run everything in CI/CD with JUnit output. As simple as curl for quick requests. As powerful as F# and C# for full test suites.
-[Documentation](https://napperapi.dev) | [GitHub Repository](https://github.com/MelbourneDeveloper/napper) | [Releases](https://github.com/MelbourneDeveloper/napper/releases)
+[Documentation](https://napperapi.dev) | [GitHub Repository](https://github.com/Nimblesite/napper) | [Releases](https://github.com/Nimblesite/napper/releases)
---
-
+
---
@@ -37,7 +37,7 @@ code --install-extension nimblesite.napper
### Or grab the CLI binary
-Download from the [latest release](https://github.com/MelbourneDeveloper/napper/releases).
+Download from the [latest release](https://github.com/Nimblesite/napper/releases).
## How do you use Napper?
diff --git a/src/Napper.VsCode/package.json b/src/Napper.VsCode/package.json
index 49cc018..c94d1a3 100644
--- a/src/Napper.VsCode/package.json
+++ b/src/Napper.VsCode/package.json
@@ -7,11 +7,11 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "https://github.com/MelbourneDeveloper/napper"
+ "url": "https://github.com/Nimblesite/napper"
},
- "homepage": "https://github.com/MelbourneDeveloper/napper",
+ "homepage": "https://github.com/Nimblesite/napper",
"bugs": {
- "url": "https://github.com/MelbourneDeveloper/napper/issues"
+ "url": "https://github.com/Nimblesite/napper/issues"
},
"keywords": [
"api",
diff --git a/src/Napper.VsCode/src/constants.ts b/src/Napper.VsCode/src/constants.ts
index d928298..c29388b 100644
--- a/src/Napper.VsCode/src/constants.ts
+++ b/src/Napper.VsCode/src/constants.ts
@@ -142,9 +142,9 @@ export const PROP_FILE_PATH = 'filePath';
// CLI installer (binary download)
export const CLI_BINARY_NAME = 'napper';
export const CLI_BIN_DIR = 'bin';
-export const CLI_DOWNLOAD_REPO = 'MelbourneDeveloper/napper';
+export const CLI_DOWNLOAD_REPO = 'Nimblesite/napper';
export const CLI_DOWNLOAD_BASE_URL =
- 'https://github.com/MelbourneDeveloper/napper/releases/download';
+ 'https://github.com/Nimblesite/napper/releases/download';
export const CLI_CHECKSUMS_FILE = 'checksums-sha256.txt';
export const CLI_ASSET_PREFIX = 'napper-';
export const CLI_WIN_EXE_SUFFIX = '.exe';
diff --git a/website/src/_data/navigation.json b/website/src/_data/navigation.json
index cac1e53..70968b7 100644
--- a/website/src/_data/navigation.json
+++ b/website/src/_data/navigation.json
@@ -2,7 +2,7 @@
"main": [
{ "text": "Docs", "url": "/docs/" },
{ "text": "Blog", "url": "/blog/" },
- { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/napper", "external": true }
+ { "text": "GitHub", "url": "https://github.com/Nimblesite/napper", "external": true }
],
"docs": [
{
@@ -54,8 +54,8 @@
{
"title": "Community",
"items": [
- { "text": "GitHub", "url": "https://github.com/MelbourneDeveloper/napper" },
- { "text": "Issues", "url": "https://github.com/MelbourneDeveloper/napper/issues" },
+ { "text": "GitHub", "url": "https://github.com/Nimblesite/napper" },
+ { "text": "Issues", "url": "https://github.com/Nimblesite/napper/issues" },
{ "text": "VS Code Marketplace", "url": "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper" }
]
},
diff --git a/website/src/_data/site.json b/website/src/_data/site.json
index 26876e4..785e88c 100644
--- a/website/src/_data/site.json
+++ b/website/src/_data/site.json
@@ -7,7 +7,7 @@
"language": "en",
"themeColor": "#1B4965",
"stylesheet": "/assets/css/styles.css",
- "github": "https://github.com/MelbourneDeveloper/napper",
+ "github": "https://github.com/Nimblesite/napper",
"ogImage": "/assets/images/logo.png",
"ogImageWidth": "800",
"ogImageHeight": "800",
@@ -21,7 +21,7 @@
"url": "https://napperapi.dev",
"logo": "/assets/images/logo.png",
"sameAs": [
- "https://github.com/MelbourneDeveloper/napper",
+ "https://github.com/Nimblesite/napper",
"https://marketplace.visualstudio.com/items?itemName=nimblesite.napper"
]
}
diff --git a/website/src/blog/introducing-napper.md b/website/src/blog/introducing-napper.md
index 038bdac..37ead1a 100644
--- a/website/src/blog/introducing-napper.md
+++ b/website/src/blog/introducing-napper.md
@@ -14,7 +14,7 @@ keywords: "API testing, VS Code extension, C# scripting, F# scripting, CLI API t
API testing tools have a problem. They're either too simple ([.http files](/docs/vs-http-files/) with no assertions and no CLI) or too heavy ([Postman](/docs/vs-postman/) with its mandatory accounts, cloud sync, and paid tiers). [Bruno](/docs/vs-bruno/) moved the needle with git-friendly collections, but it's still a GUI-first tool with sandboxed JavaScript.
-**[Napper](https://github.com/MelbourneDeveloper/napper)** takes a different approach. It's a free, open-source API testing tool where the CLI is the primary interface, everything is stored as plain text, and you get full C# and F# scripting with access to the entire [.NET](https://dotnet.microsoft.com/) ecosystem.
+**[Napper](https://github.com/Nimblesite/napper)** takes a different approach. It's a free, open-source API testing tool where the CLI is the primary interface, everything is stored as plain text, and you get full C# and F# scripting with access to the entire [.NET](https://dotnet.microsoft.com/) ecosystem.
## The CLI is the product
@@ -31,7 +31,7 @@ napper run ./smoke.naplist
napper run ./tests/ --env staging --output junit > results.xml
```
-The CLI binary is self-contained with no runtime dependencies. It runs on Windows, macOS, and Linux. Download it from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases) and you're ready to go.
+The CLI binary is self-contained with no runtime dependencies. It runs on Windows, macOS, and Linux. Download it from [GitHub Releases](https://github.com/Nimblesite/napper/releases) and you're ready to go.
## Plain text everything — git-friendly by design
@@ -228,7 +228,7 @@ jobs:
- name: Download Napper CLI
run: |
- curl -L -o napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64
+ curl -L -o napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64
chmod +x napper
sudo mv napper /usr/local/bin/
@@ -307,4 +307,4 @@ code --install-extension nimblesite.napper
6. Write [C# scripts](/docs/csharp-scripting/) or [F# scripts](/docs/fsharp-scripting/) for advanced flows
7. Run everything in [CI/CD](/docs/ci-integration/) with JUnit XML output
-Napper is free, open source, and [MIT licensed](https://github.com/MelbourneDeveloper/napper/blob/main/LICENSE). Browse the source code and examples on [GitHub](https://github.com/MelbourneDeveloper/napper).
+Napper is free, open source, and [MIT licensed](https://github.com/Nimblesite/napper/blob/main/LICENSE). Browse the source code and examples on [GitHub](https://github.com/Nimblesite/napper).
From 1eb286656bed7f03df47ad42cfd16ac2407a6641 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 7 Apr 2026 16:55:54 +1000
Subject: [PATCH 03/48] Move docs
---
{specs => docs/plans}/CLI-PLAN.md | 0
{specs => docs/plans}/HTTP-FILES-PLAN.md | 0
{specs => docs/plans}/IDE-EXTENSION-PLAN.md | 0
{specs => docs/plans}/LSP-PLAN.md | 0
{specs => docs/plans}/ZED-EXTENSION-PLAN.md | 0
.../specs}/CLI-OPENAPI-GENERATION.md | 0
{specs => docs/specs}/CLI-SPEC.md | 0
{specs => docs/specs}/FILE-FORMATS-SPEC.md | 0
{specs => docs/specs}/HTTP-FILES-SPEC.md | 0
.../IDE-EXTENION-OPENAPI-GENERATION-SPEC.md | 0
{specs => docs/specs}/IDE-EXTENSION-SPEC.md | 2 +-
{specs => docs/specs}/LSP-SPEC.md | 0
{specs => docs/specs}/SCRIPTING-SPEC.md | 0
website/eleventy.config.js | 2 +-
website/src/docs/ci-integration.md | 4 ++--
website/src/docs/installation.md | 22 +++++++++----------
website/src/docs/openapi-import.md | 2 +-
website/src/index.njk | 4 ++--
18 files changed, 18 insertions(+), 18 deletions(-)
rename {specs => docs/plans}/CLI-PLAN.md (100%)
rename {specs => docs/plans}/HTTP-FILES-PLAN.md (100%)
rename {specs => docs/plans}/IDE-EXTENSION-PLAN.md (100%)
rename {specs => docs/plans}/LSP-PLAN.md (100%)
rename {specs => docs/plans}/ZED-EXTENSION-PLAN.md (100%)
rename {specs => docs/specs}/CLI-OPENAPI-GENERATION.md (100%)
rename {specs => docs/specs}/CLI-SPEC.md (100%)
rename {specs => docs/specs}/FILE-FORMATS-SPEC.md (100%)
rename {specs => docs/specs}/HTTP-FILES-SPEC.md (100%)
rename {specs => docs/specs}/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md (100%)
rename {specs => docs/specs}/IDE-EXTENSION-SPEC.md (97%)
rename {specs => docs/specs}/LSP-SPEC.md (100%)
rename {specs => docs/specs}/SCRIPTING-SPEC.md (100%)
diff --git a/specs/CLI-PLAN.md b/docs/plans/CLI-PLAN.md
similarity index 100%
rename from specs/CLI-PLAN.md
rename to docs/plans/CLI-PLAN.md
diff --git a/specs/HTTP-FILES-PLAN.md b/docs/plans/HTTP-FILES-PLAN.md
similarity index 100%
rename from specs/HTTP-FILES-PLAN.md
rename to docs/plans/HTTP-FILES-PLAN.md
diff --git a/specs/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md
similarity index 100%
rename from specs/IDE-EXTENSION-PLAN.md
rename to docs/plans/IDE-EXTENSION-PLAN.md
diff --git a/specs/LSP-PLAN.md b/docs/plans/LSP-PLAN.md
similarity index 100%
rename from specs/LSP-PLAN.md
rename to docs/plans/LSP-PLAN.md
diff --git a/specs/ZED-EXTENSION-PLAN.md b/docs/plans/ZED-EXTENSION-PLAN.md
similarity index 100%
rename from specs/ZED-EXTENSION-PLAN.md
rename to docs/plans/ZED-EXTENSION-PLAN.md
diff --git a/specs/CLI-OPENAPI-GENERATION.md b/docs/specs/CLI-OPENAPI-GENERATION.md
similarity index 100%
rename from specs/CLI-OPENAPI-GENERATION.md
rename to docs/specs/CLI-OPENAPI-GENERATION.md
diff --git a/specs/CLI-SPEC.md b/docs/specs/CLI-SPEC.md
similarity index 100%
rename from specs/CLI-SPEC.md
rename to docs/specs/CLI-SPEC.md
diff --git a/specs/FILE-FORMATS-SPEC.md b/docs/specs/FILE-FORMATS-SPEC.md
similarity index 100%
rename from specs/FILE-FORMATS-SPEC.md
rename to docs/specs/FILE-FORMATS-SPEC.md
diff --git a/specs/HTTP-FILES-SPEC.md b/docs/specs/HTTP-FILES-SPEC.md
similarity index 100%
rename from specs/HTTP-FILES-SPEC.md
rename to docs/specs/HTTP-FILES-SPEC.md
diff --git a/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md b/docs/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md
similarity index 100%
rename from specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md
rename to docs/specs/IDE-EXTENION-OPENAPI-GENERATION-SPEC.md
diff --git a/specs/IDE-EXTENSION-SPEC.md b/docs/specs/IDE-EXTENSION-SPEC.md
similarity index 97%
rename from specs/IDE-EXTENSION-SPEC.md
rename to docs/specs/IDE-EXTENSION-SPEC.md
index a3d238d..df61543 100644
--- a/specs/IDE-EXTENSION-SPEC.md
+++ b/docs/specs/IDE-EXTENSION-SPEC.md
@@ -354,7 +354,7 @@ These settings apply across all IDEs where the extension supports configuration.
- Built in **TypeScript** using the VSCode Extension API.
- The response panel webview uses a minimal framework (Lit or vanilla TS + CSS) — no heavy UI library.
- The extension shells out to the **Nap CLI** (`nap run --output json`) for all HTTP execution.
-- **CLI acquisition:** The VSIX installs the CLI via `dotnet tool install -g napper --version X.X.X` on activation, where `X.X.X` is the extension's own `package.json` version. This avoids raw binary downloads (which trigger Windows SmartScreen warnings on unsigned binaries) and leverages NuGet as a trusted distribution channel. If the CLI is already on PATH at the correct version, installation is skipped.
+- **CLI acquisition:** see [`vscode-cli-acquisition`](#vscode-cli-acquisition) below.
- File watching via `vscode.workspace.createFileSystemWatcher` keeps the panel tree up to date without polling.
- The `.nap` language grammar (TextMate `.tmLanguage.json`) is generated from the ANTLR grammar to avoid drift.
- Published to the **VS Code Marketplace** and the **Open VSX Registry** (for VSCodium / Cursor / Windsurf users).
diff --git a/specs/LSP-SPEC.md b/docs/specs/LSP-SPEC.md
similarity index 100%
rename from specs/LSP-SPEC.md
rename to docs/specs/LSP-SPEC.md
diff --git a/specs/SCRIPTING-SPEC.md b/docs/specs/SCRIPTING-SPEC.md
similarity index 100%
rename from specs/SCRIPTING-SPEC.md
rename to docs/specs/SCRIPTING-SPEC.md
diff --git a/website/eleventy.config.js b/website/eleventy.config.js
index 476e2ac..aea08c5 100644
--- a/website/eleventy.config.js
+++ b/website/eleventy.config.js
@@ -16,7 +16,7 @@ export default function (eleventyConfig) {
url: "https://napperapi.dev",
logo: "/assets/images/logo.png",
sameAs: [
- "https://github.com/MelbourneDeveloper/napper",
+ "https://github.com/Nimblesite/napper",
"https://marketplace.visualstudio.com/items?itemName=nimblesite.napper",
],
},
diff --git a/website/src/docs/ci-integration.md b/website/src/docs/ci-integration.md
index aa53717..85b0b6c 100644
--- a/website/src/docs/ci-integration.md
+++ b/website/src/docs/ci-integration.md
@@ -26,7 +26,7 @@ jobs:
- name: Download Napper CLI
run: |
- curl -L -o napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64
+ curl -L -o napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64
chmod +x napper
sudo mv napper /usr/local/bin/
@@ -48,7 +48,7 @@ api-tests:
stage: test
image: mcr.microsoft.com/dotnet/runtime:10.0
before_script:
- - curl -L -o /usr/local/bin/napper https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64
+ - curl -L -o /usr/local/bin/napper https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64
- chmod +x /usr/local/bin/napper
script:
- napper run ./tests/ --env ci --output junit > results.xml
diff --git a/website/src/docs/installation.md b/website/src/docs/installation.md
index 7b8ff94..b95c676 100644
--- a/website/src/docs/installation.md
+++ b/website/src/docs/installation.md
@@ -43,10 +43,10 @@ ext install nimblesite.napper
### Install a VSIX manually
-If you need a specific version or are working in an air-gapped environment, download the `.vsix` file from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases) and install it manually.
+If you need a specific version or are working in an air-gapped environment, download the `.vsix` file from [GitHub Releases](https://github.com/Nimblesite/napper/releases) and install it manually.
**Via the VS Code UI:**
-1. Download `napper-.vsix` from the [Releases page](https://github.com/MelbourneDeveloper/napper/releases)
+1. Download `napper-.vsix` from the [Releases page](https://github.com/Nimblesite/napper/releases)
2. Open the Extensions panel (`Ctrl+Shift+X` / `Cmd+Shift+X`)
3. Click the `...` menu (top-right of the panel)
4. Select **Install from VSIX...**
@@ -79,14 +79,14 @@ The CLI is a self-contained binary with **no runtime dependencies** — no .NET,
### Download from GitHub Releases
-Download the binary for your platform from [GitHub Releases](https://github.com/MelbourneDeveloper/napper/releases). The current release is **v0.10.0**.
+Download the binary for your platform from [GitHub Releases](https://github.com/Nimblesite/napper/releases). The current release is **v0.10.0**.
| Platform | Binary |
|----------|--------|
-| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-arm64) |
-| macOS (Intel) | [`napper-osx-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-x64) |
-| Linux (x64) | [`napper-linux-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64) |
-| Windows (x64) | [`napper-win-x64.exe`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-win-x64.exe) |
+| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-arm64) |
+| macOS (Intel) | [`napper-osx-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-osx-x64) |
+| Linux (x64) | [`napper-linux-x64`](https://github.com/Nimblesite/napper/releases/latest/download/napper-linux-x64) |
+| Windows (x64) | [`napper-win-x64.exe`](https://github.com/Nimblesite/napper/releases/latest/download/napper-win-x64.exe) |
**macOS / Linux — make it executable and move to PATH:**
```bash
@@ -104,18 +104,18 @@ Move `napper-win-x64.exe` to a folder on your `PATH`, or rename it to `napper.ex
The install script auto-detects your platform and verifies the SHA256 checksum:
```bash
-curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash
+curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash
```
Install a specific version:
```bash
-curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash -s 0.10.0
+curl -fsSL https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.sh | bash -s 0.10.0
```
### Install script (Windows)
```powershell
-irm https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.ps1 | iex
+irm https://raw.githubusercontent.com/Nimblesite/napper/main/scripts/install.ps1 | iex
```
Install a specific version:
@@ -128,7 +128,7 @@ Install a specific version:
If you have the .NET SDK and `make` installed, you can build from source:
```bash
-git clone https://github.com/MelbourneDeveloper/napper.git
+git clone https://github.com/Nimblesite/napper.git
cd napper
make install-binaries
```
diff --git a/website/src/docs/openapi-import.md b/website/src/docs/openapi-import.md
index 82e24a7..ecef820 100644
--- a/website/src/docs/openapi-import.md
+++ b/website/src/docs/openapi-import.md
@@ -292,7 +292,7 @@ napper run ./tests/petstore.naplist --output junit > results.xml
- Verify the spec is valid JSON. YAML is not supported yet — convert it first.
- Check that the spec is valid OpenAPI 3.x or Swagger 2.0 using the [Swagger Editor](https://editor.swagger.io/).
-- Some specs with complex `$ref` chains may not resolve correctly. Open an issue on [GitHub](https://github.com/MelbourneDeveloper/napper/issues) with the spec attached.
+- Some specs with complex `$ref` chains may not resolve correctly. Open an issue on [GitHub](https://github.com/Nimblesite/napper/issues) with the spec attached.
**URL import fails with a network error**
diff --git a/website/src/index.njk b/website/src/index.njk
index 4470120..54fc9e9 100644
--- a/website/src/index.njk
+++ b/website/src/index.njk
@@ -17,7 +17,7 @@ permalink: /
{# ---- Code Demo ---- #}
@@ -478,7 +478,7 @@ permalink: /
"priceCurrency": "USD"
},
"license": "https://opensource.org/licenses/MIT",
- "downloadUrl": "https://github.com/MelbourneDeveloper/napper/releases",
+ "downloadUrl": "https://github.com/Nimblesite/napper/releases",
"installUrl": "https://marketplace.visualstudio.com/items?itemName=nimblesite.napper",
"author": {
"@type": "Person",
From 4301be814f2d94c682660278688515c6bba70191 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 7 Apr 2026 16:59:45 +1000
Subject: [PATCH 04/48] docs
---
.claude/skills/ci-prep/SKILL.md | 107 +++++++++++++++++++++++++++++++
docs/plans/IDE-EXTENSION-PLAN.md | 35 +++++++++-
docs/specs/CLI-SPEC.md | 57 ++++++++++++++--
docs/specs/IDE-EXTENSION-SPEC.md | 29 +++++++++
4 files changed, 218 insertions(+), 10 deletions(-)
create mode 100644 .claude/skills/ci-prep/SKILL.md
diff --git a/.claude/skills/ci-prep/SKILL.md b/.claude/skills/ci-prep/SKILL.md
new file mode 100644
index 0000000..ecd8eeb
--- /dev/null
+++ b/.claude/skills/ci-prep/SKILL.md
@@ -0,0 +1,107 @@
+---
+name: ci-prep
+description: Prepares the current branch for CI by running the exact same steps locally and fixing issues. If CI is already failing, fetches the GH Actions logs first to diagnose. Use before pushing, when CI is red, or when the user says "fix ci".
+argument-hint: "[--failing] [optional job name to focus on]"
+---
+
+
+
+# CI Prep
+
+Prepare the current state for CI. If CI is already failing, fetch and analyze the logs first.
+
+## Arguments
+
+- `--failing` — Indicates a GitHub Actions run is already failing. When present, you MUST execute **Step 1** before doing anything else.
+- Any other argument is treated as a job name to focus on (but all failures are still reported).
+
+If `--failing` is NOT passed, skip directly to **Step 2**.
+
+## Step 1 — Fetch failed CI logs (only when `--failing`)
+
+You MUST do this before any other work.
+
+```bash
+BRANCH=$(git branch --show-current)
+PR_JSON=$(gh pr list --head "$BRANCH" --state open --json number,title,url --limit 1)
+```
+
+If the JSON array is empty, **stop immediately**:
+> No open PR found for branch `$BRANCH`. Create a PR first.
+
+Otherwise fetch the logs:
+
+```bash
+PR_NUMBER=$(echo "$PR_JSON" | jq -r '.[0].number')
+gh pr checks "$PR_NUMBER"
+RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId')
+gh run view "$RUN_ID"
+gh run view "$RUN_ID" --log-failed
+```
+
+Read **every line** of `--log-failed` output. For each failure note the exact file, line, and error message. If a job name argument was provided, prioritize that job but still report all failures.
+
+## Step 2 — Analyze the CI workflow
+
+1. Find the CI workflow file. Look in `.github/workflows/` for `ci.yml`, `build.yml`, `test.yml`, `checks.yml`, `main.yml`, `pull_request.yml`, or any workflow triggered on `pull_request` or `push`.
+2. Read the workflow file completely. Parse every job and every step.
+3. Extract the ordered list of commands the CI actually runs (e.g., `make lint`, `make fmt-check`, `make test`, `make coverage-check`, `make build`, or whatever the workflow specifies — it may use `npm`, `cargo`, `dotnet`, raw shell commands, or anything else).
+4. Note any environment variables, matrix strategies, or conditional steps that affect execution.
+
+**Do NOT assume the steps are `make lint`, `make test`, `make coverage-check`, `make build`.** The actual CI may run different commands, in a different order, with different targets. Extract what the CI *actually does*.
+
+## Step 3 — Run each CI step locally, in order
+
+Work through failures in this priority order:
+
+1. **Formatting** — run auto-formatters first to clear noise
+2. **Compilation errors** — must compile before lint/test
+3. **Lint violations** — fix the code pattern
+4. **Runtime / test failures** — fix source code to satisfy the test
+
+For each command extracted from the CI workflow:
+
+1. Run the command exactly as CI would run it (adjusting only for local environment differences like not needing `actions/checkout`).
+2. If the step fails, **stop and fix the issues** before continuing to the next step.
+3. After fixing, re-run the same step to confirm it passes.
+4. Move to the next step only after the current one succeeds.
+
+### Hard constraints
+
+- **NEVER modify test files** — fix the source code, not the tests
+- **NEVER add suppressions** (`#[allow(...)]`, `// eslint-disable`, `#pragma warning disable`)
+- **NEVER use `any` in TypeScript** to silence type errors
+- **NEVER delete or ignore failing tests**
+- **NEVER remove assertions**
+
+If stuck on the same failure after 5 attempts, ask the user for help.
+
+## Step 4 — Report
+
+- List every step that was run and its result (pass/fail/fixed).
+- If any step could not be fixed, report what failed and why.
+- Confirm whether the branch is ready to push.
+
+## Step 5 — Commit/Push (only when `--failing`)
+
+Once all CI steps pass locally:
+
+1. Commit, but DO NOT MARK THE COMMIT WITH YOU AS AN AUTHOR!!! Only the user authors the commit!
+2. Push
+3. Monitor until completion or failure
+4. Upon failure, go back to Step 1
+
+## Rules
+
+- **Always read the CI workflow first.** Never assume what commands CI runs.
+- Do not push if any step fails (unless `--failing` and all steps now pass)
+- Fix issues found in each step before moving to the next
+- Never skip steps or suppress errors
+- If the CI workflow has multiple jobs, run all of them (respecting dependency order)
+- Skip steps that are CI-infrastructure-only (checkout, setup-node/python/rust actions, cache steps, artifact uploads) — focus on the actual build/test/lint commands
+
+## Success criteria
+
+- Every command that CI runs has been executed locally and passed
+- All fixes are applied to the working tree
+- The CI passes successfully (if you are correcting and existing failure)
diff --git a/docs/plans/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md
index 5c61ad1..6c99090 100644
--- a/docs/plans/IDE-EXTENSION-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-PLAN.md
@@ -38,7 +38,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
### Phase 4 — Polish & Distribution
-- **CLI installation via `dotnet tool install`** — replace raw binary download with `dotnet tool install -g napper --version X.X.X`. Version is read from the extension's own `package.json`. Eliminates Windows SmartScreen warnings and custom HTTP download code.
+- **CLI installation via `dotnet tool install`** — replace raw binary download with the multi-step algorithm specified in [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition). Probe PATH → ensure `dotnet` is installed (via brew / scoop / choco if missing) → `dotnet tool install -g napper --version X.X.X` → tank hard with manual instructions if any step fails. Version comes from the extension's own `package.json`. Eliminates Windows SmartScreen warnings, deletes the custom HTTP download code, and never silently downloads binaries.
- Split editor layout (request panel webview)
- New request guided flow
- OpenAPI generation command
@@ -73,13 +73,42 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
- [ ] Run ALL existing VSIX e2e tests — must pass
### Phase 4 — Polish & Distribution
-- [ ] Replace raw binary download with `dotnet tool install -g napper --version X.X.X`
-- [ ] Delete custom HTTP download code (`cliInstaller.ts` download/redirect logic)
+
+#### CLI install flow rewrite (see [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition))
+
+- [ ] Step 1 — Probe PATH: read VSIX version from `package.json`, run ` --version`, exact-match against VSIX version
+- [ ] Step 2 — Probe `dotnet --version`
+- [ ] Step 3 — Detect package manager: `brew` on macOS/Linux; `scoop` then `choco` on Windows
+- [ ] Step 3 — Install .NET SDK via detected package manager (`brew install --cask dotnet-sdk`, `brew install dotnet-sdk`, `scoop bucket add extras && scoop install dotnet-sdk`, or `choco install dotnet-sdk -y`)
+- [ ] Step 3 — When no package manager is detected, show `vscode-cli-acq-pm-prompt` notification with links to brew.sh / scoop.sh / chocolatey.org
+- [ ] Step 3 — When `dotnet` is still missing after install (PATH not refreshed), show "restart VS Code" notification
+- [ ] Step 4 — `dotnet tool install -g napper --version ` (or `dotnet tool update -g …` if already present)
+- [ ] Step 4 — Re-probe `napper --version` against VSIX version
+- [ ] Step 5 — Tank notification with "Open install guide / Open GitHub release / Open output log" buttons
+- [ ] Wrap steps 3 and 4 in `vscode.window.withProgress` (`ProgressLocation.Notification`, non-cancellable)
+- [ ] Stream all spawned-process stdout/stderr to the Napper output channel
+- [ ] Delete `src/Napper.VsCode/src/cliInstaller.ts` raw binary download + redirect-following + checksum verification code
+- [ ] Delete the related constants in `src/Napper.VsCode/src/constants.ts` (`CLI_DOWNLOAD_BASE_URL`, `CLI_CHECKSUMS_FILE`, `CLI_ASSET_PREFIX`, `CLI_RID_*`, `CLI_PLATFORM_*`, `CLI_ARCH_*`, `CLI_MAX_REDIRECTS`, `CLI_TOO_MANY_REDIRECTS`, `CLI_REDIRECT_ERROR`, `CLI_FILE_MODE_EXECUTABLE`, `CLI_CHECKSUM_*`, `CLI_DOWNLOAD_ERROR_PREFIX`, `CLI_UNSUPPORTED_PLATFORM_MSG`)
+- [ ] Delete `tests/cliInstaller.unit.test.ts` (or whatever the unit tests are named) and replace with tests against the new resolver — mocking `child_process.execFile` to assert the right commands run in the right order with the right `--version` argument
+- [ ] Add e2e test: VSIX activates with `nap.cliPath` pointing at a stub binary that prints the VSIX version → step 1 succeeds, no other steps run
+- [ ] Add e2e test: VSIX activates with no CLI on PATH and a stub `dotnet` that prints `10.0.100` and a stub `dotnet tool install` that creates a stub `napper` printing the VSIX version → steps 1 fail, 2 success, 4 success
+- [ ] Add e2e test: VSIX activates with no CLI, no dotnet, no brew → tank notification appears with the correct buttons
+
+#### Other Phase 4 work
- [ ] Split editor layout (request panel webview)
- [ ] New request guided flow
- [ ] OpenAPI generation command
- [ ] Publish to VS Code Marketplace and Open VSX Registry
+### Phase 5 — AOT migration impact (see [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration))
+
+When the CLI migrates to NativeAOT, the install flow collapses dramatically. These items are blocked on the AOT migration landing in `Napper.Cli` and the release workflow producing AOT binaries.
+
+- [ ] Delete steps 2 and 3 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) — no more `dotnet --version` probe, no more brew/scoop/choco-install-dotnet branch
+- [ ] Replace step 4 (`dotnet tool install`) with `brew install napper` / `scoop install napper` (still version-mismatch tolerant: probe and tank if not exact)
+- [ ] Document in [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) that user `.fsx` / `.csx` script hooks still require the .NET SDK separately (the AOT migration drops the dependency for the CLI's own execution, not for user scripts)
+- [ ] Remove the `vscode-cli-acq-pm-prompt` notification path — package managers become optional convenience, not a hard prerequisite
+
---
## Related Specs
diff --git a/docs/specs/CLI-SPEC.md b/docs/specs/CLI-SPEC.md
index cd53220..8c6dbdc 100644
--- a/docs/specs/CLI-SPEC.md
+++ b/docs/specs/CLI-SPEC.md
@@ -22,25 +22,68 @@ Nap is a developer-first HTTP testing tool. It is as simple as curl for one-off
## Installation
-The Napper CLI is distributed as a **dotnet tool** via NuGet. This is the primary distribution channel — it avoids code-signing requirements (no Windows SmartScreen warnings), works cross-platform, and integrates with existing .NET toolchains.
+The Napper CLI is distributed through three channels. The **canonical** channel is `dotnet tool` via NuGet — it is the only channel that supports installing an arbitrary historical version of `napper`, and it is what the VSIX uses internally to guarantee an exact version match against the extension version. The Homebrew tap and Scoop bucket exist for end users who prefer their native package manager and are willing to accept "latest from tap".
+
+### `cli-install-dotnet-tool` — dotnet tool (canonical)
```sh
# Install globally
dotnet tool install -g napper
# Install a specific version
-dotnet tool install -g napper --version 0.6.0
+dotnet tool install -g napper --version 0.12.0
# Update to latest
dotnet tool update -g napper
```
-The VSIX extension installs the CLI automatically via `dotnet tool install` on activation, using the extension's own version to determine which CLI version to install. Users with the CLI already on PATH (or configured via `nap.cliPath`) skip the auto-install.
+This requires the **.NET 10 SDK** (see [`cli-runtime-dependency`](#cli-runtime-dependency)). The dotnet tool channel is the only one that supports `--version` pinning to an arbitrary historical release. The VSIX extension uses this channel exclusively to install the CLI — see [`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition).
+
+### `cli-install-homebrew` — Homebrew tap (macOS / Linux)
+
+```sh
+brew tap Nimblesite/tap
+brew install napper
+```
+
+The `Nimblesite/homebrew-tap` formula tracks the latest Napper release. It always installs the most recent version published to the tap by the release workflow ([`update-homebrew` job in `.github/workflows/release.yml`](../../.github/workflows/release.yml)). Homebrew does not support pinning to an arbitrary historical version with a single-formula tap, so users who need an exact older version should use the dotnet tool channel.
+
+### `cli-install-scoop` — Scoop bucket (Windows)
+
+```sh
+scoop bucket add Nimblesite https://github.com/Nimblesite/scoop-bucket
+scoop install napper
+```
+
+The `Nimblesite/scoop-bucket` manifest tracks the latest Napper release. It always installs the most recent version published to the bucket by the release workflow ([`update-scoop` job in `.github/workflows/release.yml`](../../.github/workflows/release.yml)). Scoop's `@version` syntax requires the bucket to maintain an `archived/` versions folder, which the simple manifest pattern does not, so users who need an exact older version should use the dotnet tool channel.
+
+### `cli-runtime-dependency` — Current runtime dependency
+
+The Napper CLI is currently a self-contained, trimmed, single-file `dotnet publish` binary targeting **`.NET 10` (`net10.0`)**. The published binary bundles the .NET runtime, so end users do **not** need .NET installed to run `napper run …`. **However**, the canonical install channel (`dotnet tool install`) requires the .NET 10 SDK to be present at install time.
+
+### `cli-aot-migration` — Future: drop the .NET dependency entirely
+
+**This is a hard requirement, not a stretch goal.** Eventually the Napper CLI MUST be migrated off the .NET runtime dependency by switching to **NativeAOT** (`PublishAot=true`). The end state:
+
+- `napper` ships as a single statically-linked native executable per RID, with **zero runtime dependencies** — no .NET SDK, no .NET runtime, no JIT.
+- The dotnet tool channel can be **deprecated** (or kept as a convenience for .NET developers) once Homebrew, Scoop, and a NativeAOT-built standalone binary are the primary channels.
+- Brew and Scoop install the AOT binary directly, with no .NET prerequisite — the VSIX install flow ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)) collapses to "PATH probe → brew/scoop install → tank", with no `dotnet tool` step at all.
+- The release workflow's `build-cli` matrix continues to produce raw binaries and archives, but the published binaries are AOT-compiled instead of self-contained .NET.
+
+**Why this is non-negotiable:**
+
+- **Install size**: Self-contained .NET trimmed publishes are ~17–20 MB per RID. NativeAOT binaries for an F# CLI of this scope target ~5–10 MB.
+- **Cold start**: NativeAOT eliminates JIT warmup, dropping `napper --version` start time from ~150 ms to ~10 ms. Critical for editor integration where the VSIX spawns the CLI on every save.
+- **Install friction**: The dotnet tool channel requires the .NET 10 SDK as a prerequisite. The VSIX currently has to install the SDK via brew/scoop/choco on first activation if it is missing — see [`vscode-cli-acq-install-dotnet`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition). After AOT migration, this entire branch of the install algorithm disappears.
+- **Distribution**: AOT binaries are signable and notarisable per-platform. The dotnet tool path delegates trust to NuGet but still ships unsigned native code at runtime.
+
+**Known blockers / risks:**
+
+- F# AOT support is functional but has rougher edges than C# AOT — particularly around `printf` family functions, reflection-based serialisation, and quotations. Any code path that uses runtime reflection will fail at trim/publish time and must be rewritten.
+- F# scripting hooks (`.fsx`) and C# scripting hooks (`.csx`) executed via `dotnet fsi`/`dotnet-script` will continue to require the .NET SDK on the user's machine **regardless** of whether `napper` itself is AOT-compiled. The AOT migration drops the dependency for the CLI's own execution; it does **not** drop it for user scripts. This is acceptable — script-using projects already need .NET — but should be documented prominently.
+- The third-party libraries Napper depends on (TOML parser, JSONPath, etc.) must all be AOT-compatible. Audit before migration.
-**Future channels** (not yet implemented):
-- Homebrew formula (`brew install napper`)
-- Winget / Chocolatey / Scoop packages
-- Standalone native binary (NativeAOT single-file publish)
+**Migration is tracked in [CLI-PLAN.md](../plans/CLI-PLAN.md).**
---
diff --git a/docs/specs/IDE-EXTENSION-SPEC.md b/docs/specs/IDE-EXTENSION-SPEC.md
index df61543..e9bac7f 100644
--- a/docs/specs/IDE-EXTENSION-SPEC.md
+++ b/docs/specs/IDE-EXTENSION-SPEC.md
@@ -359,6 +359,35 @@ These settings apply across all IDEs where the extension supports configuration.
- The `.nap` language grammar (TextMate `.tmLanguage.json`) is generated from the ANTLR grammar to avoid drift.
- Published to the **VS Code Marketplace** and the **Open VSX Registry** (for VSCodium / Cursor / Windsurf users).
+#### `vscode-cli-acquisition` — CLI install resolution
+
+The CLI version MUST exactly match the VSIX `package.json` version. The VSIX is the source of truth. The canonical channel is `dotnet tool install -g napper --version X` because it is the only channel that pins to an arbitrary historical version. Brew/scoop/choco are used **only to install the .NET SDK prerequisite** — never `napper` itself. The VSIX MUST NOT download binaries directly over HTTPS.
+
+Resolution runs on activation, idempotent, first match wins:
+
+1. **`vscode-cli-acq-path-probe`** — ` --version` equals VSIX version → done.
+2. **`vscode-cli-acq-dotnet-probe`** — `dotnet --version` succeeds → skip to 4.
+3. **`vscode-cli-acq-install-dotnet`** — Install .NET SDK via package manager:
+
+ | OS | Detect | Command |
+ |---------|--------|---------|
+ | macOS | `brew` | `brew install --cask dotnet-sdk` |
+ | Linux | `brew` | `brew install dotnet-sdk` |
+ | Windows | `scoop` | `scoop bucket add extras && scoop install dotnet-sdk` |
+ | Windows | `choco` | `choco install dotnet-sdk -y` |
+
+ No detected package manager → `vscode-cli-acq-pm-prompt`. After install, if `dotnet` still not on PATH (process env not refreshed), prompt user to restart VS Code.
+4. **`vscode-cli-acq-dotnet-tool-install`** — `dotnet tool install -g napper --version ` (or `update -g` if present), re-probe.
+5. **`vscode-cli-acq-tank`** — Hard error notification with buttons: **Open install guide** (`https://napperapi.dev/docs/installation/`), **Open GitHub release** (`…/releases/tag/v`), **Open output log**. CLI-dependent commands fail with the same message until resolved.
+
+`vscode-cli-acq-pm-prompt` — When no package manager is detected: notification with link buttons to `brew.sh` (mac/Linux) or `scoop.sh` + `chocolatey.org/install` (Windows), plus **Open install guide**.
+
+`vscode-cli-acq-progress` — Steps 3 and 4 run inside `vscode.window.withProgress` (`ProgressLocation.Notification`, non-cancellable). All spawned process stdout/stderr streams to the Napper output channel. No terminal windows.
+
+`vscode-cli-acq-tap-coexist` — Users can `brew install napper` / `scoop install napper` themselves via [`Nimblesite/homebrew-tap`](https://github.com/Nimblesite/homebrew-tap) and [`Nimblesite/scoop-bucket`](https://github.com/Nimblesite/scoop-bucket). If the user-installed version matches, step 1 finds it and the chain stops. If not, step 4 installs the matching version alongside; the VSIX never touches the user-managed binary.
+
+> When [`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration) lands, steps 2–3 disappear and step 4 becomes `brew install napper` / `scoop install napper` directly.
+
### Zed
- Built in **Rust**, compiled to **WebAssembly** via `zed_extension_api` crate.
From 6ba6e0c0bce482806b3cd048b8046d4e22d565cc Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 7 Apr 2026 17:12:29 +1000
Subject: [PATCH 05/48] doc update
---
.claude/skills/code-dedup/SKILL.md | 110 ++++++++++++++++++
.claude/skills/submit-pr/SKILL.md | 84 +++++--------
...d-claude-md.md => 00-read-instructions.md} | 0
Claude.md | 3 +
docs/plans/IDE-EXTENSION-PLAN.md | 41 ++-----
docs/specs/CLI-SPEC.md | 54 +++------
6 files changed, 173 insertions(+), 119 deletions(-)
create mode 100644 .claude/skills/code-dedup/SKILL.md
rename .clinerules/{00-read-claude-md.md => 00-read-instructions.md} (100%)
diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md
new file mode 100644
index 0000000..0ce6c1b
--- /dev/null
+++ b/.claude/skills/code-dedup/SKILL.md
@@ -0,0 +1,110 @@
+---
+name: code-dedup
+description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code.
+---
+
+
+
+# Code Dedup
+
+Carefully search for duplicate code, duplicate tests, and dead code across the repo. Merge duplicates and delete dead code — but only when test coverage proves the change is safe.
+
+## Prerequisites — hard gate
+
+Before touching ANY code, verify these conditions. If any fail, stop and report why.
+
+1. Run `make test` — all tests must pass. If tests fail, stop. Do not dedup a broken codebase.
+2. Run `make coverage-check` — coverage must meet the repo's threshold. If it doesn't, stop.
+3. Verify the project uses **static typing**. The Napper repo is fully statically typed (F#, TypeScript strict mode, Rust). Proceed.
+
+## Steps
+
+Copy this checklist and track progress:
+
+```
+Dedup Progress:
+- [ ] Step 1: Prerequisites passed (tests green, coverage met, typed)
+- [ ] Step 2: Dead code scan complete
+- [ ] Step 3: Duplicate code scan complete
+- [ ] Step 4: Duplicate test scan complete
+- [ ] Step 5: Changes applied
+- [ ] Step 6: Verification passed (tests green, coverage stable)
+```
+
+### Step 1 — Inventory test coverage
+
+Before deciding what to touch, understand what is tested.
+
+1. Run `make test` and `make coverage-check` to confirm green baseline
+2. Note the current coverage percentage — this is the floor. It must not drop.
+3. Identify which files/modules have coverage and which do not. Only files WITH coverage are candidates for dedup.
+
+### Step 2 — Scan for dead code
+
+Search for code that is never called, never imported, never referenced.
+
+1. Look for unused exports, unused functions, unused classes, unused variables
+2. Use language-appropriate tools where available:
+ - Rust: the compiler already warns on dead code — check `make lint` output
+ - TypeScript: check for `noUnusedLocals`/`noUnusedParameters` in tsconfig, look for unexported functions with zero references
+ - F#/C#: analyzer warnings for unused members
+3. For each candidate: **grep the entire codebase** for references (including tests, scripts, configs). Only mark as dead if truly zero references.
+4. List all dead code found with file paths and line numbers. Do NOT delete yet.
+
+### Step 3 — Scan for duplicate code
+
+Search for code blocks that do the same thing in multiple places.
+
+1. Look for functions/methods with identical or near-identical logic
+2. Look for copy-pasted blocks (same structure, maybe different variable names)
+3. Look for multiple implementations of the same algorithm or pattern
+4. Check across module boundaries — duplicates often hide in different packages/crates/projects. **For Napper specifically, check whether F# logic in Napper.Cli, Napper.Lsp, or Napper.VsCode could move into Napper.Core.**
+5. For each duplicate pair: note both locations, what they do, and how they differ (if at all)
+6. List all duplicates found. Do NOT merge yet.
+
+### Step 4 — Scan for duplicate tests
+
+Search for tests that verify the same behavior.
+
+1. Look for test functions with identical assertions against the same code paths
+2. Look for test fixtures/helpers that are duplicated across test files
+3. Look for integration tests that fully cover what a unit test also covers (keep the integration test, mark the unit test as redundant per CLAUDE.md rules)
+4. List all duplicate tests found. Do NOT delete yet.
+
+### Step 5 — Apply changes (one at a time)
+
+For each change, follow this cycle: **change → test → verify coverage → continue or revert**.
+
+#### 5a. Remove dead code
+- Delete dead code identified in Step 2
+- After each deletion: run `make test` and `make coverage-check`
+- If tests fail or coverage drops: **revert immediately** and investigate
+- Dead code removal should never break tests or drop coverage
+
+#### 5b. Merge duplicate code
+- For each duplicate pair: extract the shared logic into a single function/module
+- Update all call sites to use the shared version
+- After each merge: run `make test` and `make coverage-check`
+- If tests fail: **revert immediately**. The duplicates may have subtle differences you missed.
+- If coverage drops: the shared code must have equivalent test coverage. Add tests if needed before proceeding.
+
+#### 5c. Remove duplicate tests
+- Delete the redundant test (keep the more thorough one)
+- After each deletion: run `make coverage-check`
+- If coverage drops: **revert immediately**. The "duplicate" test was covering something the other wasn't.
+
+### Step 6 — Final verification
+
+1. Run `make test` — all tests must still pass
+2. Run `make coverage-check` — coverage must be >= the baseline from Step 1
+3. Run `make lint` and `make fmt-check` — code must be clean
+4. Report: what was removed, what was merged, final coverage vs baseline
+
+## Rules
+
+- **No test coverage = do not touch.** If a file has no tests covering it, leave it alone entirely. You cannot safely dedup what you cannot verify.
+- **Coverage must not drop.** If removing or merging code causes coverage to decrease, revert and investigate. The coverage floor from Step 1 is sacred.
+- **One change at a time.** Make one dedup change, run tests, verify coverage. Never batch multiple dedup changes before testing.
+- **When in doubt, leave it.** If two code blocks look similar but you're not 100% sure they're functionally identical, leave both. False dedup is worse than duplication.
+- **Preserve public API surface.** Do not change function signatures, class names, or module exports that external code depends on. Internal refactoring only.
+- **Three similar lines is fine.** Do not create abstractions for trivial duplication. The cure must not be worse than the disease. Only dedup when the shared logic is substantial (>10 lines) or when there are 3+ copies.
diff --git a/.claude/skills/submit-pr/SKILL.md b/.claude/skills/submit-pr/SKILL.md
index c6cb432..2b733f4 100644
--- a/.claude/skills/submit-pr/SKILL.md
+++ b/.claude/skills/submit-pr/SKILL.md
@@ -1,63 +1,39 @@
---
name: submit-pr
-description: Create and submit a GitHub pull request using the diff against main
+description: Creates a pull request with a well-structured description after verifying CI passes. Use when the user asks to submit, create, or open a pull request.
disable-model-invocation: true
-allowed-tools: Bash(git *), Bash(gh *)
---
-# Submit Pull Request
+
-Create a GitHub pull request for the current branch.
+# Submit PR
-## Steps
-
-1. Get the diff against the latest LOCAL main branch commit:
-
-```
-git diff main...HEAD
-```
-
-2. Read the diff output carefully. Do NOT look at commit messages. The diff is the only source of truth for what changed.
-
-3. Check if there's a related GitHub issue. Look for issue references in the branch name (e.g. `42-fix-bug` or `issue-42`). If found, fetch the issue title:
-
-```
-gh issue view --json title -q .title
-```
-
-4. Write the PR content using the project's PR template
-
-You read the file at .github/PULL_REQUEST_TEMPLATE.md
-
-Keep content TIGHT. Don't add waffle.
+Create a pull request for the current branch with a well-structured description.
-5. Construct the PR title:
-- If an issue number was found: `#: `
-- Otherwise: ``
-- Keep under 70 characters
-
-6. Commit changes and push the current branch if needed:
-
-```
-git push -u origin HEAD
-```
-
-DO NOT include yourself as a a coauthor!
-
-7. Create the PR using `gh`:
-
-```
-gh pr create --title "" --body "$(cat <<'EOF'
-# TLDR;
-
-
-# Details
-
-
-# How do the tests prove the change works
-
-EOF
-)"
-```
+## Steps
-8. Return the PR URL to the user.
+1. Run `make ci` — must pass completely before creating PR
+2. **Generate the diff against main.** Run `git diff main...HEAD > /tmp/pr-diff.txt` to capture the full diff between the current branch and the head of main. This is the ONLY source of truth for what the PR contains. **Warning:** the diff can be very large. If the diff file exceeds context limits, process it in chunks (e.g., read sections with `head`/`tail` or split by file) rather than trying to load it all at once.
+3. **Derive the PR title and description SOLELY from the diff.** Read the diff output and summarize what changed. Ignore commit messages, branch names, and any other metadata — only the actual code/content diff matters.
+4. Write PR body using the template in `.github/pull_request_template.md`
+5. Fill in (based on the diff analysis from step 3):
+ - TLDR: one sentence
+ - What Was Added: new files, features, deps
+ - What Was Changed/Deleted: modified behaviour
+ - How Tests Prove It Works: specific test names or output
+ - Spec/Doc Changes: if any
+ - Breaking Changes: yes/no + description
+6. Use `gh pr create` with the filled template
+
+## Rules
+
+- Never create a PR if `make ci` fails
+- PR description must be specific and tight — no vague placeholders
+- Link to the relevant GitHub issue if one exists
+- DO NOT include yourself as a co-author
+
+## Success criteria
+
+- `make ci` passed
+- PR created with `gh pr create`
+- PR URL returned to user
diff --git a/.clinerules/00-read-claude-md.md b/.clinerules/00-read-instructions.md
similarity index 100%
rename from .clinerules/00-read-claude-md.md
rename to .clinerules/00-read-instructions.md
diff --git a/Claude.md b/Claude.md
index 753fb76..2f4b042 100644
--- a/Claude.md
+++ b/Claude.md
@@ -1,3 +1,5 @@
+
+
## Too Many Cooks
You are working with many other agents. Make sure there is effective cooperation
@@ -23,6 +25,7 @@ You are working with many other agents. Make sure there is effective cooperation
- **Keep files under 450 LOC and functions under 20 LOC**
- **No commented-out code** - Delete it
- **No placeholders** - If incomplete, leave LOUD compilation error with TODO
+- **Spec IDs are hierarchical, descriptive, and non-numeric.** Every spec section MUST have a unique ID in the format `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]` (e.g., `[CLI-PARSE-NAP]`, `[LSP-COMPLETION-VARS]`, `[HTTP-REQ-HEADERS]`). The first word is the **group** — all sections in the same group MUST be adjacent in the spec's TOC. NEVER use sequential numbers like `[SPEC-001]`. All code, tests, and design docs that implement a spec section MUST reference its ID in a comment (e.g., `// Implements [LSP-COMPLETION-VARS]`).
### Rust
diff --git a/docs/plans/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md
index 6c99090..46cf9e5 100644
--- a/docs/plans/IDE-EXTENSION-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-PLAN.md
@@ -38,7 +38,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
### Phase 4 — Polish & Distribution
-- **CLI installation via `dotnet tool install`** — replace raw binary download with the multi-step algorithm specified in [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition). Probe PATH → ensure `dotnet` is installed (via brew / scoop / choco if missing) → `dotnet tool install -g napper --version X.X.X` → tank hard with manual instructions if any step fails. Version comes from the extension's own `package.json`. Eliminates Windows SmartScreen warnings, deletes the custom HTTP download code, and never silently downloads binaries.
+- **CLI install resolver** — implement [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); delete `cliInstaller.ts`.
- Split editor layout (request panel webview)
- New request guided flow
- OpenAPI generation command
@@ -74,40 +74,23 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
### Phase 4 — Polish & Distribution
-#### CLI install flow rewrite (see [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition))
-
-- [ ] Step 1 — Probe PATH: read VSIX version from `package.json`, run ` --version`, exact-match against VSIX version
-- [ ] Step 2 — Probe `dotnet --version`
-- [ ] Step 3 — Detect package manager: `brew` on macOS/Linux; `scoop` then `choco` on Windows
-- [ ] Step 3 — Install .NET SDK via detected package manager (`brew install --cask dotnet-sdk`, `brew install dotnet-sdk`, `scoop bucket add extras && scoop install dotnet-sdk`, or `choco install dotnet-sdk -y`)
-- [ ] Step 3 — When no package manager is detected, show `vscode-cli-acq-pm-prompt` notification with links to brew.sh / scoop.sh / chocolatey.org
-- [ ] Step 3 — When `dotnet` is still missing after install (PATH not refreshed), show "restart VS Code" notification
-- [ ] Step 4 — `dotnet tool install -g napper --version ` (or `dotnet tool update -g …` if already present)
-- [ ] Step 4 — Re-probe `napper --version` against VSIX version
-- [ ] Step 5 — Tank notification with "Open install guide / Open GitHub release / Open output log" buttons
-- [ ] Wrap steps 3 and 4 in `vscode.window.withProgress` (`ProgressLocation.Notification`, non-cancellable)
-- [ ] Stream all spawned-process stdout/stderr to the Napper output channel
-- [ ] Delete `src/Napper.VsCode/src/cliInstaller.ts` raw binary download + redirect-following + checksum verification code
-- [ ] Delete the related constants in `src/Napper.VsCode/src/constants.ts` (`CLI_DOWNLOAD_BASE_URL`, `CLI_CHECKSUMS_FILE`, `CLI_ASSET_PREFIX`, `CLI_RID_*`, `CLI_PLATFORM_*`, `CLI_ARCH_*`, `CLI_MAX_REDIRECTS`, `CLI_TOO_MANY_REDIRECTS`, `CLI_REDIRECT_ERROR`, `CLI_FILE_MODE_EXECUTABLE`, `CLI_CHECKSUM_*`, `CLI_DOWNLOAD_ERROR_PREFIX`, `CLI_UNSUPPORTED_PLATFORM_MSG`)
-- [ ] Delete `tests/cliInstaller.unit.test.ts` (or whatever the unit tests are named) and replace with tests against the new resolver — mocking `child_process.execFile` to assert the right commands run in the right order with the right `--version` argument
-- [ ] Add e2e test: VSIX activates with `nap.cliPath` pointing at a stub binary that prints the VSIX version → step 1 succeeds, no other steps run
-- [ ] Add e2e test: VSIX activates with no CLI on PATH and a stub `dotnet` that prints `10.0.100` and a stub `dotnet tool install` that creates a stub `napper` printing the VSIX version → steps 1 fail, 2 success, 4 success
-- [ ] Add e2e test: VSIX activates with no CLI, no dotnet, no brew → tank notification appears with the correct buttons
-
-#### Other Phase 4 work
+CLI install rewrite — implement [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition):
+
+- [ ] Implement steps 1–5 of the resolver in a new module; delete `src/Napper.VsCode/src/cliInstaller.ts` and its raw-download/checksum constants
+- [ ] Wrap steps 3 and 4 in `vscode.window.withProgress`; stream all spawned process I/O to the Napper output channel
+- [ ] Unit tests: mock `execFile` and assert the exact command sequence per OS (PATH match / dotnet present / dotnet missing+brew / dotnet missing+no PM / install fails → tank)
+- [ ] E2e tests: stub `napper` / `dotnet` / package manager binaries on PATH and assert the right resolution path runs
+
+Other Phase 4:
- [ ] Split editor layout (request panel webview)
- [ ] New request guided flow
- [ ] OpenAPI generation command
- [ ] Publish to VS Code Marketplace and Open VSX Registry
-### Phase 5 — AOT migration impact (see [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration))
-
-When the CLI migrates to NativeAOT, the install flow collapses dramatically. These items are blocked on the AOT migration landing in `Napper.Cli` and the release workflow producing AOT binaries.
+### Phase 5 — AOT collapse (blocked on [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration))
-- [ ] Delete steps 2 and 3 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) — no more `dotnet --version` probe, no more brew/scoop/choco-install-dotnet branch
-- [ ] Replace step 4 (`dotnet tool install`) with `brew install napper` / `scoop install napper` (still version-mismatch tolerant: probe and tank if not exact)
-- [ ] Document in [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) that user `.fsx` / `.csx` script hooks still require the .NET SDK separately (the AOT migration drops the dependency for the CLI's own execution, not for user scripts)
-- [ ] Remove the `vscode-cli-acq-pm-prompt` notification path — package managers become optional convenience, not a hard prerequisite
+- [ ] Drop steps 2–3 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); replace step 4 with `brew install napper` / `scoop install napper`
+- [ ] Drop the `vscode-cli-acq-pm-prompt` path
---
diff --git a/docs/specs/CLI-SPEC.md b/docs/specs/CLI-SPEC.md
index 8c6dbdc..be44caa 100644
--- a/docs/specs/CLI-SPEC.md
+++ b/docs/specs/CLI-SPEC.md
@@ -22,68 +22,50 @@ Nap is a developer-first HTTP testing tool. It is as simple as curl for one-off
## Installation
-The Napper CLI is distributed through three channels. The **canonical** channel is `dotnet tool` via NuGet — it is the only channel that supports installing an arbitrary historical version of `napper`, and it is what the VSIX uses internally to guarantee an exact version match against the extension version. The Homebrew tap and Scoop bucket exist for end users who prefer their native package manager and are willing to accept "latest from tap".
+Three channels. `dotnet tool` is canonical (only channel that pins to a historical version) and is what the VSIX uses ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)). Brew/Scoop are convenience channels for end users; both track "latest from tap" only.
### `cli-install-dotnet-tool` — dotnet tool (canonical)
```sh
-# Install globally
-dotnet tool install -g napper
-
-# Install a specific version
-dotnet tool install -g napper --version 0.12.0
-
-# Update to latest
-dotnet tool update -g napper
+dotnet tool install -g napper # latest
+dotnet tool install -g napper --version 0.12.0 # exact version
+dotnet tool update -g napper # update
```
-This requires the **.NET 10 SDK** (see [`cli-runtime-dependency`](#cli-runtime-dependency)). The dotnet tool channel is the only one that supports `--version` pinning to an arbitrary historical release. The VSIX extension uses this channel exclusively to install the CLI — see [`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition).
+Requires the **.NET 10 SDK** ([`cli-runtime-dependency`](#cli-runtime-dependency)).
### `cli-install-homebrew` — Homebrew tap (macOS / Linux)
```sh
-brew tap Nimblesite/tap
-brew install napper
+brew tap Nimblesite/tap && brew install napper
```
-The `Nimblesite/homebrew-tap` formula tracks the latest Napper release. It always installs the most recent version published to the tap by the release workflow ([`update-homebrew` job in `.github/workflows/release.yml`](../../.github/workflows/release.yml)). Homebrew does not support pinning to an arbitrary historical version with a single-formula tap, so users who need an exact older version should use the dotnet tool channel.
+Tracks latest only. Published by [`update-homebrew`](../../.github/workflows/release.yml) on every release.
### `cli-install-scoop` — Scoop bucket (Windows)
```sh
-scoop bucket add Nimblesite https://github.com/Nimblesite/scoop-bucket
-scoop install napper
+scoop bucket add Nimblesite https://github.com/Nimblesite/scoop-bucket && scoop install napper
```
-The `Nimblesite/scoop-bucket` manifest tracks the latest Napper release. It always installs the most recent version published to the bucket by the release workflow ([`update-scoop` job in `.github/workflows/release.yml`](../../.github/workflows/release.yml)). Scoop's `@version` syntax requires the bucket to maintain an `archived/` versions folder, which the simple manifest pattern does not, so users who need an exact older version should use the dotnet tool channel.
+Tracks latest only. Published by [`update-scoop`](../../.github/workflows/release.yml) on every release.
### `cli-runtime-dependency` — Current runtime dependency
-The Napper CLI is currently a self-contained, trimmed, single-file `dotnet publish` binary targeting **`.NET 10` (`net10.0`)**. The published binary bundles the .NET runtime, so end users do **not** need .NET installed to run `napper run …`. **However**, the canonical install channel (`dotnet tool install`) requires the .NET 10 SDK to be present at install time.
-
-### `cli-aot-migration` — Future: drop the .NET dependency entirely
-
-**This is a hard requirement, not a stretch goal.** Eventually the Napper CLI MUST be migrated off the .NET runtime dependency by switching to **NativeAOT** (`PublishAot=true`). The end state:
-
-- `napper` ships as a single statically-linked native executable per RID, with **zero runtime dependencies** — no .NET SDK, no .NET runtime, no JIT.
-- The dotnet tool channel can be **deprecated** (or kept as a convenience for .NET developers) once Homebrew, Scoop, and a NativeAOT-built standalone binary are the primary channels.
-- Brew and Scoop install the AOT binary directly, with no .NET prerequisite — the VSIX install flow ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)) collapses to "PATH probe → brew/scoop install → tank", with no `dotnet tool` step at all.
-- The release workflow's `build-cli` matrix continues to produce raw binaries and archives, but the published binaries are AOT-compiled instead of self-contained .NET.
+Self-contained, trimmed, single-file `dotnet publish` targeting **`net10.0`**. End users running `napper` do not need .NET installed. The `dotnet tool install` channel does require the .NET 10 SDK at install time.
-**Why this is non-negotiable:**
+### `cli-aot-migration` — MUST: drop the .NET dependency
-- **Install size**: Self-contained .NET trimmed publishes are ~17–20 MB per RID. NativeAOT binaries for an F# CLI of this scope target ~5–10 MB.
-- **Cold start**: NativeAOT eliminates JIT warmup, dropping `napper --version` start time from ~150 ms to ~10 ms. Critical for editor integration where the VSIX spawns the CLI on every save.
-- **Install friction**: The dotnet tool channel requires the .NET 10 SDK as a prerequisite. The VSIX currently has to install the SDK via brew/scoop/choco on first activation if it is missing — see [`vscode-cli-acq-install-dotnet`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition). After AOT migration, this entire branch of the install algorithm disappears.
-- **Distribution**: AOT binaries are signable and notarisable per-platform. The dotnet tool path delegates trust to NuGet but still ships unsigned native code at runtime.
+The CLI MUST migrate to **NativeAOT** (`PublishAot=true`). Non-negotiable. End state:
-**Known blockers / risks:**
+- Single statically-linked native binary per RID, zero runtime dependencies.
+- Smaller (~5–10 MB vs ~17–20 MB), faster cold start (~10 ms vs ~150 ms — critical because the VSIX spawns the CLI on every save).
+- Brew / Scoop / direct download become the primary channels. `dotnet tool` becomes optional.
+- The VSIX install flow ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)) collapses: no more .NET SDK prerequisite, no brew/scoop/choco-install-dotnet step.
-- F# AOT support is functional but has rougher edges than C# AOT — particularly around `printf` family functions, reflection-based serialisation, and quotations. Any code path that uses runtime reflection will fail at trim/publish time and must be rewritten.
-- F# scripting hooks (`.fsx`) and C# scripting hooks (`.csx`) executed via `dotnet fsi`/`dotnet-script` will continue to require the .NET SDK on the user's machine **regardless** of whether `napper` itself is AOT-compiled. The AOT migration drops the dependency for the CLI's own execution; it does **not** drop it for user scripts. This is acceptable — script-using projects already need .NET — but should be documented prominently.
-- The third-party libraries Napper depends on (TOML parser, JSONPath, etc.) must all be AOT-compatible. Audit before migration.
+**Risks**: F# AOT has rough edges (`printf`, reflection, quotations) — anything reflection-based fails at publish time. Third-party deps must be AOT-compatible (audit required). User `.fsx` / `.csx` script hooks still need the .NET SDK after migration — that dependency is on `dotnet fsi`, not on `napper`, and is acceptable.
-**Migration is tracked in [CLI-PLAN.md](../plans/CLI-PLAN.md).**
+Tracked in [CLI-PLAN.md](../plans/CLI-PLAN.md).
---
From b1023aac9a8ceb4627594ec81fe8c0d40d0054fe Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 7 Apr 2026 17:22:12 +1000
Subject: [PATCH 06/48] docs
---
.github/pull_request_template.md | 12 +-
.github/workflows/PULL_REQUEST_TEMPLATE.MD | 5 -
docs/plans/IDE-EXTENSION-INSTALL-PLAN.md | 195 +++++++++++++++++++++
docs/plans/IDE-EXTENSION-PLAN.md | 14 +-
docs/specs/IDE-EXTENSION-SPEC.md | 16 +-
5 files changed, 210 insertions(+), 32 deletions(-)
delete mode 100644 .github/workflows/PULL_REQUEST_TEMPLATE.MD
create mode 100644 docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d45c33f..5e7fe59 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,20 +1,10 @@
## TLDR
-## What Was Added?
-
-## What Was Changed or Deleted?
+## Details
## How Do The Automated Tests Prove It Works?
-
-## Spec / Doc Changes
-
-
-
-## Breaking Changes
-- [ ] None
-
diff --git a/.github/workflows/PULL_REQUEST_TEMPLATE.MD b/.github/workflows/PULL_REQUEST_TEMPLATE.MD
deleted file mode 100644
index 04d29ee..0000000
--- a/.github/workflows/PULL_REQUEST_TEMPLATE.MD
+++ /dev/null
@@ -1,5 +0,0 @@
-# TLDR;
-
-# Details
-
-# How do the tests prove the changes work?
\ No newline at end of file
diff --git a/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
new file mode 100644
index 0000000..1785aaf
--- /dev/null
+++ b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
@@ -0,0 +1,195 @@
+# IDE Extension — CLI Install Plan
+
+Implements [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition).
+
+The VSIX guarantees that a `napper` binary on PATH reports a version exactly equal to the VSIX `package.json` version. The canonical install channel is **`dotnet tool install -g napper --version X`** because it is the only channel that pins to a historical version. Brew/Scoop/Choco are used **only** to install the .NET SDK prerequisite when missing — never to install `napper` itself. The VSIX never downloads binaries directly.
+
+---
+
+## Resolution Algorithm
+
+| # | Spec ID | What it does | Success → | Failure → |
+|---|---------|--------------|-----------|-----------|
+| 1 | [`vscode-cli-acq-path-probe`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) | ` --version` | done | step 2 |
+| 2 | `vscode-cli-acq-dotnet-probe` | `dotnet --version` | step 5 | step 3 |
+| 3 | `vscode-cli-acq-dotnet-consent` | Modal: `Napper needs the .NET 10 SDK. Install it now via ?` | step 4 | tank |
+| 4 | `vscode-cli-acq-install-dotnet` | Run package-manager install command | re-probe `dotnet`; if still missing → restart-VS-Code prompt | tank |
+| 5 | `vscode-cli-acq-dotnet-tool-install` | `dotnet tool install -g napper --version ` (or `update -g`) | re-probe `napper`; match → done | tank |
+| 6 | `vscode-cli-acq-tank` | Hard error notification, three buttons | — | — |
+
+---
+
+## Per-OS Detail
+
+### macOS
+
+| Step | Command |
+|------|---------|
+| Detect dotnet | `dotnet --version` |
+| Detect package manager | `brew --version` |
+| Install .NET SDK | `brew install --cask dotnet-sdk` |
+| Install Napper | `dotnet tool install -g napper --version ` |
+| If brew missing | Prompt → `https://brew.sh` → tank |
+
+PATH after install: brew adds `/usr/local/bin` (Intel) or `/opt/homebrew/bin` (Apple Silicon). `dotnet tool` adds `~/.dotnet/tools`. Both should already be on a fresh shell PATH; the running VS Code process may still need a restart to see them.
+
+### Linux
+
+| Step | Command |
+|------|---------|
+| Detect dotnet | `dotnet --version` |
+| Detect package manager | `brew --version` (Linuxbrew) |
+| Install .NET SDK | `brew install dotnet-sdk` |
+| Install Napper | `dotnet tool install -g napper --version ` |
+| If brew missing | Prompt → `https://brew.sh` → tank |
+
+We do **not** attempt apt/dnf/pacman in this iteration. Linuxbrew is the single supported path. Distro-specific package managers each have a different .NET SDK package name and repository setup; supporting them is deferred until [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration) makes the .NET prerequisite go away entirely.
+
+### Windows
+
+| Step | Command |
+|------|---------|
+| Detect dotnet | `dotnet --version` |
+| Detect package manager (in order) | `scoop --version`, then `choco --version` |
+| Install .NET SDK (scoop) | `scoop bucket add extras` then `scoop install dotnet-sdk` |
+| Install .NET SDK (choco) | `choco install dotnet-sdk -y` |
+| Install Napper | `dotnet tool install -g napper --version ` |
+| If neither | Prompt → `https://scoop.sh` + `https://chocolatey.org/install` → tank |
+
+`choco install` requires an elevated shell. The VSIX runs commands as the VS Code process user, so `choco` may fail with an elevation error. If detection fails, the user is asked to install via scoop instead, or to install .NET manually and reload VS Code. We do not attempt UAC elevation from inside the extension.
+
+---
+
+## Module Layout
+
+| File | Responsibility |
+|------|----------------|
+| `src/Napper.VsCode/src/cliInstaller.ts` | **Delete.** All raw-binary download, redirect-following, checksum verification, and dotnet-tool-fallback logic goes away. |
+| `src/Napper.VsCode/src/cliResolver.ts` | **New.** Pure resolver: takes `{ vsixVersion, configuredCliPath, platform, exec }`, returns a `Result<{ cliPath: string }, ResolverError>`. No vscode SDK imports. Functional, no classes. Each step is a small function returning `Result`. |
+| `src/Napper.VsCode/src/cliResolverCommands.ts` | **New.** Per-OS command tables: maps `(os, packageManager)` → `{ detectCmd, installCmd }`. Single source of truth for install commands. No `if (os === 'darwin')` branches anywhere else. |
+| `src/Napper.VsCode/src/cliResolverUi.ts` | **New.** vscode SDK glue: shows the consent modal, the progress notification, the pm-prompt notification, the tank notification. Calls `cliResolver` with an `exec` function that streams to the Napper output channel. Decoupled per CLAUDE.md "Decouple providers from the VSCODE SDK". |
+| `src/Napper.VsCode/src/extension.ts` | Replace `ensureCliInstalled` (lines 159–180) with a single call to `cliResolverUi.ensureCli()`. Drop all `cliInstaller` imports. |
+| `src/Napper.VsCode/src/constants.ts` | Delete `CLI_DOWNLOAD_BASE_URL`, `CLI_CHECKSUMS_FILE`, `CLI_ASSET_PREFIX`, `CLI_RID_*`, `CLI_PLATFORM_*` (where unused), `CLI_ARCH_*`, `CLI_MAX_REDIRECTS`, `CLI_TOO_MANY_REDIRECTS`, `CLI_REDIRECT_ERROR`, `CLI_FILE_MODE_EXECUTABLE`, `CLI_CHECKSUM_*`, `CLI_DOWNLOAD_ERROR_PREFIX`, `CLI_UNSUPPORTED_PLATFORM_MSG`, `CLI_DOTNET_FALLBACK_MSG`. Add new constants for the consent modal, progress titles, tank message, and the per-pm install commands. All strings in **one location** per CLAUDE.md. |
+
+`cliResolver.ts` MUST stay under 250 LOC. `cliResolverUi.ts` MUST stay under 250 LOC. Any function over 20 LOC gets split. Per CLAUDE.md: pure functions, named-parameter object args, `Result` returns, no throwing.
+
+---
+
+## Error Handling
+
+All resolver functions return `Result` from `types.ts`. `ResolverError` is a discriminated union:
+
+```ts
+type ResolverError =
+ | { kind: 'path-mismatch'; expected: string; actual: string }
+ | { kind: 'dotnet-missing' }
+ | { kind: 'consent-declined' }
+ | { kind: 'pm-missing'; os: 'darwin' | 'linux' | 'win32' }
+ | { kind: 'pm-install-failed'; pm: string; stderr: string; exitCode: number }
+ | { kind: 'tool-install-failed'; stderr: string; exitCode: number }
+ | { kind: 'restart-required' }
+```
+
+Each `kind` maps to exactly one user-visible message and one set of notification buttons in `cliResolverUi.ts`. No string literals scattered through the resolver. All log lines use `logger.info` / `logger.error`.
+
+---
+
+## Progress UI
+
+Steps 4 and 5 wrap in a single `vscode.window.withProgress` call (`location: ProgressLocation.Notification`, `cancellable: false`). Title updates per step:
+
+- Step 4: `Installing .NET SDK via ...`
+- Step 5: `Installing Napper CLI v via dotnet tool...`
+
+All spawned process stdout/stderr lines stream to the Napper output channel via `logger.info`. No separate terminal window opens.
+
+---
+
+## NuGet Deployment
+
+`napper` is published to `nuget.org` as a dotnet tool by [`.github/workflows/release.yml`](../../.github/workflows/release.yml) → `publish-nuget` job. The job:
+
+1. `dotnet pack src/Napper.Cli/Napper.Cli.fsproj -c Release -p:Version=$VERSION`
+2. `dotnet nuget push src/Napper.Cli/nupkg/napper.${VERSION}.nupkg --api-key ${{ secrets.NIMBLESITE_NUGET_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate`
+
+The CLI fsproj already has `true`, `napper`, `napper` ([src/Napper.Cli/Napper.Cli.fsproj](../../src/Napper.Cli/Napper.Cli.fsproj)). The release workflow's `validate-tag` job derives `$VERSION` from the git tag, so the published NuGet package version is always ``. The VSIX `package.json` is bumped to the same version by the `build-vsix` job (`npm version $VERSION --no-git-tag-version --allow-same-version`) before packaging the VSIX. Both artifacts therefore land on the marketplace with matching versions.
+
+The first end-to-end exercise of this flow happens when you tag `v0.12.0`. Until then, the latest NuGet `napper` is `0.9.0` (published manually before the v0.10/v0.11 release runs failed on the stale `NUGET_API_KEY` secret name), so the install resolver against a v0.12.0 VSIX will fall through to `tool-install-failed` until v0.12.0 is tagged and the release workflow runs to green.
+
+---
+
+## Testing
+
+### Unit tests — `src/Napper.VsCode/src/test/unit/cliResolver.test.ts`
+
+Drive `cliResolver` with a mocked `exec` function. Each test asserts the exact sequence of commands invoked and the final `Result`. No vscode SDK, no real child processes.
+
+| Scenario | Mocked exec responses | Expected Result |
+|----------|----------------------|-----------------|
+| PATH match | `napper --version` → `0.12.0` | `ok({ cliPath: 'napper' })` |
+| PATH mismatch, dotnet present, tool install succeeds | `napper --version` → `0.9.0`; `dotnet --version` → `10.0.100`; `dotnet tool update -g napper --version 0.12.0` → exit 0; second `napper --version` → `0.12.0` | `ok({ cliPath: 'napper' })` |
+| PATH missing, dotnet missing, brew present, .NET install succeeds, tool install succeeds | `napper --version` → `ENOENT`; `dotnet --version` → `ENOENT`; `brew --version` → `4.x`; `brew install --cask dotnet-sdk` → exit 0; `dotnet --version` → `10.0.100`; `dotnet tool install -g napper --version 0.12.0` → exit 0; second `napper --version` → `0.12.0` | `ok` |
+| PATH missing, dotnet missing, brew missing | `napper --version` → `ENOENT`; `dotnet --version` → `ENOENT`; `brew --version` → `ENOENT` | `err({ kind: 'pm-missing', os: 'darwin' })` |
+| Consent declined | (same as above through dotnet-missing); consent stub returns `false` | `err({ kind: 'consent-declined' })` |
+| brew install fails | `brew install --cask dotnet-sdk` → exit 1, stderr "no recipe" | `err({ kind: 'pm-install-failed', pm: 'brew', exitCode: 1, stderr: 'no recipe' })` |
+| `dotnet tool install` fails | exit 1, stderr "Package not found" | `err({ kind: 'tool-install-failed', exitCode: 1, stderr: 'Package not found' })` |
+| Windows scoop path | `napper --version` → `ENOENT`; `dotnet --version` → `ENOENT`; `scoop --version` → ok; `scoop bucket add extras` → ok; `scoop install dotnet-sdk` → ok; rest → ok | `ok` |
+| Windows choco fallback | scoop missing, choco present | uses choco install command |
+| Restart required | brew install ok but second `dotnet --version` → `ENOENT` | `err({ kind: 'restart-required' })` |
+
+### E2E tests — `src/Napper.VsCode/src/test/e2e/cliResolver.e2e.test.ts`
+
+Place a stub `napper` shell script on the test workspace's PATH (via `process.env.PATH` prefix) that prints the VSIX version. Activate the extension; assert no install runs and `napper.runFile` works against a real `.nap` fixture. This is the **only** scenario we test e2e — all other branches are too slow and brittle to drive through real VS Code activation. Per CLAUDE.md "FAILING TEST = OK. TEST THAT DOESN'T ENFORCE BEHAVIOR = ILLEGAL", the e2e test asserts on actual `napper run` output, not on internal install state.
+
+---
+
+## Risks
+
+| Risk | Mitigation |
+|------|------------|
+| brew/scoop/choco prompt for sudo or elevation, blocking the spawned process | Detect non-zero exit + specific stderr substrings ("password", "elevation", "administrator"); surface as `pm-install-failed` with a tailored message telling the user to run the command manually in an elevated shell |
+| `dotnet tool install` succeeds but `~/.dotnet/tools` is not on PATH (fresh .NET install on Windows) | After tool install, also probe the absolute path `/.dotnet/tools/napper[.exe]`. If found there, set `nap.cliPath` to the absolute path automatically and log a warning |
+| User has multiple .NET SDKs and `dotnet tool install` targets the wrong global tools dir | Use the absolute-path probe above; log the resolved `dotnet --info` output to the Napper output channel for debugging |
+| Brew/scoop install runs for >60s on slow connections, user thinks VS Code is hung | Progress notification with a live message; stream brew/scoop output to the Napper channel so the user can see real activity |
+| The VSIX activates before the user has any internet at all | Step 1 still works if `napper` is already on PATH at the right version; otherwise step 4 fails fast with `pm-install-failed` (network error in stderr) and tank fires |
+
+---
+
+## TODO
+
+### Spec & release prerequisites
+- [x] Spec section [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition) updated with the 6-step resolver and consent prompt
+- [x] [`.github/workflows/release.yml`](../../.github/workflows/release.yml) `publish-nuget` job uses `secrets.NIMBLESITE_NUGET_KEY` and `--skip-duplicate`
+- [ ] Tag `v0.12.0` to publish the first NuGet package on the new release pipeline (validates the end-to-end install flow has anything to install)
+
+### New modules
+- [ ] Create `src/Napper.VsCode/src/cliResolver.ts` — pure resolver, no vscode SDK imports, returns `Result<…, ResolverError>` per the table above
+- [ ] Create `src/Napper.VsCode/src/cliResolverCommands.ts` — per-OS detect/install command tables
+- [ ] Create `src/Napper.VsCode/src/cliResolverUi.ts` — vscode SDK glue: consent modal, progress notification, pm-prompt notification, tank notification
+- [ ] Add `ResolverError` discriminated union to `src/Napper.VsCode/src/types.ts`
+
+### Wire-up
+- [ ] In `src/Napper.VsCode/src/extension.ts`, replace `ensureCliInstalled` (lines 159–180) with `await cliResolverUi.ensureCli({ vsixVersion, logger, outputChannel, storageDir })`
+- [ ] Drop the `bundledCliPath` / extension `bin/` lookup if no longer needed (extension stops bundling a CLI binary)
+- [ ] After successful install, persist the resolved absolute `cliPath` to extension globalState; warm-start probes the cached path before re-running the resolver
+
+### Cleanup
+- [ ] Delete `src/Napper.VsCode/src/cliInstaller.ts`
+- [ ] Delete the unused constants in `src/Napper.VsCode/src/constants.ts` (see Module Layout table)
+- [ ] Add new constants to `constants.ts` for consent text, progress titles, tank message, button labels — **one location only** per CLAUDE.md
+- [ ] Delete the bundled CLI staging step in `Makefile` `build-extension` if we stop bundling
+
+### Tests
+- [ ] Create `src/Napper.VsCode/src/test/unit/cliResolver.test.ts` covering every scenario in the unit-test table above
+- [ ] Create `src/Napper.VsCode/src/test/e2e/cliResolver.e2e.test.ts` covering the PATH-match happy path against a real `.nap` fixture
+- [ ] Update `npm run lint` config if any of the new files trip ESLint rules — fix the rule violations, never disable
+
+### Docs
+- [ ] Update [README.md](../../README.md) install section: brew tap, scoop bucket, dotnet tool, "the VS Code extension installs napper for you on first activation"
+- [ ] Update [website/src/docs/installation.md](../../website/src/docs/installation.md) to match
+- [ ] Note in the troubleshooting section that consent-declined / pm-missing / restart-required are the three states a user can self-resolve
+
+### Phase 5 (blocked on [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration))
+- [ ] Drop `cliResolverCommands.ts` brew-install-dotnet / scoop-install-dotnet / choco-install-dotnet entries
+- [ ] Drop steps 2–4 of the resolver; step 5 becomes `brew install napper` / `scoop install napper`
+- [ ] Drop `dotnet-missing`, `pm-install-failed`, `restart-required` from `ResolverError`
diff --git a/docs/plans/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md
index 46cf9e5..cda4318 100644
--- a/docs/plans/IDE-EXTENSION-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-PLAN.md
@@ -74,12 +74,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
### Phase 4 — Polish & Distribution
-CLI install rewrite — implement [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition):
-
-- [ ] Implement steps 1–5 of the resolver in a new module; delete `src/Napper.VsCode/src/cliInstaller.ts` and its raw-download/checksum constants
-- [ ] Wrap steps 3 and 4 in `vscode.window.withProgress`; stream all spawned process I/O to the Napper output channel
-- [ ] Unit tests: mock `execFile` and assert the exact command sequence per OS (PATH match / dotnet present / dotnet missing+brew / dotnet missing+no PM / install fails → tank)
-- [ ] E2e tests: stub `napper` / `dotnet` / package manager binaries on PATH and assert the right resolution path runs
+- CLI install rewrite — see [IDE-EXTENSION-INSTALL-PLAN.md](./IDE-EXTENSION-INSTALL-PLAN.md).
Other Phase 4:
- [ ] Split editor layout (request panel webview)
@@ -89,13 +84,14 @@ Other Phase 4:
### Phase 5 — AOT collapse (blocked on [`cli-aot-migration`](../specs/CLI-SPEC.md#cli-aot-migration))
-- [ ] Drop steps 2–3 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); replace step 4 with `brew install napper` / `scoop install napper`
+- [ ] Drop steps 2–4 of [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); replace step 5 with `brew install napper` / `scoop install napper`
- [ ] Drop the `vscode-cli-acq-pm-prompt` path
---
## Related Specs
-- [LSP Specification](./LSP-SPEC.md) — Language server capabilities
+- [LSP Specification](../specs/LSP-SPEC.md) — Language server capabilities
- [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO
-- [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and shared behaviour
+- [IDE Extension Spec](../specs/IDE-EXTENSION-SPEC.md) — Feature matrix and shared behaviour
+- [IDE Extension Install Plan](./IDE-EXTENSION-INSTALL-PLAN.md) — VSIX CLI install resolver
diff --git a/docs/specs/IDE-EXTENSION-SPEC.md b/docs/specs/IDE-EXTENSION-SPEC.md
index e9bac7f..32dcf02 100644
--- a/docs/specs/IDE-EXTENSION-SPEC.md
+++ b/docs/specs/IDE-EXTENSION-SPEC.md
@@ -367,7 +367,8 @@ Resolution runs on activation, idempotent, first match wins:
1. **`vscode-cli-acq-path-probe`** — ` --version` equals VSIX version → done.
2. **`vscode-cli-acq-dotnet-probe`** — `dotnet --version` succeeds → skip to 4.
-3. **`vscode-cli-acq-install-dotnet`** — Install .NET SDK via package manager:
+3. **`vscode-cli-acq-dotnet-consent`** — Detect package manager. Show modal: `Napper needs the .NET 10 SDK. Install it now via ?` with **Install** / **Cancel** buttons. Cancel → `vscode-cli-acq-tank`.
+4. **`vscode-cli-acq-install-dotnet`** — On consent, install .NET SDK:
| OS | Detect | Command |
|---------|--------|---------|
@@ -377,8 +378,8 @@ Resolution runs on activation, idempotent, first match wins:
| Windows | `choco` | `choco install dotnet-sdk -y` |
No detected package manager → `vscode-cli-acq-pm-prompt`. After install, if `dotnet` still not on PATH (process env not refreshed), prompt user to restart VS Code.
-4. **`vscode-cli-acq-dotnet-tool-install`** — `dotnet tool install -g napper --version ` (or `update -g` if present), re-probe.
-5. **`vscode-cli-acq-tank`** — Hard error notification with buttons: **Open install guide** (`https://napperapi.dev/docs/installation/`), **Open GitHub release** (`…/releases/tag/v`), **Open output log**. CLI-dependent commands fail with the same message until resolved.
+5. **`vscode-cli-acq-dotnet-tool-install`** — `dotnet tool install -g napper --version ` (or `update -g` if present), re-probe.
+6. **`vscode-cli-acq-tank`** — Hard error notification with buttons: **Open install guide** (`https://napperapi.dev/docs/installation/`), **Open GitHub release** (`…/releases/tag/v`), **Open output log**. CLI-dependent commands fail with the same message until resolved.
`vscode-cli-acq-pm-prompt` — When no package manager is detected: notification with link buttons to `brew.sh` (mac/Linux) or `scoop.sh` + `chocolatey.org/install` (Windows), plus **Open install guide**.
@@ -386,7 +387,7 @@ Resolution runs on activation, idempotent, first match wins:
`vscode-cli-acq-tap-coexist` — Users can `brew install napper` / `scoop install napper` themselves via [`Nimblesite/homebrew-tap`](https://github.com/Nimblesite/homebrew-tap) and [`Nimblesite/scoop-bucket`](https://github.com/Nimblesite/scoop-bucket). If the user-installed version matches, step 1 finds it and the chain stops. If not, step 4 installs the matching version alongside; the VSIX never touches the user-managed binary.
-> When [`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration) lands, steps 2–3 disappear and step 4 becomes `brew install napper` / `scoop install napper` directly.
+> When [`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration) lands, steps 2–4 disappear and step 5 becomes `brew install napper` / `scoop install napper` directly.
### Zed
@@ -407,7 +408,8 @@ Resolution runs on activation, idempotent, first match wins:
## Related Specs
- [LSP Specification](./LSP-SPEC.md) — Language server capabilities, architecture, and protocol details
-- [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO
-- [IDE Extension Plan (VSCode)](./IDE-EXTENSION-PLAN.md) — VSCode implementation phases and TODO
-- [IDE Extension Plan (Zed)](./ZED-EXTENSION-PLAN.md) — Zed implementation phases and TODO
+- [LSP Plan](../plans/LSP-PLAN.md) — LSP implementation phases and TODO
+- [IDE Extension Plan (VSCode)](../plans/IDE-EXTENSION-PLAN.md) — VSCode implementation phases and TODO
+- [IDE Extension Install Plan](../plans/IDE-EXTENSION-INSTALL-PLAN.md) — VSIX CLI install resolver
+- [IDE Extension Plan (Zed)](../plans/ZED-EXTENSION-PLAN.md) — Zed implementation phases and TODO
- [OpenAPI Generation (Extension)](./IDE-EXTENION-OPENAPI-GENERATION-SPEC.md) — Import command and AI enrichment
From 868be6318f0adac0812635f79acfcf430606b41c Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 7 Apr 2026 17:49:02 +1000
Subject: [PATCH 07/48] cleanup docs
---
docs/plans/IDE-EXTENSION-INSTALL-PLAN.md | 6 ++
docs/plans/IDE-EXTENSION-PLAN.md | 6 +-
docs/plans/LSP-PLAN.md | 117 ++++++++++++++---------
docs/plans/ZED-EXTENSION-PLAN.md | 14 +--
docs/specs/CLI-SPEC.md | 13 ++-
docs/specs/IDE-EXTENSION-SPEC.md | 47 ++++-----
docs/specs/LSP-SPEC.md | 107 ++++++++++-----------
7 files changed, 172 insertions(+), 138 deletions(-)
diff --git a/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
index 1785aaf..d1babc6 100644
--- a/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
@@ -4,6 +4,8 @@ Implements [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-
The VSIX guarantees that a `napper` binary on PATH reports a version exactly equal to the VSIX `package.json` version. The canonical install channel is **`dotnet tool install -g napper --version X`** because it is the only channel that pins to a historical version. Brew/Scoop/Choco are used **only** to install the .NET SDK prerequisite when missing — never to install `napper` itself. The VSIX never downloads binaries directly.
+**One install gives you both the CLI and the LSP.** The Nap language server is the **`napper lsp` subcommand** of the same `napper` binary ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)). After this resolver puts a version-matched `napper` on PATH, the VSIX can launch ` lsp` to start the language server with no further discovery, no second install, no second version pin. There is no `napper-lsp` and there never will be.
+
---
## Resolution Algorithm
@@ -173,6 +175,10 @@ Place a stub `napper` shell script on the test workspace's PATH (via `process.en
- [ ] Drop the `bundledCliPath` / extension `bin/` lookup if no longer needed (extension stops bundling a CLI binary)
- [ ] After successful install, persist the resolved absolute `cliPath` to extension globalState; warm-start probes the cached path before re-running the resolver
+### LSP wire-up (depends on [LSP-PLAN.md Phase 2.5](./LSP-PLAN.md))
+- [ ] After the resolver returns `ok`, pass the resolved `cliPath` to `vscode-languageclient` as `command` with `args: ['lsp']`. The LSP and CLI are the same binary ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)) — no second discovery, no second version pin.
+- [ ] If the resolver tanks, the LSP client is **not** started. Diagnostics, completions, and hover are unavailable until the user resolves the install issue and reloads VS Code.
+
### Cleanup
- [ ] Delete `src/Napper.VsCode/src/cliInstaller.ts`
- [ ] Delete the unused constants in `src/Napper.VsCode/src/constants.ts` (see Module Layout table)
diff --git a/docs/plans/IDE-EXTENSION-PLAN.md b/docs/plans/IDE-EXTENSION-PLAN.md
index cda4318..e827da2 100644
--- a/docs/plans/IDE-EXTENSION-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-PLAN.md
@@ -19,7 +19,7 @@
### Phase 3 — LSP Cutover
-Connect the VSCode extension to `napper-lsp` via `vscode-languageclient`. The LSP itself is a separate project — see **[LSP Plan](./LSP-PLAN.md)**.
+Connect the VSCode extension to the language server via `vscode-languageclient`. The language server is the **`napper lsp` subcommand** of the resolved `napper` binary — same binary the install resolver already put on PATH ([`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)). No separate discovery, no separate version pin. See **[LSP Plan](./LSP-PLAN.md)**.
This phase **deletes duplicated TypeScript parsing code** and replaces it with LSP calls. After this phase, the VSIX is a thin UI shell — it renders data from the LSP, it does NOT parse `.nap` files itself.
@@ -32,7 +32,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
- Curl generation (TS) → use `napper/curlCommand` from LSP
**Wire up:**
-- `vscode-languageclient` to launch `napper-lsp` over stdio
+- `vscode-languageclient` configured with `command: `, `args: ['lsp']` — spawn the same binary as a subprocess in LSP mode
- Environment switcher (status bar + quick-pick — data from LSP `napper/environments`)
- Hover, completions, diagnostics (provided by LSP)
@@ -61,7 +61,7 @@ This phase **deletes duplicated TypeScript parsing code** and replaces it with L
### Phase 3 — LSP Cutover
- [ ] Add `vscode-languageclient` dependency
-- [ ] Wire up to launch `napper-lsp` over stdio on activation
+- [ ] Wire up to launch ` lsp` over stdio on activation (use the install resolver's resolved path; no separate LSP discovery)
- [ ] Delete `extractHttpMethod` — use documentSymbol
- [ ] Delete `parseMethodAndUrl` — use `napper/requestInfo`
- [ ] Delete `parsePlaylistStepPaths` — use documentSymbol
diff --git a/docs/plans/LSP-PLAN.md b/docs/plans/LSP-PLAN.md
index f639b06..d4245d9 100644
--- a/docs/plans/LSP-PLAN.md
+++ b/docs/plans/LSP-PLAN.md
@@ -1,20 +1,22 @@
# Nap Language Server — Implementation Plan
-The LSP is a **thin F# project** (`Napper.Lsp`) that references `Napper.Core` directly. It contains ONLY LSP protocol adapters — all parsing, types, environment resolution, and logging come from `Napper.Core`, the same shared library used by `Napper.Cli`. **Zero duplicated domain logic. Period.**
+The LSP is **a subcommand of `napper`**, not a separate binary. The F# project `Napper.Lsp` is a library (no `OutputType=Exe`, no `Program.fs`) referenced by `Napper.Cli`. When the user runs `napper lsp`, the CLI entry point hands stdio to the LSP layer. **One binary, one install, one version.** See [`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary).
+
+LSP handler code contains ONLY protocol adapters — all parsing, types, environment resolution, and logging come from `Napper.Core`, the same shared library used by every CLI subcommand. **Zero duplicated domain logic. Period.**
---
## ⛔️ DO NOT BREAK EXISTING FUNCTIONALITY
-**The LSP is a PARALLEL project.** It does NOT touch the existing VSIX, CLI, or tests until the cutover phase.
+The LSP layer is built incrementally inside the existing solution. It does NOT touch the existing VSIX, CLI subcommands, or tests except via the explicit `napper lsp` subcommand wire-up.
-- **DO NOT modify any existing TypeScript files in `src/Napper.VsCode/`**
-- **DO NOT modify any existing F# files in `src/Napper.Core/` or `src/Napper.Cli/`** (unless adding new public functions for LSP consumption — and those MUST NOT change existing signatures or behaviour)
-- **DO NOT modify or delete any existing tests**
-- **ALL existing tests MUST continue to pass at all times**
-- **The cutover happens ONLY after the LSP is stable and its own tests pass**
+- **DO NOT modify any existing TypeScript files in `src/Napper.VsCode/`** outside the LSP cutover phase.
+- **DO NOT modify any existing F# files in `src/Napper.Core/` or `src/Napper.Cli/`** beyond (a) adding the `lsp` subcommand dispatch in `Napper.Cli/Program.fs` and (b) adding new public functions in `Napper.Core` for LSP consumption. Existing signatures and behaviour stay untouched.
+- **DO NOT modify or delete any existing tests**.
+- **ALL existing tests MUST continue to pass at all times**.
+- **The cutover happens ONLY after the LSP layer is stable and its own tests pass**.
-If you need to add a function to `Napper.Core` for the LSP, that's fine — but it's an ADDITION, not a modification. Existing code stays untouched.
+If you need to add a function to `Napper.Core` for the LSP, that's fine — but it's an ADDITION, not a modification.
---
@@ -24,20 +26,20 @@ The goal is to **move logic OUT of TypeScript/Rust and INTO F#**. The VSIX curre
```mermaid
graph LR
- subgraph "Phase 1-2: Build LSP (parallel)"
- LSP[Napper.Lsp project] -->|references| CORE[Napper.Core]
+ subgraph "Phase 1-2: Build LSP layer (parallel)"
+ LSP[Napper.Lsp library] -->|references| CORE[Napper.Core]
+ CLI[Napper.Cli] -->|references| LSP
LSPT[Napper.Lsp.Tests] -->|tests| LSP
end
subgraph "Existing (UNTOUCHED)"
- CLI[Napper.Cli] -->|references| CORE
VSIX[Napper.VsCode VSIX]
TESTS[All existing tests]
end
subgraph "Phase 3: Cutover"
- VSIX2[VSIX wires up vscode-languageclient] -->|stdio| LSP2[napper-lsp binary]
- ZED[Zed extension] -->|stdio| LSP2
+ VSIX2[VSIX wires up vscode-languageclient] -->|spawns 'napper lsp', stdio| NAPPER[napper binary]
+ ZED[Zed extension] -->|spawns 'napper lsp', stdio| NAPPER
end
```
@@ -65,11 +67,11 @@ graph TB
ZED_RS[Zed Rust would need same logic] --> FILES
end
- subgraph "AFTER: Single source of truth in LSP"
- VS2[VSCode — thin UI shell] -->|LSP requests| LSP[napper-lsp F#]
- ZED2[Zed — thin UI shell] -->|LSP requests| LSP
- NV2[Neovim — thin UI shell] -->|LSP requests| LSP
- LSP -->|calls| CORE[Napper.Core Parser.fs / Environment.fs]
+ subgraph "AFTER: Single source of truth in the napper binary"
+ VS2[VSCode — thin UI shell] -->|spawns 'napper lsp'| NAPPER[napper binary LSP subcommand]
+ ZED2[Zed — thin UI shell] -->|spawns 'napper lsp'| NAPPER
+ NV2[Neovim — thin UI shell] -->|spawns 'napper lsp'| NAPPER
+ NAPPER -->|calls| CORE[Napper.Core Parser.fs / Environment.fs]
CORE --> FILES2[.nap / .naplist / .napenv files]
end
```
@@ -78,19 +80,24 @@ graph TB
## Project Structure
+`Napper.Lsp` is a **library** (no `OutputType=Exe`, no `Program.fs`) referenced by `Napper.Cli`. The single executable is `napper`. The CLI entry point dispatches `napper lsp` to a `Napper.Lsp.Server.start` function that takes over stdio.
+
```
src/Napper.Lsp/
-├── Napper.Lsp.fsproj # References Napper.Core, depends on Ionide.LanguageServerProtocol
-├── Client.fs # LSP client wrapper for notifications back to IDE
-├── Server.fs # LSP server — lifecycle, document sync, symbols, custom requests
-├── Workspace.fs # Workspace state: open documents, loaded environments
-└── Program.fs # Entry point: stdio transport, server init
+├── Napper.Lsp.fsproj # Library. References Napper.Core, depends on Ionide.LanguageServerProtocol
+├── Client.fs # LSP client wrapper for notifications back to IDE
+├── Server.fs # LSP server — lifecycle, document sync, symbols, custom requests
+└── Workspace.fs # Workspace state: open documents, loaded environments
+
+src/Napper.Cli/
+├── Napper.Cli.fsproj # References Napper.Core AND Napper.Lsp
+└── Program.fs # Entry point. 'napper lsp' subcommand calls Napper.Lsp.Server.start
```
```mermaid
graph TD
- PROGRAM[Program.fs Entry point + stdio] --> SERVER[Server.fs Lifecycle + handlers]
- SERVER --> WS[Workspace.fs Docs + env state]
+ PROGRAM["Napper.Cli Program.fs napper lsp subcommand"] --> SERVER[Napper.Lsp.Server Lifecycle + handlers]
+ SERVER --> WS[Napper.Lsp.Workspace Docs + env state]
WS --> CORE_P[Napper.Core.Parser]
WS --> CORE_E[Napper.Core.Environment]
@@ -102,7 +109,7 @@ graph TD
## ⚠️ Code Sharing with Napper.Core — MANDATORY
-**`Napper.Lsp` contains ONLY LSP protocol glue.** All domain logic lives in `Napper.Core` and is shared with `Napper.Cli`. If the LSP needs a capability that doesn't exist in `Napper.Core` yet, ADD IT TO `Napper.Core` — do NOT put it in `Napper.Lsp`. This is non-negotiable.
+**`Napper.Lsp` contains ONLY LSP protocol glue.** All domain logic lives in `Napper.Core` and is shared with every CLI subcommand. If the LSP needs a capability that doesn't exist in `Napper.Core` yet, ADD IT TO `Napper.Core` — do NOT put it in `Napper.Lsp`. This is non-negotiable.
The rule is simple: **if it's not LSP protocol code, it goes in `Napper.Core`.**
@@ -114,6 +121,7 @@ Examples of what belongs where:
- Generating a curl command → `Napper.Core` (add new module)
- Listing environment names → `Napper.Core.Environment` (add new function)
- Formatting an LSP CompletionItem → `Napper.Lsp` (protocol glue)
+- Dispatching `napper lsp` subcommand → `Napper.Cli/Program.fs` (CLI glue)
| Napper.Core Module | LSP Usage |
|-------------------|-----------|
@@ -131,16 +139,15 @@ Examples of what belongs where:
## Implementation Phases
-### Phase 1 — Project Scaffold + Document Sync
+### Phase 1 — Library Scaffold + Document Sync
-Set up the F# project, wire up JSON-RPC over stdio, and implement document synchronization. **No existing code is modified.**
+Set up the F# library project, wire up JSON-RPC over stdio, and implement document synchronization. **No existing code is modified except adding the `Napper.Lsp` project to `Napper.slnx`.**
-- Create `Napper.Lsp.fsproj` referencing `Napper.Core` and `Ionide.LanguageServerProtocol`
+- Create `Napper.Lsp.fsproj` as a **library** (`` removed) referencing `Napper.Core` and `Ionide.LanguageServerProtocol`
- Add project to `Napper.slnx`
-- Implement `Program.fs` — stdio transport, server lifecycle
-- Implement `Server.fs` — `initialize`/`initialized`/`shutdown` handlers, capability advertisement
+- Implement `Server.fs` — `initialize`/`initialized`/`shutdown` handlers, capability advertisement, exposed as `Server.start : Stream -> Stream -> int`
- Implement `Workspace.fs` — in-memory document store (`didOpen`, `didChange`, `didClose`)
-- Verify the server starts, handshakes, and tracks open documents
+- Verify the library builds; integration tests in `Napper.Lsp.Tests` drive `Server.start` directly with in-process pipes
### Phase 2 — Shared Features + Tests
@@ -155,11 +162,11 @@ Build the LSP features that REPLACE duplicated TypeScript/Rust logic. These are
- `napper/environments` — scan workspace for `.napenv.*` files, return list of environment names
- `napper/curlCommand` — given a `.nap` file URI, return the curl command string
-**Napper.Core additions** (shared with CLI):
+**Napper.Core additions** (shared with every CLI subcommand):
- `Environment.detectEnvironmentNames` — scan a directory for `.napenv.*` files and return env names
- `CurlGenerator.toCurl` — generate curl string from a `NapRequest`
-**Tests** — every test launches the real `napper-lsp` binary and talks JSON-RPC over stdio:
+**Tests** — every test runs `Napper.Lsp.Server.start` against in-process pipes (or shells out to `napper lsp` once Phase 2.5 lands) and talks JSON-RPC:
- All Phase 1 lifecycle tests (already done)
- Test: `textDocument/documentSymbol` returns sections for valid `.nap` file
- Test: `textDocument/documentSymbol` returns sections for valid `.naplist` file
@@ -169,13 +176,25 @@ Build the LSP features that REPLACE duplicated TypeScript/Rust logic. These are
- **ALL existing F# tests still pass**
- **ALL existing VSIX e2e tests still pass**
+### Phase 2.5 — `napper lsp` Subcommand
+
+Wire the LSP layer into the CLI entry point so `napper lsp` is a real command users (and IDE extensions) can launch.
+
+- `Napper.Cli.fsproj` adds a project reference to `Napper.Lsp`
+- `Napper.Cli/Program.fs` matches `lsp` as a subcommand, calls `Napper.Lsp.Server.start (Console.OpenStandardInput()) (Console.OpenStandardOutput())`
+- `Napper.Cli` MUST NOT print anything to stdout when `lsp` is the active subcommand — every log line goes to stderr or to a file. The CLI's banner / `--verbose` output is suppressed for `lsp`.
+- `napper help` lists `lsp` as a valid subcommand: `napper lsp Run the language server (LSP 3.17 over stdio)`
+- A `Napper.Cli.Tests` integration test spawns `napper lsp` as a subprocess, sends an `initialize` request over stdin, and asserts the `initialize` response on stdout
+- **Delete `Napper.Lsp/Program.fs`** if it still exists from earlier scaffolding
+- **Delete the `napper-lsp` `AssemblyName` and `OutputType=Exe`** from `Napper.Lsp.fsproj`
+
### Phase 3 — Cutover (VSIX + Zed Wire Up)
-**Only after Phase 2 is complete and all tests pass.**
+**Only after Phase 2.5 is complete and all tests pass.**
- Add `vscode-languageclient` dependency to VSIX
-- Wire up VSIX to launch `napper-lsp` over stdio on activation
-- Zed extension: implement `language_server_command` in `lib.rs` to launch `napper-lsp`
+- Wire up VSIX to launch ` lsp` over stdio on activation. The resolved path comes from [`vscode-cli-acquisition`](../specs/IDE-EXTENSION-SPEC.md#vscode-cli-acquisition); no separate LSP discovery
+- Zed extension: implement `language_server_command` in `lib.rs` to launch `napper lsp`
- **DELETE** duplicated TypeScript parsing code (`extractHttpMethod`, `parseMethodAndUrl`, `parsePlaylistStepPaths`, `detectEnvironments`) — replace with LSP calls
- Verify: existing VSIX features work exactly as before (now powered by LSP)
- **Run ALL existing VSIX e2e tests — every single one must pass**
@@ -191,7 +210,7 @@ These are genuinely NEW capabilities that don't exist in any IDE today.
- Configuration — `workspace/didChangeConfiguration` for environment name and mask settings
- File watching — `.napenv` changes trigger revalidation
-Each feature gets its own LSP integration tests (same approach: real binary, real JSON-RPC, real assertions).
+Each feature gets its own LSP integration tests (same approach: real `napper lsp` subprocess, real JSON-RPC, real assertions).
---
@@ -200,7 +219,7 @@ Each feature gets its own LSP integration tests (same approach: real binary, rea
**No unit tests. No mocks. LSP integration tests ONLY.**
Every test:
-1. Launches the `napper-lsp` binary as a subprocess
+1. Spawns `napper lsp` as a subprocess (or, in early Phase 1/2, drives `Napper.Lsp.Server.start` directly with in-process pipes)
2. Sends LSP JSON-RPC messages over stdin (the exact same protocol VSCode/Zed use)
3. Reads LSP JSON-RPC responses from stdout
4. Asserts on the responses
@@ -226,11 +245,11 @@ No other dependencies. The LSP is lightweight by design.
## TODO
-### Phase 1 — Project Scaffold + Document Sync
+### Phase 1 — Library Scaffold + Document Sync
- [x] Create `Napper.Lsp.fsproj` with `Napper.Core` project reference
- [x] Add `Ionide.LanguageServerProtocol` package reference
- [x] Add `Napper.Lsp` to `Napper.slnx`
-- [x] Implement `Program.fs` — stdio transport and server lifecycle
+- [x] Implement `Program.fs` — stdio transport and server lifecycle (will move to `Napper.Cli/Program.fs` in Phase 2.5)
- [x] Implement `Server.fs` — initialize/shutdown, capability registration
- [x] Implement `Workspace.fs` — document store (didOpen/didChange/didClose)
@@ -263,10 +282,22 @@ No other dependencies. The LSP is lightweight by design.
- [ ] Verify ALL existing F# tests pass
- [ ] Verify ALL existing VSIX e2e tests pass
+### Phase 2.5 — `napper lsp` Subcommand
+- [ ] Convert `Napper.Lsp.fsproj` from executable to library: remove `Exe` and the `napper-lsp` ``
+- [ ] Delete `src/Napper.Lsp/Program.fs` (its logic moves into the CLI entry point)
+- [ ] Expose `Napper.Lsp.Server.start : Stream -> Stream -> int` as the public entry point used by both CLI dispatch and tests
+- [ ] Add `Napper.Lsp` project reference to `src/Napper.Cli/Napper.Cli.fsproj`
+- [ ] Add `lsp` subcommand dispatch in `src/Napper.Cli/Program.fs` that calls `Napper.Lsp.Server.start`
+- [ ] Suppress all stdout output from the CLI when `lsp` is the active subcommand (logs go to stderr or file)
+- [ ] Update `napper help` to list `napper lsp`
+- [ ] Add a `Napper.Cli.Tests` integration test that spawns `napper lsp`, sends `initialize`, and asserts the response
+- [ ] Update `Napper.Lsp.Tests` to drive `Server.start` directly via in-process pipes (no subprocess) — this stays the fast unit-ish integration path
+- [ ] `napper --version` returns the same version regardless of subcommand
+
### Phase 3 — Cutover
- [ ] Add `vscode-languageclient` to VSIX
-- [ ] Wire VSIX to launch `napper-lsp` on activation
-- [ ] Wire Zed `language_server_command` to launch `napper-lsp`
+- [ ] Wire VSIX to launch ` lsp` on activation (path comes from the install resolver — no separate LSP discovery)
+- [ ] Wire Zed `language_server_command` to launch `napper lsp`
- [ ] Delete duplicated TS parsing code, replace with LSP calls
- [ ] Verify existing VSIX features unchanged
- [ ] Run ALL existing VSIX e2e tests — must pass
diff --git a/docs/plans/ZED-EXTENSION-PLAN.md b/docs/plans/ZED-EXTENSION-PLAN.md
index 80fe21a..e6d43ab 100644
--- a/docs/plans/ZED-EXTENSION-PLAN.md
+++ b/docs/plans/ZED-EXTENSION-PLAN.md
@@ -54,12 +54,12 @@ Build the Tree-sitter grammar for `.nap` and `.naplist` files. Write all query f
### Phase 3 — LSP Integration
-The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_server_command`. The LSP itself is a separate project — see **[LSP Spec](./LSP-SPEC.md)** and **[LSP Plan](./LSP-PLAN.md)** for details.
+The Zed extension launches the language server by spawning **`napper lsp`** — the LSP is a subcommand of the `napper` CLI ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)), not a separate binary. Same `napper` install gives you the LSP for free. See **[LSP Spec](../specs/LSP-SPEC.md)** and **[LSP Plan](./LSP-PLAN.md)**.
-- Implement `language_server_command` in `lib.rs` to launch `nap-lsp` binary
+- Implement `language_server_command` in `lib.rs` to return `{ command: "napper", args: ["lsp"] }`
- Register the language server in `extension.toml` for `.nap` and `.naplist` languages
- The LSP provides completions, diagnostics, hover, symbols — no Zed-specific code needed
-- Handle LSP binary discovery (check PATH, fallback to download)
+- Discovery: check `PATH` for `napper`. If missing, surface a notification linking to the install guide. Zed extensions cannot install dotnet tools themselves; the user runs `dotnet tool install -g napper` (or `brew install napper`) once.
### Phase 4 — Slash Commands + Redactions
@@ -97,10 +97,10 @@ The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_se
- [ ] Add runnable label showing HTTP method + URL
### Phase 3 — LSP Integration
-- [ ] Implement `language_server_command` in `lib.rs`
+- [ ] Implement `language_server_command` in `lib.rs` to return `{ command: "napper", args: ["lsp"] }`
- [ ] Register language server in `extension.toml`
- [ ] Test completions, diagnostics, hover via LSP
-- [ ] Handle LSP binary discovery (PATH lookup)
+- [ ] PATH lookup for `napper`; surface a notification with install instructions if missing
### Phase 4 — Slash Commands + Redactions
- [ ] Implement `/nap-run` slash command
@@ -119,6 +119,6 @@ The Zed extension launches `nap-lsp` (the shared F# LSP binary) via `language_se
## Related Specs
-- [LSP Specification](./LSP-SPEC.md) — Language server capabilities, architecture, and protocol details
+- [LSP Specification](../specs/LSP-SPEC.md) — Language server capabilities, architecture, and protocol details
- [LSP Plan](./LSP-PLAN.md) — LSP implementation phases and TODO
-- [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and shared/IDE-specific behaviour
+- [IDE Extension Spec](../specs/IDE-EXTENSION-SPEC.md) — Feature matrix and shared/IDE-specific behaviour
diff --git a/docs/specs/CLI-SPEC.md b/docs/specs/CLI-SPEC.md
index be44caa..4fedf74 100644
--- a/docs/specs/CLI-SPEC.md
+++ b/docs/specs/CLI-SPEC.md
@@ -106,6 +106,15 @@ napper generate openapi ./petstore.json --output-dir ./petstore/
See [CLI OpenAPI Generation](./CLI-OPENAPI-GENERATION.md) for full details.
+### `cli-lsp` — Language Server
+
+```sh
+# Start the Nap language server (LSP 3.17 over stdio)
+napper lsp
+```
+
+`napper lsp` runs the language server in the same process as the CLI. **The LSP and CLI are one binary** ([`lsp-one-binary`](./LSP-SPEC.md#lsp-one-binary)) — there is no separate `napper-lsp`. IDE extensions spawn `napper lsp` as a child process and communicate via JSON-RPC over stdin/stdout. While `lsp` is the active subcommand, the process MUST NOT write anything to stdout outside LSP framing — all logs go to stderr or to a file. See [LSP Specification](./LSP-SPEC.md) for capabilities and protocol details.
+
---
## CLI Flags
@@ -145,5 +154,7 @@ See [CLI OpenAPI Generation](./CLI-OPENAPI-GENERATION.md) for full details.
- [File Formats](./FILE-FORMATS-SPEC.md) — `.nap`, `.napenv`, `.naplist` format specifications
- [Scripting](./SCRIPTING-SPEC.md) — F# and C# scripting model, NapContext, NapRunner
-- [CLI Plan](./CLI-PLAN.md) — Parser, project layout, implementation phases
+- [CLI Plan](../plans/CLI-PLAN.md) — Parser, project layout, implementation phases
+- [LSP Specification](./LSP-SPEC.md) — `napper lsp` subcommand: protocol, capabilities, transport
+- [LSP Plan](../plans/LSP-PLAN.md) — LSP implementation phases (same `napper` binary)
- [OpenAPI Generation (CLI)](./CLI-OPENAPI-GENERATION.md) — Test suite generation from OpenAPI specs
diff --git a/docs/specs/IDE-EXTENSION-SPEC.md b/docs/specs/IDE-EXTENSION-SPEC.md
index 32dcf02..8e28c86 100644
--- a/docs/specs/IDE-EXTENSION-SPEC.md
+++ b/docs/specs/IDE-EXTENSION-SPEC.md
@@ -26,9 +26,8 @@ graph TB
NV[Neovim Plugin Lua]
end
- subgraph "Nap Toolchain"
- LSP[nap-lsp F# binary]
- CLI[nap CLI F# binary]
+ subgraph "Nap Toolchain (single napper binary)"
+ NAPPER["napper F# binary (run / check / generate / convert / lsp)"]
end
subgraph "Napper.Core (shared F# library)"
@@ -39,35 +38,31 @@ graph TB
OPENAPI[OpenApiGenerator.fs]
end
- VS -->|stdio / LSP| LSP
- ZD -->|stdio / LSP| LSP
- NV -->|stdio / LSP| LSP
+ VS -->|spawns 'napper lsp', stdio| NAPPER
+ ZD -->|spawns 'napper lsp', stdio| NAPPER
+ NV -->|spawns 'napper lsp', stdio| NAPPER
- VS -->|shell out| CLI
- ZD -->|shell out| CLI
- NV -->|shell out| CLI
+ VS -->|spawns 'napper run', exec| NAPPER
+ ZD -->|spawns 'napper run', exec| NAPPER
+ NV -->|spawns 'napper run', exec| NAPPER
- LSP --> PARSER
- LSP --> TYPES
- LSP --> ENV
-
- CLI --> PARSER
- CLI --> TYPES
- CLI --> ENV
- CLI --> RUNNER
- CLI --> OPENAPI
+ NAPPER --> PARSER
+ NAPPER --> TYPES
+ NAPPER --> ENV
+ NAPPER --> RUNNER
+ NAPPER --> OPENAPI
```
```mermaid
graph LR
- subgraph "IDE ↔ LSP (language intelligence)"
+ subgraph "IDE ↔ napper lsp (language intelligence)"
direction LR
- IDE1[IDE] -->|completions, diagnostics, hover, symbols| LSP1[nap-lsp]
+ IDE1[IDE] -->|completions, diagnostics, hover, symbols| LSP1["napper lsp (subcommand)"]
end
- subgraph "IDE ↔ CLI (execution)"
+ subgraph "IDE ↔ napper run (execution)"
direction LR
- IDE2[IDE] -->|nap run, nap generate| CLI1[nap CLI]
+ IDE2[IDE] -->|napper run, napper generate| CLI1["napper (other subcommands)"]
end
```
@@ -85,13 +80,13 @@ graph LR
## `ide-lsp` — Portable Core: Nap Language Server (LSP)
-The foundation for cross-IDE feature parity is a **Nap Language Server** (`napper-lsp`) — an F# binary that speaks LSP 3.17 over stdio. It reuses `Napper.Core` directly (parser, types, environment) with zero duplicated logic.
+The foundation for cross-IDE feature parity is the **Nap Language Server**, which runs as the **`napper lsp` subcommand** of the `napper` CLI. **One binary, one install** — see [`lsp-one-binary`](./LSP-SPEC.md#lsp-one-binary). IDE extensions spawn `napper lsp` and speak LSP 3.17 over stdio. The LSP layer reuses `Napper.Core` directly (parser, types, environment) with zero duplicated logic.
**The LSP replaces duplicated logic in IDE extensions.** The VSIX currently re-parses `.nap` files in TypeScript to extract HTTP methods, URLs, playlist steps, and environment names. This logic already exists in `Napper.Core` F#. After the LSP cutover, all IDEs ask the LSP for this data instead of reimplementing parsing in their own language. **Less TypeScript, less Rust, MORE F#.**
IDE extensions become **thin UI shells** — they render data from the LSP and handle IDE-specific UI (CodeLens, tree views, status bars). They do NOT parse `.nap` files themselves.
-See **[LSP Specification](./LSP-SPEC.md)** for the full capability spec and **[LSP Plan](./LSP-PLAN.md)** for implementation phases.
+See **[LSP Specification](./LSP-SPEC.md)** for the full capability spec and **[LSP Plan](../plans/LSP-PLAN.md)** for implementation phases.
---
@@ -149,7 +144,7 @@ Every IDE must support running a `.nap` file or `.naplist` file from within the
### Language Intelligence (via LSP)
-All IDEs connect to the Nap Language Server (`nap-lsp`) for completions, diagnostics, hover, and document symbols. See **[LSP Specification](./LSP-SPEC.md)** for the full details.
+All IDEs connect to the Nap Language Server by spawning `napper lsp` and speaking JSON-RPC 2.0 over stdio. The LSP provides completions, diagnostics, hover, and document symbols. See **[LSP Specification](./LSP-SPEC.md)** for the full details.
---
@@ -400,7 +395,7 @@ Resolution runs on activation, idempotent, first match wins:
### Shared
- All extensions shell out to `nap run` for execution. No IDE re-implements HTTP logic.
-- All extensions connect to `nap-lsp` for language intelligence. See **[LSP Specification](./LSP-SPEC.md)**.
+- All extensions launch the LSP by spawning `napper lsp` over stdio. See **[LSP Specification](./LSP-SPEC.md)**.
- Grammar definitions (TextMate and Tree-sitter) are both derived from the same ANTLR `.g4` grammar to prevent drift.
---
diff --git a/docs/specs/LSP-SPEC.md b/docs/specs/LSP-SPEC.md
index ec47eca..6514a6b 100644
--- a/docs/specs/LSP-SPEC.md
+++ b/docs/specs/LSP-SPEC.md
@@ -1,6 +1,14 @@
# Nap Language Server — Specification
-> A standalone LSP binary that provides language intelligence for `.nap`, `.naplist`, and `.napenv` files across all IDEs. Built in F#, reusing **Napper.Core** modules directly.
+> The Napper language server is **not a separate binary**. It is a subcommand of the `napper` CLI: `napper lsp` runs the LSP over stdio. **One binary. One install. One version.** The LSP and CLI are the same artifact.
+
+---
+
+## `lsp-one-binary` — One Binary
+
+The CLI and the LSP ship as a single `napper` executable. Running `napper run …` executes a `.nap` file. Running `napper lsp` starts the language server, reads JSON-RPC from stdin, and writes JSON-RPC to stdout. There is no `napper-lsp`, no `nap-lsp`, no separate NuGet package, no separate brew formula, no separate version-resolution path. The version reported by `napper --version` is the version of every capability in the binary, including the LSP.
+
+This is non-negotiable. Any change that splits the LSP back out into its own binary is a regression. When [`cli-aot-migration`](./CLI-SPEC.md#cli-aot-migration) lands, the AOT-compiled `napper` binary still contains the LSP — exactly the same way.
---
@@ -14,57 +22,40 @@ graph TB
NV[Neovim Plugin Lua]
end
- subgraph "nap-lsp (F# binary)"
- JSONRPC[JSON-RPC over stdio]
- HANDLERS[LSP Handlers]
+ subgraph "napper (single F# binary)"
+ ENTRY["Program.fs napper run / check / lsp / ..."]
+ CLI_HANDLERS[CLI subcommands]
+ LSP_HANDLERS["LSP handlers (napper lsp subcommand)"]
subgraph "Napper.Core (shared library)"
- PARSER[Parser.fs FParsec]
- ENV[Environment.fs Variable Resolution]
- TYPES[Types.fs Domain Model]
+ PARSER[Parser.fs]
+ ENV[Environment.fs]
+ TYPES[Types.fs]
+ LOGGER[Logger.fs]
end
end
- VS -->|stdio| JSONRPC
- ZD -->|stdio| JSONRPC
- NV -->|stdio| JSONRPC
- JSONRPC --> HANDLERS
- HANDLERS --> PARSER
- HANDLERS --> ENV
- HANDLERS --> TYPES
-```
-
-```mermaid
-graph LR
- subgraph "Napper.Core (shared)"
- T[Types.fs]
- P[Parser.fs]
- E[Environment.fs]
- L[Logger.fs]
- end
-
- subgraph "Consumers"
- CLI[Napper.Cli]
- LSP[Napper.Lsp]
- end
-
- CLI --> T
- CLI --> P
- CLI --> E
- CLI --> L
- LSP --> T
- LSP --> P
- LSP --> E
- LSP --> L
+ VS -->|spawn 'napper lsp', stdio| ENTRY
+ ZD -->|spawn 'napper lsp', stdio| ENTRY
+ NV -->|spawn 'napper lsp', stdio| ENTRY
+ VS -->|spawn 'napper run', exec| ENTRY
+ ZD -->|spawn 'napper run', exec| ENTRY
+ ENTRY --> CLI_HANDLERS
+ ENTRY --> LSP_HANDLERS
+ CLI_HANDLERS --> PARSER
+ CLI_HANDLERS --> ENV
+ LSP_HANDLERS --> PARSER
+ LSP_HANDLERS --> ENV
+ LSP_HANDLERS --> TYPES
```
---
## Design Principles
-- **⚠️ ZERO duplicated logic — this is the #1 rule.** `Napper.Lsp` MUST NOT contain any parsing, type definitions, environment resolution, or domain logic. ALL of that lives in `Napper.Core`. The LSP is a thin protocol adapter that calls `Napper.Core` functions and translates results to LSP responses. If you find yourself writing domain logic in `Napper.Lsp`, STOP — it belongs in `Napper.Core` where the CLI can use it too.
-- **Napper.Core is the single source of truth.** `Napper.Cli` and `Napper.Lsp` are both thin consumers of `Napper.Core`. They share the exact same parser, types, environment resolution, and logger. Any new capability needed by the LSP that could be useful to the CLI MUST be added to `Napper.Core`, not to `Napper.Lsp`.
-- **Standalone binary.** Published as a self-contained `nap-lsp` executable via `dotnet publish`. No .NET runtime required on the user's machine.
-- **Protocol-only coupling.** IDE extensions communicate exclusively via LSP over stdio. No IDE-specific code in the LSP binary.
+- **One binary.** [`lsp-one-binary`](#lsp-one-binary). The LSP is a subcommand of `napper`, not a separate executable.
+- **⚠️ ZERO duplicated logic.** LSP handler code MUST NOT contain parsing, types, environment resolution, or any domain logic. Those live in `Napper.Core` and are shared with the CLI subcommands. The LSP layer is a thin protocol adapter that calls `Napper.Core` functions and translates results to LSP responses.
+- **Napper.Core is the single source of truth.** Every CLI subcommand and every LSP handler calls into `Napper.Core`. Any new capability the LSP needs that could be useful to the CLI MUST be added to `Napper.Core`.
+- **Protocol-only coupling.** IDE extensions communicate with the LSP exclusively via JSON-RPC over stdio. No IDE-specific code in the F# binary.
- **Incremental.** Each LSP capability ships independently. The server advertises only what it supports.
---
@@ -73,12 +64,12 @@ graph LR
| Property | Value |
|----------|-------|
+| Launch | `napper lsp` (subcommand) |
| Transport | stdio (stdin/stdout) |
| Protocol | JSON-RPC 2.0 (LSP 3.17) |
| Encoding | UTF-8 |
-| Binary name | `nap-lsp` |
-IDE extensions launch `nap-lsp` as a child process and communicate over stdin/stdout. No TCP, no WebSocket, no HTTP.
+IDE extensions spawn `napper lsp` as a child process and communicate over stdin/stdout. No TCP, no WebSocket, no HTTP. The `napper lsp` subcommand takes over stdio for the lifetime of the process — it MUST NOT print anything to stdout outside of LSP framing, and MUST log to stderr or to a file (never stdout).
---
@@ -215,26 +206,26 @@ The LSP accepts configuration via `workspace/didChangeConfiguration` and `initia
## Distribution
-| Platform | Binary | Notes |
-|----------|--------|-------|
-| macOS (arm64) | `nap-lsp` | Self-contained, single file |
-| macOS (x64) | `nap-lsp` | Self-contained, single file |
-| Linux (x64) | `nap-lsp` | Self-contained, single file |
-| Windows (x64) | `nap-lsp.exe` | Self-contained, single file |
+The LSP has no separate distribution. It ships inside `napper`:
+
+- **NuGet** — `dotnet tool install -g napper` ([`cli-install-dotnet-tool`](./CLI-SPEC.md#cli-install-dotnet-tool)). The LSP is the same binary; you launch it via `napper lsp`.
+- **Homebrew tap** — `brew install napper` ([`cli-install-homebrew`](./CLI-SPEC.md#cli-install-homebrew)).
+- **Scoop bucket** — `scoop install napper` ([`cli-install-scoop`](./CLI-SPEC.md#cli-install-scoop)).
+
+The VSIX install resolver ([`vscode-cli-acquisition`](./IDE-EXTENSION-SPEC.md#vscode-cli-acquisition)) installs `napper` once. That single install gives you the LSP for free — no second download, no second version pin, no second discovery step.
-Built with `dotnet publish -c Release -r --self-contained -p:PublishSingleFile=true`.
+## Discovery
-IDE extensions discover the binary by:
-1. Checking `nap.cliPath` setting (if configured)
-2. Looking for `nap-lsp` on `PATH`
-3. Downloading from GitHub releases (future)
+IDE extensions launch the language server by spawning ` lsp`. The resolved path is whatever the install resolver settled on (`napper` from `nap.cliPath`, the user's `PATH`, or the dotnet tools directory). There is no separate `nap-lsp` lookup — the LSP is reachable iff the CLI is reachable, by definition.
---
## Related Specs
+- [CLI Spec](./CLI-SPEC.md) — `napper` CLI subcommands including `napper lsp`
- [IDE Extension Spec](./IDE-EXTENSION-SPEC.md) — Feature matrix and IDE-specific behaviour
-- [IDE Extension Plan (VSCode)](./IDE-EXTENSION-PLAN.md) — VSCode implementation phases
-- [Zed Extension Plan](./ZED-EXTENSION-PLAN.md) — Zed implementation phases
+- [IDE Extension Install Plan](../plans/IDE-EXTENSION-INSTALL-PLAN.md) — VSIX CLI install resolver (the same install gives you the LSP)
+- [IDE Extension Plan (VSCode)](../plans/IDE-EXTENSION-PLAN.md) — VSCode implementation phases
+- [Zed Extension Plan](../plans/ZED-EXTENSION-PLAN.md) — Zed implementation phases
- [File Formats Spec](./FILE-FORMATS-SPEC.md) — `.nap`, `.naplist`, `.napenv` format definitions
-- [LSP Implementation Plan](./LSP-PLAN.md) — Implementation phases and TODO
+- [LSP Implementation Plan](../plans/LSP-PLAN.md) — Implementation phases and TODO
From 57f09291f34356f64760cd0b6fad994352624734 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 09:33:09 +1000
Subject: [PATCH 08/48] fixes
---
.gitignore | 7 ++++++-
.vscode/settings.json | 4 ++++
2 files changed, 10 insertions(+), 1 deletion(-)
create mode 100644 .vscode/settings.json
diff --git a/.gitignore b/.gitignore
index 25b7b92..0967cb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,7 +13,6 @@ ehthumbs.db
Desktop.ini
# IDE / Editor
-.vscode/
.idea/
*.swp
*.swo
@@ -110,3 +109,9 @@ tests/Napper.Core.Tests/.spec-cache/
# Script logs
scripts/logs/
+
+
+.deslop-cache/
+
+
+.ghissues/
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c07f42a
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "basilisk.testExplorer.enabled": true,
+ "basilisk.uv.enabled": true
+}
\ No newline at end of file
From 6d27e0e5d6fc69db2378611419773622ae32f8f3 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 09:36:16 +1000
Subject: [PATCH 09/48] Clean up git ignore
---
.gitignore | 73 +++++++++++++++++++++++-------------------------------
1 file changed, 31 insertions(+), 42 deletions(-)
diff --git a/.gitignore b/.gitignore
index 0967cb8..2f4e4d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,6 @@
# =============================================================================
-# UNIVERSAL
-# =============================================================================
-
# OS
+# =============================================================================
.DS_Store
.DS_Store?
._*
@@ -12,7 +10,9 @@ Thumbs.db
ehthumbs.db
Desktop.ini
+# =============================================================================
# IDE / Editor
+# =============================================================================
.idea/
*.swp
*.swo
@@ -23,28 +23,9 @@ Desktop.ini
*.sublime-project
*.sublime-workspace
-# Portfolio-wide tooling
-.too_many_cooks/
-.commandtree/
-.playwright-mcp/
-coordination/
-logs/
-nohup.out
-
-# Coverage artifacts (all languages)
-coverage/
-lcov.info
-*.profraw
-*.profdata
-htmlcov/
-.coverage
-coverage.xml
-coverage.out
-coverage-summary.json
-TestResults/
-mutants.out/
-
-# Secrets / local overrides
+# =============================================================================
+# Secrets / Local Overrides
+# =============================================================================
.env
.env.local
.env.*.local
@@ -55,11 +36,28 @@ mutants.out/
!*.pub.key
.napenv.local
+# =============================================================================
# Temporary
+# =============================================================================
tmp/
temp/
scratch/
+# =============================================================================
+# Coverage Artifacts
+# =============================================================================
+coverage/
+lcov.info
+*.profraw
+*.profdata
+htmlcov/
+.coverage
+coverage.xml
+coverage.out
+coverage-summary.json
+TestResults/
+mutants.out/
+
# =============================================================================
# F# / .NET
# =============================================================================
@@ -90,28 +88,19 @@ src/Napper.Zed/target/
*.wasm
# =============================================================================
-# Project-specific
+# Tool Caches
# =============================================================================
-src/Napper.VsCode/node_modules/
-src/Napper.VsCode/dist/
-src/Napper.VsCode/out/
-src/Napper.VsCode/*.vsix
-src/Napper.VsCode/.vscode-test/
-src/Napper.VsCode/.nyc_output/
+.commandtree/
+.deslop-cache/
+.ghissues/
-# Generated files
+# =============================================================================
+# Generated Files
+# =============================================================================
website/_site/
examples/httpbin/advanced-report.html
examples/httpbin/all-methods-report.html
-
-# Cached test specs
tests/Napper.Core.Tests/.spec-cache/
-
-# Script logs
scripts/logs/
-
-.deslop-cache/
-
-
-.ghissues/
\ No newline at end of file
+scripts/.too_many_cooks/
From d2bd9b47f84e407e137f5dc201acd9afb4afcbc0 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 11:19:07 +1000
Subject: [PATCH 10/48] LSP stuff
---
docs/plans/IDE-EXTENSION-INSTALL-PLAN.md | 6 +-
docs/plans/LSP-PLAN.md | 31 +--
docs/plans/ZED-EXTENSION-PLAN.md | 35 +--
src/Napper.Cli/Napper.Cli.fsproj | 2 +
src/Napper.Cli/Program.fs | 7 +
src/Napper.Lsp.Tests/LspClient.fs | 14 +-
src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj | 4 +
src/Napper.Lsp/Napper.Lsp.fsproj | 3 -
src/Napper.Lsp/Program.fs | 85 ------
src/Napper.Lsp/Server.fs | 79 ++++++
src/Napper.VsCode/package-lock.json | 70 ++++-
src/Napper.VsCode/package.json | 3 +
src/Napper.VsCode/src/cliResolver.ts | 247 ++++++++++++++++++
src/Napper.VsCode/src/cliResolverCommands.ts | 131 ++++++++++
src/Napper.VsCode/src/constants.ts | 13 +-
src/Napper.VsCode/src/curlCopy.ts | 65 +----
src/Napper.VsCode/src/environmentAdapter.ts | 8 +-
src/Napper.VsCode/src/extension.ts | 10 +-
src/Napper.VsCode/src/lspClient.ts | 114 ++++++++
.../src/test/unit/cliResolver.test.ts | 192 ++++++++++++++
src/Napper.VsCode/src/types.ts | 36 +++
src/Napper.Zed/src/lib.rs | 31 ++-
src/Napper.Zed/src/tests/tests_pure.rs | 27 +-
23 files changed, 1008 insertions(+), 205 deletions(-)
delete mode 100644 src/Napper.Lsp/Program.fs
create mode 100644 src/Napper.VsCode/src/cliResolver.ts
create mode 100644 src/Napper.VsCode/src/cliResolverCommands.ts
create mode 100644 src/Napper.VsCode/src/lspClient.ts
create mode 100644 src/Napper.VsCode/src/test/unit/cliResolver.test.ts
diff --git a/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
index d1babc6..0dd3908 100644
--- a/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
+++ b/docs/plans/IDE-EXTENSION-INSTALL-PLAN.md
@@ -176,14 +176,16 @@ Place a stub `napper` shell script on the test workspace's PATH (via `process.en
- [ ] After successful install, persist the resolved absolute `cliPath` to extension globalState; warm-start probes the cached path before re-running the resolver
### LSP wire-up (depends on [LSP-PLAN.md Phase 2.5](./LSP-PLAN.md))
-- [ ] After the resolver returns `ok`, pass the resolved `cliPath` to `vscode-languageclient` as `command` with `args: ['lsp']`. The LSP and CLI are the same binary ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)) — no second discovery, no second version pin.
+- [x] After the resolver returns `ok`, pass the resolved `cliPath` to `vscode-languageclient` as `command` with `args: ['lsp']` via `src/lspClient.ts:startLspClient`. The LSP and CLI are the same binary ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)) — no second discovery, no second version pin.
+- [x] `vscode-languageclient` installed and wired in `extension.ts` — called from `checkVersionAt` on success.
- [ ] If the resolver tanks, the LSP client is **not** started. Diagnostics, completions, and hover are unavailable until the user resolves the install issue and reloads VS Code.
### Cleanup
- [ ] Delete `src/Napper.VsCode/src/cliInstaller.ts`
- [ ] Delete the unused constants in `src/Napper.VsCode/src/constants.ts` (see Module Layout table)
- [ ] Add new constants to `constants.ts` for consent text, progress titles, tank message, button labels — **one location only** per CLAUDE.md
-- [ ] Delete the bundled CLI staging step in `Makefile` `build-extension` if we stop bundling
+- [x] Keep VSIX packaging unbundled: `.vscodeignore` excludes `bin/**` and `build-extension` does not stage a bundled CLI binary
+- [ ] Delete the remaining local-dev CLI copy to `src/Napper.VsCode/bin/` from `Makefile build-cli` once no local workflow depends on it
### Tests
- [ ] Create `src/Napper.VsCode/src/test/unit/cliResolver.test.ts` covering every scenario in the unit-test table above
diff --git a/docs/plans/LSP-PLAN.md b/docs/plans/LSP-PLAN.md
index d4245d9..5c98842 100644
--- a/docs/plans/LSP-PLAN.md
+++ b/docs/plans/LSP-PLAN.md
@@ -279,26 +279,27 @@ No other dependencies. The LSP is lightweight by design.
- [x] Test: `napper.requestInfo` returns parsed method + URL
- [x] Test: `napper.copyCurl` returns curl string
- [x] Test: `napper.listEnvironments` returns env names
-- [ ] Verify ALL existing F# tests pass
-- [ ] Verify ALL existing VSIX e2e tests pass
+- [x] Verify ALL existing F# tests pass
+- [x] Verify ALL existing VSIX e2e tests pass
### Phase 2.5 — `napper lsp` Subcommand
-- [ ] Convert `Napper.Lsp.fsproj` from executable to library: remove `Exe` and the `napper-lsp` ``
-- [ ] Delete `src/Napper.Lsp/Program.fs` (its logic moves into the CLI entry point)
-- [ ] Expose `Napper.Lsp.Server.start : Stream -> Stream -> int` as the public entry point used by both CLI dispatch and tests
-- [ ] Add `Napper.Lsp` project reference to `src/Napper.Cli/Napper.Cli.fsproj`
-- [ ] Add `lsp` subcommand dispatch in `src/Napper.Cli/Program.fs` that calls `Napper.Lsp.Server.start`
-- [ ] Suppress all stdout output from the CLI when `lsp` is the active subcommand (logs go to stderr or file)
-- [ ] Update `napper help` to list `napper lsp`
-- [ ] Add a `Napper.Cli.Tests` integration test that spawns `napper lsp`, sends `initialize`, and asserts the response
-- [ ] Update `Napper.Lsp.Tests` to drive `Server.start` directly via in-process pipes (no subprocess) — this stays the fast unit-ish integration path
+- [x] Convert `Napper.Lsp.fsproj` from executable to library: remove `Exe` and `napper-lsp` ``
+- [x] Delete `src/Napper.Lsp/Program.fs` — logic moved to `Napper.Lsp.LspRunner.run` in `Server.fs`
+- [x] Expose `Napper.Lsp.LspRunner.run : Stream -> Stream -> int` as public entry point
+- [x] Add `Napper.Lsp` project reference to `src/Napper.Cli/Napper.Cli.fsproj`
+- [x] Add `lsp` subcommand dispatch in `src/Napper.Cli/Program.fs` calling `LspRunner.run`
+- [x] Suppress all stdout output when `lsp` subcommand active (early exit before Logger.init)
+- [x] Update `napper help` to list `napper lsp`
+- [x] Update `Napper.Lsp.Tests` to spawn `napper lsp` via `Napper.Cli` project ref (14/14 tests pass)
+- [ ] Add a `Napper.Cli.Tests` integration test that spawns `napper lsp`, sends `initialize`, asserts response
- [ ] `napper --version` returns the same version regardless of subcommand
### Phase 3 — Cutover
-- [ ] Add `vscode-languageclient` to VSIX
-- [ ] Wire VSIX to launch ` lsp` on activation (path comes from the install resolver — no separate LSP discovery)
-- [ ] Wire Zed `language_server_command` to launch `napper lsp`
-- [ ] Delete duplicated TS parsing code, replace with LSP calls
+- [x] Add `vscode-languageclient` to VSIX
+- [x] Wire VSIX to launch ` lsp` on activation via `lspClient.ts:startLspClient`
+- [x] Wire Zed `language_server_command` to launch `napper lsp` (finds napper on PATH)
+- [x] Delete `parseMethodAndUrl` from `curlCopy.ts` — replaced by `lspClient.copyCurl`
+- [x] Delete `detectEnvironments` from `environmentAdapter.ts` — replaced by `lspClient.listEnvironments`
- [ ] Verify existing VSIX features unchanged
- [ ] Run ALL existing VSIX e2e tests — must pass
- [ ] Run ALL existing F# tests — must pass
diff --git a/docs/plans/ZED-EXTENSION-PLAN.md b/docs/plans/ZED-EXTENSION-PLAN.md
index e6d43ab..2021b1d 100644
--- a/docs/plans/ZED-EXTENSION-PLAN.md
+++ b/docs/plans/ZED-EXTENSION-PLAN.md
@@ -56,7 +56,7 @@ Build the Tree-sitter grammar for `.nap` and `.naplist` files. Write all query f
The Zed extension launches the language server by spawning **`napper lsp`** — the LSP is a subcommand of the `napper` CLI ([`lsp-one-binary`](../specs/LSP-SPEC.md#lsp-one-binary)), not a separate binary. Same `napper` install gives you the LSP for free. See **[LSP Spec](../specs/LSP-SPEC.md)** and **[LSP Plan](./LSP-PLAN.md)**.
-- Implement `language_server_command` in `lib.rs` to return `{ command: "napper", args: ["lsp"] }`
+- Implement `language_server_command` in `lib.rs` to resolve `napper` from the worktree PATH and return `{ command: , args: ["lsp"] }`
- Register the language server in `extension.toml` for `.nap` and `.naplist` languages
- The LSP provides completions, diagnostics, hover, symbols — no Zed-specific code needed
- Discovery: check `PATH` for `napper`. If missing, surface a notification linking to the install guide. Zed extensions cannot install dotnet tools themselves; the user runs `dotnet tool install -g napper` (or `brew install napper`) once.
@@ -81,32 +81,33 @@ The Zed extension launches the language server by spawning **`napper lsp`** —
## TODO
### Phase 1 — Tree-sitter Grammar + Syntax Highlighting
-- [ ] Write `grammar.js` for `.nap` file format
-- [ ] Write `grammar.js` for `.naplist` file format (or combined grammar)
-- [ ] Write `highlights.scm`
-- [ ] Write `brackets.scm`
-- [ ] Write `outline.scm`
-- [ ] Write `indents.scm`
-- [ ] Write `config.toml` with language metadata
-- [ ] Register grammar in `extension.toml`
+- [x] Write `grammar.js` for `.nap` file format
+- [x] Write `grammar.js` for `.naplist` file format
+- [x] Write `grammar.js` for `.napenv` file format
+- [x] Write `highlights.scm`
+- [x] Write `brackets.scm`
+- [x] Write `outline.scm`
+- [x] Write `indents.scm`
+- [x] Write `config.toml` with language metadata
+- [x] Register grammar in `extension.toml`
- [ ] Test highlighting matches VSCode TextMate grammar visually
### Phase 2 — Runnables
-- [ ] Write `runnables.scm` to detect `[request]` blocks
+- [x] Write `runnables.scm` to detect `[request]` blocks
- [ ] Verify `nap run ` executes in Zed terminal
- [ ] Add runnable label showing HTTP method + URL
### Phase 3 — LSP Integration
-- [ ] Implement `language_server_command` in `lib.rs` to return `{ command: "napper", args: ["lsp"] }`
-- [ ] Register language server in `extension.toml`
+- [x] Implement `language_server_command` in `lib.rs` — uses `worktree.which("napper")` and returns `{ command: napper_path, args: ["lsp"] }`
+- [x] Register language server in `extension.toml`
+- [x] PATH lookup for `napper` via Zed `Worktree::which`; surfaces error with install instructions if missing
- [ ] Test completions, diagnostics, hover via LSP
-- [ ] PATH lookup for `napper`; surface a notification with install instructions if missing
### Phase 4 — Slash Commands + Redactions
-- [ ] Implement `/nap-run` slash command
-- [ ] Implement `/nap-import-openapi` slash command
-- [ ] Implement argument completion for slash commands
-- [ ] Write `redactions.scm` for `{{variable}}` masking
+- [x] Implement `/nap-run` slash command
+- [x] Implement `/nap-import-openapi` slash command
+- [x] Implement argument completion for slash commands
+- [x] Write `redactions.scm` for `{{variable}}` masking
### Phase 5 — Polish & Publishing
- [ ] Test on macOS and Linux
diff --git a/src/Napper.Cli/Napper.Cli.fsproj b/src/Napper.Cli/Napper.Cli.fsproj
index ee27a55..2b14483 100644
--- a/src/Napper.Cli/Napper.Cli.fsproj
+++ b/src/Napper.Cli/Napper.Cli.fsproj
@@ -9,6 +9,7 @@
./nupkgCLI-first, test-oriented HTTP API testing toolhttp;api;testing;cli;rest;fsharp;dotnet-tool
+ direct
@@ -18,6 +19,7 @@
+
diff --git a/src/Napper.Cli/Program.fs b/src/Napper.Cli/Program.fs
index 17739d7..43de38c 100644
--- a/src/Napper.Cli/Program.fs
+++ b/src/Napper.Cli/Program.fs
@@ -93,6 +93,7 @@ let printHelp () =
printfn " nap check Validate a .nap or .naplist file"
printfn " nap generate openapi --output-dir Generate .nap files from OpenAPI spec"
printfn " nap convert http --output-dir Convert .http files to .nap format"
+ printfn " nap lsp Run the language server (LSP 3.17 over stdio)"
printfn " nap help Show this help"
printfn ""
printfn "Options:"
@@ -488,6 +489,12 @@ let convertHttp (args: CliArgs) : int =
[]
let main argv =
+ // LSP subcommand: take over stdio immediately, suppress all other stdout
+ if argv.Length > 0 && argv[0] = "lsp" then
+ let input = Console.OpenStandardInput()
+ let output = Console.OpenStandardOutput()
+ Environment.Exit(Napper.Lsp.LspRunner.run input output)
+
let args = parseArgs argv
Logger.init args.Verbose
let joinedArgs = argv |> String.concat " "
diff --git a/src/Napper.Lsp.Tests/LspClient.fs b/src/Napper.Lsp.Tests/LspClient.fs
index 8140fb5..ddb179f 100644
--- a/src/Napper.Lsp.Tests/LspClient.fs
+++ b/src/Napper.Lsp.Tests/LspClient.fs
@@ -1,4 +1,5 @@
-/// Test client that launches napper-lsp and communicates via JSON-RPC over stdio.
+// Implements [LSP-TEST-CLIENT]
+/// Test client that launches 'napper lsp' and communicates via JSON-RPC over stdio.
/// This is the exact same protocol VSCode and Zed use.
module Napper.Lsp.Tests.LspClient
@@ -11,10 +12,10 @@ open System.Threading
open System.Threading.Tasks
open Xunit
-let private lspBinaryPath =
+let private napperBinaryPath =
let baseDir = AppContext.BaseDirectory
let repoRoot = DirectoryInfo(baseDir).Parent.Parent.Parent.Parent.Parent.FullName
- Path.Combine(repoRoot, "src", "Napper.Lsp", "bin", "Debug", "net10.0", "napper-lsp")
+ Path.Combine(repoRoot, "src", "Napper.Cli", "bin", "Debug", "net10.0", "napper")
/// Encode a JSON-RPC message with Content-Length header (LSP wire format)
let private encodeMessage (json: string) : byte[] =
@@ -59,15 +60,16 @@ type LspServerProcess() =
let mutable started = false
member this.Start() : unit =
- Assert.True(File.Exists(lspBinaryPath), $"LSP binary not found at {lspBinaryPath}")
- proc.StartInfo.FileName <- lspBinaryPath
+ Assert.True(File.Exists(napperBinaryPath), $"napper binary not found at {napperBinaryPath}")
+ proc.StartInfo.FileName <- napperBinaryPath
+ proc.StartInfo.Arguments <- "lsp"
proc.StartInfo.UseShellExecute <- false
proc.StartInfo.RedirectStandardInput <- true
proc.StartInfo.RedirectStandardOutput <- true
proc.StartInfo.RedirectStandardError <- true
proc.StartInfo.CreateNoWindow <- true
let ok = proc.Start()
- Assert.True(ok, "Failed to start napper-lsp process")
+ Assert.True(ok, "Failed to start 'napper lsp' process")
started <- true
member this.SendRequest(method: string, id: int, ?paramObj: JsonNode) : Task =
diff --git a/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj b/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj
index 5b390f2..b3b736e 100644
--- a/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj
+++ b/src/Napper.Lsp.Tests/Napper.Lsp.Tests.fsproj
@@ -17,4 +17,8 @@
+
+
+
+
diff --git a/src/Napper.Lsp/Napper.Lsp.fsproj b/src/Napper.Lsp/Napper.Lsp.fsproj
index 07a9722..18b656b 100644
--- a/src/Napper.Lsp/Napper.Lsp.fsproj
+++ b/src/Napper.Lsp/Napper.Lsp.fsproj
@@ -1,8 +1,6 @@
- Exe
- napper-lspdirect
@@ -10,7 +8,6 @@
-
diff --git a/src/Napper.Lsp/Program.fs b/src/Napper.Lsp/Program.fs
deleted file mode 100644
index 508f72e..0000000
--- a/src/Napper.Lsp/Program.fs
+++ /dev/null
@@ -1,85 +0,0 @@
-/// Entry point for the napper-lsp language server.
-/// LSP takes over stdio — do NOT read/write to stdin/stdout directly.
-module Napper.Lsp.Program
-
-open System
-open System.Threading.Tasks
-open Ionide.LanguageServerProtocol
-open Ionide.LanguageServerProtocol.JsonUtils
-open Napper.Lsp
-open Newtonsoft.Json
-open StreamJsonRpc
-
-let private defaultJsonRpcFormatter () =
- let fmt = new JsonMessageFormatter()
- fmt.JsonSerializer.NullValueHandling <- NullValueHandling.Ignore
- fmt.JsonSerializer.ConstructorHandling <- ConstructorHandling.AllowNonPublicDefaultConstructor
- fmt.JsonSerializer.MissingMemberHandling <- MissingMemberHandling.Ignore
- fmt.JsonSerializer.Converters.Add(StrictNumberConverter())
- fmt.JsonSerializer.Converters.Add(StrictStringConverter())
- fmt.JsonSerializer.Converters.Add(StrictBoolConverter())
- fmt.JsonSerializer.Converters.Add(SingleCaseUnionConverter())
- fmt.JsonSerializer.Converters.Add(OptionConverter())
- fmt.JsonSerializer.Converters.Add(ErasedUnionConverter())
- fmt.JsonSerializer.ContractResolver <- OptionAndCamelCasePropertyNamesContractResolver()
- fmt
-
-let private createRpc (handler: IJsonRpcMessageHandler) : JsonRpc =
- let rec (|HandleableException|_|) (e: exn) =
- match e with
- | :? LocalRpcException -> Some()
- | :? TaskCanceledException -> Some()
- | :? OperationCanceledException -> Some()
- | :? JsonSerializationException -> Some()
- | :? AggregateException as aex -> aex.InnerExceptions |> Seq.tryHead |> Option.bind (|HandleableException|_|)
- | _ -> None
-
- let strategy = ActivityTracingStrategy()
-
- { new JsonRpc(handler, ActivityTracingStrategy = strategy) with
- member _.IsFatalException(ex: Exception) =
- match ex with
- | HandleableException -> false
- | _ -> true
-
- member this.CreateErrorDetails(request: Protocol.JsonRpcRequest, ex: Exception) =
- match ex with
- | :? JsonSerializationException as jex ->
- let isSerializable = this.ExceptionStrategy = ExceptionProcessing.ISerializable
-
- let data: obj =
- if isSerializable then
- (jex :> obj)
- else
- Protocol.CommonErrorData(jex)
-
- Protocol.JsonRpcError.ErrorDetail(
- Code = Protocol.JsonRpcErrorCode.ParseError,
- Message = jex.Message,
- Data = data
- )
- | _ -> base.CreateErrorDetails(request, ex) }
-
-let private startServer () =
- let input = Console.OpenStandardInput()
- let output = Console.OpenStandardOutput()
-
- let requestHandlings: Map> =
- Server.defaultRequestHandlings ()
-
- Server.start
- requestHandlings
- input
- output
- (fun (notifier, requester) -> new Client(notifier, requester))
- (fun client -> new NapLspServer(client))
- createRpc
-
-[]
-let main _args =
- try
- let result = startServer ()
- int result
- with ex ->
- eprintfn $"napper-lsp crashed: %A{ex}"
- 1
diff --git a/src/Napper.Lsp/Server.fs b/src/Napper.Lsp/Server.fs
index 6f7d5e5..f7076f5 100644
--- a/src/Napper.Lsp/Server.fs
+++ b/src/Napper.Lsp/Server.fs
@@ -1,9 +1,16 @@
+// Implements [LSP-SERVER]
namespace Napper.Lsp
+open System
+open System.IO
+open System.Threading.Tasks
open Ionide.LanguageServerProtocol
+open Ionide.LanguageServerProtocol.JsonUtils
open Ionide.LanguageServerProtocol.Types
open Napper.Core
+open Newtonsoft.Json
open Newtonsoft.Json.Linq
+open StreamJsonRpc
/// LSP server — lifecycle, document sync, symbols, code lens, and commands.
/// All domain logic lives in Napper.Core. This file is protocol glue only.
@@ -286,3 +293,75 @@ type NapLspServer(client: Client) =
}
override _.Dispose() = ()
+
+/// Public entry point used by Napper.Cli and tests.
+module LspRunner =
+
+ let private defaultJsonRpcFormatter () =
+ let fmt = new JsonMessageFormatter()
+ fmt.JsonSerializer.NullValueHandling <- NullValueHandling.Ignore
+ fmt.JsonSerializer.ConstructorHandling <- ConstructorHandling.AllowNonPublicDefaultConstructor
+ fmt.JsonSerializer.MissingMemberHandling <- MissingMemberHandling.Ignore
+ fmt.JsonSerializer.Converters.Add(StrictNumberConverter())
+ fmt.JsonSerializer.Converters.Add(StrictStringConverter())
+ fmt.JsonSerializer.Converters.Add(StrictBoolConverter())
+ fmt.JsonSerializer.Converters.Add(SingleCaseUnionConverter())
+ fmt.JsonSerializer.Converters.Add(OptionConverter())
+ fmt.JsonSerializer.Converters.Add(ErasedUnionConverter())
+ fmt.JsonSerializer.ContractResolver <- OptionAndCamelCasePropertyNamesContractResolver()
+ fmt
+
+ let private createRpc (handler: IJsonRpcMessageHandler) : JsonRpc =
+ let rec (|HandleableException|_|) (e: exn) =
+ match e with
+ | :? LocalRpcException -> Some()
+ | :? TaskCanceledException -> Some()
+ | :? OperationCanceledException -> Some()
+ | :? JsonSerializationException -> Some()
+ | :? AggregateException as aex ->
+ aex.InnerExceptions |> Seq.tryHead |> Option.bind (|HandleableException|_|)
+ | _ -> None
+
+ let strategy = ActivityTracingStrategy()
+
+ { new JsonRpc(handler, ActivityTracingStrategy = strategy) with
+ member _.IsFatalException(ex: Exception) =
+ match ex with
+ | HandleableException -> false
+ | _ -> true
+
+ member this.CreateErrorDetails(request: Protocol.JsonRpcRequest, ex: Exception) =
+ match ex with
+ | :? JsonSerializationException as jex ->
+ let isSerializable = this.ExceptionStrategy = ExceptionProcessing.ISerializable
+
+ let data: obj =
+ if isSerializable then (jex :> obj)
+ else Protocol.CommonErrorData(jex)
+
+ Protocol.JsonRpcError.ErrorDetail(
+ Code = Protocol.JsonRpcErrorCode.ParseError,
+ Message = jex.Message,
+ Data = data)
+ | _ -> base.CreateErrorDetails(request, ex) }
+
+ /// Start the LSP server over the given streams. Returns the exit code.
+ /// Called by Napper.Cli for 'napper lsp' and by tests via in-process pipes.
+ let run (input: Stream) (output: Stream) : int =
+ try
+ let requestHandlings: Map> =
+ Server.defaultRequestHandlings ()
+
+ let result =
+ Server.start
+ requestHandlings
+ input
+ output
+ (fun (notifier, requester) -> new Client(notifier, requester))
+ (fun client -> new NapLspServer(client))
+ createRpc
+
+ int result
+ with ex ->
+ eprintfn $"napper lsp crashed: %A{ex}"
+ 1
diff --git a/src/Napper.VsCode/package-lock.json b/src/Napper.VsCode/package-lock.json
index a457a4c..caf52a9 100644
--- a/src/Napper.VsCode/package-lock.json
+++ b/src/Napper.VsCode/package-lock.json
@@ -8,6 +8,9 @@
"name": "napper",
"version": "0.11.0",
"license": "MIT",
+ "dependencies": {
+ "vscode-languageclient": "^9.0.1"
+ },
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/mocha": "^10.0.10",
@@ -6252,7 +6255,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -7329,6 +7331,72 @@
"url": "https://bevry.me/fund"
}
},
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageclient": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
+ "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
+ "license": "MIT",
+ "dependencies": {
+ "minimatch": "^5.1.0",
+ "semver": "^7.3.7",
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "engines": {
+ "vscode": "^1.82.0"
+ }
+ },
+ "node_modules/vscode-languageclient/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageclient/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/vscode-languageclient/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "license": "MIT"
+ },
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
diff --git a/src/Napper.VsCode/package.json b/src/Napper.VsCode/package.json
index c94d1a3..9f6acd2 100644
--- a/src/Napper.VsCode/package.json
+++ b/src/Napper.VsCode/package.json
@@ -356,5 +356,8 @@
"typescript-eslint": "^8.56.1",
"webpack": "^5.105.3",
"webpack-cli": "^6.0.1"
+ },
+ "dependencies": {
+ "vscode-languageclient": "^9.0.1"
}
}
diff --git a/src/Napper.VsCode/src/cliResolver.ts b/src/Napper.VsCode/src/cliResolver.ts
new file mode 100644
index 0000000..ebe1b6a
--- /dev/null
+++ b/src/Napper.VsCode/src/cliResolver.ts
@@ -0,0 +1,247 @@
+import {
+ CLI_BINARY_NAME,
+ CLI_RESOLVER_UNKNOWN_ERROR,
+ CLI_TOOL_INSTALL_ARG,
+ CLI_TOOL_UPDATE_ARG,
+ DEFAULT_CLI_PATH,
+} from './constants';
+import {
+ dotnetToolCommand,
+ dotnetVersionCommand,
+ packageManagers,
+ type ExecCommand,
+ type ExecResult,
+ type PackageManagerCommands,
+ versionCommand,
+} from './cliResolverCommands';
+import {
+ err,
+ ok,
+ type PackageManager,
+ type ResolverError,
+ ResolverErrorKind,
+ type ResolverPlatform,
+ type Result,
+} from './types';
+
+export type ResolverExec = (command: ExecCommand) => Promise;
+
+export type ConfirmDotnetInstall = (args: {
+ readonly packageManager: PackageManager;
+}) => Promise;
+
+export interface ResolveCliArgs {
+ readonly vsixVersion: string;
+ readonly configuredCliPath?: string;
+ readonly platform: ResolverPlatform;
+ readonly exec: ResolverExec;
+ readonly confirmDotnetInstall: ConfirmDotnetInstall;
+}
+
+interface ResolverContext extends ResolveCliArgs {
+ readonly initialCliPath: string;
+}
+
+type VersionProbe =
+ | { readonly kind: 'match' | 'missing' }
+ | { readonly kind: 'mismatch'; readonly actual: string };
+
+export async function resolveCli(
+ args: ResolveCliArgs,
+): Promise> {
+ const context = buildContext({ args });
+ const pathProbe = await probeCli({ context, cliPath: context.initialCliPath });
+ if (pathProbe.kind === 'match') {
+ return ok({ cliPath: context.initialCliPath });
+ }
+ const dotnet = await ensureDotnet({ context });
+ return dotnet.ok ? ensureNapperTool({ context, pathProbe }) : err(dotnet.error);
+}
+
+function buildContext({ args }: { readonly args: ResolveCliArgs }): ResolverContext {
+ return {
+ ...args,
+ initialCliPath: resolveInitialCliPath({ configuredCliPath: args.configuredCliPath }),
+ };
+}
+
+function resolveInitialCliPath({
+ configuredCliPath,
+}: {
+ readonly configuredCliPath: string | undefined;
+}): string {
+ return configuredCliPath === undefined || configuredCliPath.length === 0
+ ? DEFAULT_CLI_PATH
+ : configuredCliPath;
+}
+
+async function ensureDotnet({
+ context,
+}: {
+ readonly context: ResolverContext;
+}): Promise> {
+ const dotnetProbe = await runExec({ exec: context.exec, command: dotnetVersionCommand() });
+ if (isSuccess({ result: dotnetProbe })) {
+ return ok(undefined);
+ }
+ const commands = packageManagers({ platform: context.platform });
+ const pm = await detectPackageManager({ context, commands });
+ if (!pm.ok) {
+ return err(pm.error);
+ }
+ const consent = await context.confirmDotnetInstall({ packageManager: pm.value.packageManager });
+ return consent
+ ? installDotnet({ context, commands: pm.value })
+ : err({ kind: ResolverErrorKind.ConsentDeclined });
+}
+
+async function installDotnet({
+ context,
+ commands,
+}: {
+ readonly context: ResolverContext;
+ readonly commands: PackageManagerCommands;
+}): Promise> {
+ const install = await runInstallCommands({ context, commands });
+ if (!install.ok) {
+ return err(install.error);
+ }
+ const dotnetProbe = await runExec({ exec: context.exec, command: dotnetVersionCommand() });
+ return isSuccess({ result: dotnetProbe })
+ ? ok(undefined)
+ : err({ kind: ResolverErrorKind.RestartRequired });
+}
+
+async function ensureNapperTool({
+ context,
+ pathProbe,
+}: {
+ readonly context: ResolverContext;
+ readonly pathProbe: VersionProbe;
+}): Promise> {
+ const tool = await runExec({
+ exec: context.exec,
+ command: dotnetToolCommand({
+ action: pathProbe.kind === 'mismatch' ? CLI_TOOL_UPDATE_ARG : CLI_TOOL_INSTALL_ARG,
+ version: context.vsixVersion,
+ }),
+ });
+ return isSuccess({ result: tool })
+ ? probeInstalledCli({ context })
+ : err(toolInstallFailed({ result: tool }));
+}
+
+async function probeInstalledCli({
+ context,
+}: {
+ readonly context: ResolverContext;
+}): Promise> {
+ const probe = await probeCli({ context, cliPath: CLI_BINARY_NAME });
+ if (probe.kind === 'match') {
+ return ok({ cliPath: CLI_BINARY_NAME });
+ }
+ return probe.kind === 'mismatch'
+ ? err(pathMismatch({ context, actual: probe.actual }))
+ : err({ kind: ResolverErrorKind.RestartRequired });
+}
+
+async function probeCli({
+ context,
+ cliPath,
+}: {
+ readonly context: ResolverContext;
+ readonly cliPath: string;
+}): Promise {
+ const result = await runExec({ exec: context.exec, command: versionCommand({ cliPath }) });
+ if (!isSuccess({ result })) {
+ return { kind: 'missing' };
+ }
+ const actual = result.stdout.trim();
+ return actual === context.vsixVersion ? { kind: 'match' } : { kind: 'mismatch', actual };
+}
+
+async function detectPackageManager({
+ context,
+ commands,
+}: {
+ readonly context: ResolverContext;
+ readonly commands: readonly PackageManagerCommands[];
+}): Promise> {
+ const command = commands[0];
+ if (command === undefined) {
+ return err({ kind: ResolverErrorKind.PmMissing, os: context.platform });
+ }
+ const result = await runExec({ exec: context.exec, command: command.detect });
+ return isSuccess({ result })
+ ? ok(command)
+ : detectPackageManager({ context, commands: commands.slice(1) });
+}
+
+async function runInstallCommands({
+ context,
+ commands,
+}: {
+ readonly context: ResolverContext;
+ readonly commands: PackageManagerCommands;
+}): Promise> {
+ const command = commands.install[0];
+ if (command === undefined) {
+ return ok(undefined);
+ }
+ const result = await runExec({ exec: context.exec, command });
+ return isSuccess({ result })
+ ? runInstallCommands({ context, commands: { ...commands, install: commands.install.slice(1) } })
+ : err(pmInstallFailed({ commands, result }));
+}
+
+async function runExec({
+ exec,
+ command,
+}: {
+ readonly exec: ResolverExec;
+ readonly command: ExecCommand;
+}): Promise {
+ try {
+ return await exec(command);
+ } catch (error: unknown) {
+ const stderr = error instanceof Error ? error.message : CLI_RESOLVER_UNKNOWN_ERROR;
+ return { exitCode: 1, stdout: '', stderr };
+ }
+}
+
+function isSuccess({ result }: { readonly result: ExecResult }): boolean {
+ return result.exitCode === 0;
+}
+
+function pathMismatch({
+ context,
+ actual,
+}: {
+ readonly context: ResolverContext;
+ readonly actual: string;
+}): ResolverError {
+ return { kind: ResolverErrorKind.PathMismatch, expected: context.vsixVersion, actual };
+}
+
+function pmInstallFailed({
+ commands,
+ result,
+}: {
+ readonly commands: PackageManagerCommands;
+ readonly result: ExecResult;
+}): ResolverError {
+ return {
+ kind: ResolverErrorKind.PmInstallFailed,
+ pm: commands.packageManager,
+ stderr: result.stderr,
+ exitCode: result.exitCode,
+ };
+}
+
+function toolInstallFailed({ result }: { readonly result: ExecResult }): ResolverError {
+ return {
+ kind: ResolverErrorKind.ToolInstallFailed,
+ stderr: result.stderr,
+ exitCode: result.exitCode,
+ };
+}
diff --git a/src/Napper.VsCode/src/cliResolverCommands.ts b/src/Napper.VsCode/src/cliResolverCommands.ts
new file mode 100644
index 0000000..6cc3471
--- /dev/null
+++ b/src/Napper.VsCode/src/cliResolverCommands.ts
@@ -0,0 +1,131 @@
+// Implements [vscode-cli-acquisition]
+// Command tables for the pure CLI resolver.
+
+import {
+ CLI_BINARY_NAME,
+ CLI_DOTNET_CMD,
+ CLI_PLATFORM_DARWIN,
+ CLI_PLATFORM_LINUX,
+ CLI_RESOLVER_ADD_ARG,
+ CLI_RESOLVER_BUCKET_ARG,
+ CLI_RESOLVER_CASK_FLAG,
+ CLI_RESOLVER_DOTNET_SDK,
+ CLI_RESOLVER_EXTRAS_ARG,
+ CLI_RESOLVER_PM_BREW,
+ CLI_RESOLVER_PM_CHOCO,
+ CLI_RESOLVER_PM_SCOOP,
+ CLI_RESOLVER_YES_FLAG,
+ CLI_TOOL_ARG,
+ CLI_TOOL_GLOBAL_FLAG,
+ CLI_TOOL_INSTALL_ARG,
+ CLI_TOOL_VERSION_FLAG,
+ CLI_VERSION_FLAG,
+} from './constants';
+import type { PackageManager, ResolverPlatform } from './types';
+
+export interface ExecCommand {
+ readonly command: string;
+ readonly args: readonly string[];
+}
+
+export interface ExecResult {
+ readonly exitCode: number;
+ readonly stdout: string;
+ readonly stderr: string;
+}
+
+export interface PackageManagerCommands {
+ readonly packageManager: PackageManager;
+ readonly detect: ExecCommand;
+ readonly install: readonly ExecCommand[];
+}
+
+export function packageManagers({
+ platform,
+}: {
+ readonly platform: ResolverPlatform;
+}): readonly PackageManagerCommands[] {
+ if (platform === CLI_PLATFORM_DARWIN) {
+ return [brewCommands({ cask: true })];
+ }
+ if (platform === CLI_PLATFORM_LINUX) {
+ return [brewCommands({ cask: false })];
+ }
+ return [scoopCommands(), chocoCommands()];
+}
+
+export function versionCommand({ cliPath }: { readonly cliPath: string }): ExecCommand {
+ return {
+ command: cliPath,
+ args: [CLI_VERSION_FLAG],
+ };
+}
+
+export function dotnetVersionCommand(): ExecCommand {
+ return {
+ command: CLI_DOTNET_CMD,
+ args: [CLI_VERSION_FLAG],
+ };
+}
+
+export function dotnetToolCommand({
+ action,
+ version,
+}: {
+ readonly action: string;
+ readonly version: string;
+}): ExecCommand {
+ return {
+ command: CLI_DOTNET_CMD,
+ args: [
+ CLI_TOOL_ARG,
+ action,
+ CLI_TOOL_GLOBAL_FLAG,
+ CLI_BINARY_NAME,
+ CLI_TOOL_VERSION_FLAG,
+ version,
+ ],
+ };
+}
+
+function brewCommands({ cask }: { readonly cask: boolean }): PackageManagerCommands {
+ return {
+ packageManager: CLI_RESOLVER_PM_BREW,
+ detect: { command: CLI_RESOLVER_PM_BREW, args: [CLI_VERSION_FLAG] },
+ install: [
+ {
+ command: CLI_RESOLVER_PM_BREW,
+ args: cask
+ ? [CLI_TOOL_INSTALL_ARG, CLI_RESOLVER_CASK_FLAG, CLI_RESOLVER_DOTNET_SDK]
+ : [CLI_TOOL_INSTALL_ARG, CLI_RESOLVER_DOTNET_SDK],
+ },
+ ],
+ };
+}
+
+function scoopCommands(): PackageManagerCommands {
+ return {
+ packageManager: CLI_RESOLVER_PM_SCOOP,
+ detect: { command: CLI_RESOLVER_PM_SCOOP, args: [CLI_VERSION_FLAG] },
+ install: [
+ {
+ command: CLI_RESOLVER_PM_SCOOP,
+ args: [CLI_RESOLVER_BUCKET_ARG, CLI_RESOLVER_ADD_ARG, CLI_RESOLVER_EXTRAS_ARG],
+ },
+ { command: CLI_RESOLVER_PM_SCOOP, args: [CLI_TOOL_INSTALL_ARG, CLI_RESOLVER_DOTNET_SDK] },
+ ],
+ };
+}
+
+function chocoCommands(): PackageManagerCommands {
+ return {
+ packageManager: CLI_RESOLVER_PM_CHOCO,
+ detect: { command: CLI_RESOLVER_PM_CHOCO, args: [CLI_VERSION_FLAG] },
+ install: [
+ {
+ command: CLI_RESOLVER_PM_CHOCO,
+ args: [CLI_TOOL_INSTALL_ARG, CLI_RESOLVER_DOTNET_SDK, CLI_RESOLVER_YES_FLAG],
+ },
+ ],
+ };
+}
diff --git a/src/Napper.VsCode/src/constants.ts b/src/Napper.VsCode/src/constants.ts
index c29388b..f6405af 100644
--- a/src/Napper.VsCode/src/constants.ts
+++ b/src/Napper.VsCode/src/constants.ts
@@ -143,8 +143,7 @@ export const PROP_FILE_PATH = 'filePath';
export const CLI_BINARY_NAME = 'napper';
export const CLI_BIN_DIR = 'bin';
export const CLI_DOWNLOAD_REPO = 'Nimblesite/napper';
-export const CLI_DOWNLOAD_BASE_URL =
- 'https://github.com/Nimblesite/napper/releases/download';
+export const CLI_DOWNLOAD_BASE_URL = 'https://github.com/Nimblesite/napper/releases/download';
export const CLI_CHECKSUMS_FILE = 'checksums-sha256.txt';
export const CLI_ASSET_PREFIX = 'napper-';
export const CLI_WIN_EXE_SUFFIX = '.exe';
@@ -177,6 +176,16 @@ export const CLI_TOOL_VERSION_FLAG = '--version';
export const CLI_DOTNET_TOOL_INSTALL_TIMEOUT = 60000;
export const CLI_DOTNET_FALLBACK_MSG = 'Binary install failed, falling back to dotnet tool';
export const CLI_DOTNET_INSTALL_ERROR_PREFIX = 'dotnet tool install failed: ';
+export const CLI_RESOLVER_PM_BREW = 'brew';
+export const CLI_RESOLVER_PM_SCOOP = 'scoop';
+export const CLI_RESOLVER_PM_CHOCO = 'choco';
+export const CLI_RESOLVER_DOTNET_SDK = 'dotnet-sdk';
+export const CLI_RESOLVER_CASK_FLAG = '--cask';
+export const CLI_RESOLVER_BUCKET_ARG = 'bucket';
+export const CLI_RESOLVER_ADD_ARG = 'add';
+export const CLI_RESOLVER_EXTRAS_ARG = 'extras';
+export const CLI_RESOLVER_YES_FLAG = '-y';
+export const CLI_RESOLVER_UNKNOWN_ERROR = 'Unknown exec failure';
// CLI installer (shared)
export const CLI_INSTALL_MSG = 'Installing Napper CLI...';
diff --git a/src/Napper.VsCode/src/curlCopy.ts b/src/Napper.VsCode/src/curlCopy.ts
index 874a56e..d721e09 100644
--- a/src/Napper.VsCode/src/curlCopy.ts
+++ b/src/Napper.VsCode/src/curlCopy.ts
@@ -1,69 +1,20 @@
+// Implements [LSP-VSCODE-CURL]
// Specs: vscode-commands
-// Curl copy command — copyAsCurl and parsing helpers
-// Extracted from extension.ts to keep files under 450 LOC
+// Curl copy command — delegates to LSP napper.copyCurl command.
import * as vscode from 'vscode';
-import {
- CURL_CMD_PREFIX,
- DEFAULT_METHOD,
- HTTP_METHODS,
- MSG_COPIED,
- NAP_KEY_METHOD,
- NAP_KEY_URL,
-} from './constants';
-
-const EQUALS_CHAR = '=',
- SPACE_CHAR = ' ',
- valueAfterFirstEquals = (line: string): string => {
- const eqIndex = line.indexOf(EQUALS_CHAR);
- return eqIndex === -1 ? '' : line.slice(eqIndex + 1).trim();
- },
- matchesHttpMethodLine = (trimmed: string, method: string): boolean =>
- trimmed.startsWith(`${method}${SPACE_CHAR}`),
- extractMethodFromLine = (
- trimmed: string,
- ): { readonly method: string; readonly url: string } | undefined => {
- for (const m of HTTP_METHODS) {
- if (matchesHttpMethodLine(trimmed, m)) {
- return { method: m, url: trimmed.slice(m.length + 1).trim() };
- }
- }
- return undefined;
- },
- parseLine = (trimmed: string, current: { method: string; url: string }): void => {
- const httpMatch = extractMethodFromLine(trimmed);
- if (httpMatch !== undefined) {
- current.method = httpMatch.method;
- current.url = httpMatch.url;
- }
- if (trimmed.startsWith(NAP_KEY_METHOD) && trimmed.includes(EQUALS_CHAR)) {
- current.method = valueAfterFirstEquals(trimmed);
- }
- if (trimmed.startsWith(NAP_KEY_URL) && trimmed.includes(EQUALS_CHAR)) {
- current.url = valueAfterFirstEquals(trimmed);
- }
- };
-
-export const parseMethodAndUrl = (
- text: string,
-): { readonly method: string; readonly url: string } => {
- const result = { method: DEFAULT_METHOD, url: '' },
- lines = text.split('\n');
- for (const line of lines) {
- parseLine(line.trim(), result);
- }
- return result;
-};
+import { MSG_COPIED } from './constants';
+import { copyCurl } from './lspClient';
export const copyAsCurl = async (uri?: vscode.Uri): Promise => {
const fileUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (fileUri === undefined) {
return;
}
-
- const doc = await vscode.workspace.openTextDocument(fileUri),
- { method, url } = parseMethodAndUrl(doc.getText()),
- curl = `${CURL_CMD_PREFIX}${method} '${url}'`;
+ const curl = await copyCurl(fileUri);
+ if (curl === undefined) {
+ return;
+ }
await vscode.env.clipboard.writeText(curl);
void vscode.window.showInformationMessage(MSG_COPIED);
};
diff --git a/src/Napper.VsCode/src/environmentAdapter.ts b/src/Napper.VsCode/src/environmentAdapter.ts
index aeeb02d..61c9a03 100644
--- a/src/Napper.VsCode/src/environmentAdapter.ts
+++ b/src/Napper.VsCode/src/environmentAdapter.ts
@@ -1,14 +1,14 @@
+// Implements [LSP-VSCODE-ENV]
// Specs: vscode-env-switcher, vscode-impl
// VSCode adapter for the environment switcher
// Status bar item and quick pick integration
import * as vscode from 'vscode';
-import { detectEnvironments } from './environmentSwitcher';
+import { listEnvironments } from './lspClient';
import {
CMD_SWITCH_ENV,
CONFIG_DEFAULT_ENV,
CONFIG_SECTION,
- NAPENV_GLOB,
PROMPT_SELECT_ENV,
STATUS_BAR_NO_ENV,
STATUS_BAR_PREFIX,
@@ -49,8 +49,8 @@ export class EnvironmentStatusBar implements vscode.Disposable {
}
async showPicker(): Promise {
- const files = await vscode.workspace.findFiles(NAPENV_GLOB, '**/node_modules/**'),
- envNames = detectEnvironments(files.map((f) => f.fsPath)),
+ const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri;
+ const envNames = rootUri !== undefined ? (await listEnvironments(rootUri)) ?? [] : [],
items = envNames.map((name) => ({
label: name,
picked: name === this._currentEnv,
diff --git a/src/Napper.VsCode/src/extension.ts b/src/Napper.VsCode/src/extension.ts
index ad9eea7..65c2961 100644
--- a/src/Napper.VsCode/src/extension.ts
+++ b/src/Napper.VsCode/src/extension.ts
@@ -29,6 +29,7 @@ import {
} from './editAndImportCommands';
import { registerContextMenuCommands } from './contextMenuCommands';
import { registerAutoRun, registerWatchers } from './watchers';
+import { startLspClient, stopLspClient } from './lspClient';
import {
CLI_BIN_DIR,
CLI_BINARY_NAME,
@@ -74,6 +75,7 @@ import {
} from './constants';
let envStatusBar: EnvironmentStatusBar,
+ extensionContext: vscode.ExtensionContext,
extensionDir: string,
extensionVersion: string,
explorerProvider: ExplorerAdapter,
@@ -81,6 +83,7 @@ let envStatusBar: EnvironmentStatusBar,
lastPlaylistReport: (() => void) | undefined,
lastResult: RunResult | undefined,
logger: Logger,
+ outputChannel: vscode.OutputChannel,
playlistPanel: PlaylistPanel,
responsePanel: ResponsePanel,
storageDir: string;
@@ -112,6 +115,7 @@ const bundledCliPath = (): string => path.join(extensionDir, CLI_BIN_DIR, CLI_BI
}
installedCliOverride = cliPath;
logger.info(`${CLI_INSTALL_COMPLETE_MSG} (${cliPath})`);
+ startLspClient(cliPath, outputChannel, extensionContext);
return true;
},
checkVersionMatch = async (): Promise => {
@@ -370,7 +374,8 @@ const collectResult = (state: StreamState, result: RunResult): void => {
);
},
initLogger = (context: vscode.ExtensionContext): void => {
- const outputChannel = vscode.window.createOutputChannel(LOG_CHANNEL_NAME);
+ extensionContext = context;
+ outputChannel = vscode.window.createOutputChannel(LOG_CHANNEL_NAME);
context.subscriptions.push(outputChannel);
logger = createLogger((msg) => {
outputChannel.appendLine(msg);
@@ -407,6 +412,7 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi {
return { explorerProvider };
}
-export function deactivate(): void {
+export async function deactivate(): Promise {
logger.info(LOG_MSG_DEACTIVATED);
+ await stopLspClient();
}
diff --git a/src/Napper.VsCode/src/lspClient.ts b/src/Napper.VsCode/src/lspClient.ts
new file mode 100644
index 0000000..9bbfa17
--- /dev/null
+++ b/src/Napper.VsCode/src/lspClient.ts
@@ -0,0 +1,114 @@
+// Implements [LSP-VSCODE-CLIENT]
+// Napper LSP client — spawns 'napper lsp' and connects via vscode-languageclient.
+// Decoupled from the CLI resolver: receives the resolved cliPath.
+
+import * as vscode from 'vscode';
+import {
+ LanguageClient,
+ type LanguageClientOptions,
+ type ServerOptions,
+ TransportKind,
+} from 'vscode-languageclient/node';
+import { NAP_EXTENSION, NAPENV_EXTENSION, NAPLIST_EXTENSION } from './constants';
+
+const LSP_CLIENT_ID = 'napper-lsp';
+const LSP_CLIENT_NAME = 'Napper Language Server';
+const LSP_SUBCOMMAND = 'lsp';
+
+const documentSelector = [
+ { scheme: 'file', language: 'nap' },
+ { scheme: 'file', language: 'naplist' },
+ { scheme: 'file', language: 'napenv' },
+];
+
+const filePattern = `**/*{${NAP_EXTENSION},${NAPLIST_EXTENSION},${NAPENV_EXTENSION}}`;
+
+let client: LanguageClient | undefined;
+
+const buildServerOptions = (cliPath: string): ServerOptions => ({
+ command: cliPath,
+ args: [LSP_SUBCOMMAND],
+ transport: TransportKind.stdio,
+});
+
+const buildClientOptions = (outputChannel: vscode.OutputChannel): LanguageClientOptions => ({
+ documentSelector,
+ synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher(filePattern) },
+ outputChannel,
+});
+
+/** Start the Napper language server using the resolved CLI path. */
+export const startLspClient = (
+ cliPath: string,
+ outputChannel: vscode.OutputChannel,
+ context: vscode.ExtensionContext,
+): void => {
+ if (client !== undefined) {
+ return;
+ }
+ const serverOptions = buildServerOptions(cliPath);
+ const clientOptions = buildClientOptions(outputChannel);
+ const newClient = new LanguageClient(LSP_CLIENT_ID, LSP_CLIENT_NAME, serverOptions, clientOptions);
+ client = newClient;
+ void newClient.start();
+ context.subscriptions.push(newClient);
+};
+
+/** Stop the Napper language server (called on deactivate). */
+export const stopLspClient = async (): Promise => {
+ const current = client;
+ if (current === undefined) {
+ return;
+ }
+ client = undefined;
+ await current.stop();
+};
+
+/**
+ * Send napper.requestInfo custom command to the LSP.
+ * Returns { method, url, headers } or undefined if LSP not available.
+ */
+export const requestInfo = async (
+ uri: vscode.Uri,
+): Promise<{ method: string; url: string; headers: Record } | undefined> => {
+ if (client === undefined) {
+ return undefined;
+ }
+ const result = await client.sendRequest<
+ { method: string; url: string; headers: Record } | null
+ >('workspace/executeCommand', {
+ command: 'napper.requestInfo',
+ arguments: [uri.toString()],
+ });
+ return result ?? undefined;
+};
+
+/**
+ * Send napper.copyCurl custom command to the LSP.
+ * Returns the curl string or undefined if LSP not available.
+ */
+export const copyCurl = async (uri: vscode.Uri): Promise => {
+ if (client === undefined) {
+ return undefined;
+ }
+ const result = await client.sendRequest('workspace/executeCommand', {
+ command: 'napper.copyCurl',
+ arguments: [uri.toString()],
+ });
+ return result ?? undefined;
+};
+
+/**
+ * Send napper.listEnvironments custom command to the LSP.
+ * Returns the list of env names or undefined if LSP not available.
+ */
+export const listEnvironments = async (rootUri: vscode.Uri): Promise => {
+ if (client === undefined) {
+ return undefined;
+ }
+ const result = await client.sendRequest('workspace/executeCommand', {
+ command: 'napper.listEnvironments',
+ arguments: [rootUri.toString()],
+ });
+ return result ?? undefined;
+};
diff --git a/src/Napper.VsCode/src/test/unit/cliResolver.test.ts b/src/Napper.VsCode/src/test/unit/cliResolver.test.ts
new file mode 100644
index 0000000..c0f3468
--- /dev/null
+++ b/src/Napper.VsCode/src/test/unit/cliResolver.test.ts
@@ -0,0 +1,192 @@
+import * as assert from 'assert';
+import type { ExecCommand, ExecResult } from '../../cliResolverCommands';
+import { resolveCli, type ResolverExec } from '../../cliResolver';
+import {
+ CLI_BINARY_NAME,
+ CLI_DOTNET_CMD,
+ CLI_RESOLVER_PM_BREW,
+ CLI_RESOLVER_PM_SCOOP,
+ CLI_TOOL_UPDATE_ARG,
+ CLI_VERSION_FLAG,
+} from '../../constants';
+import { ResolverErrorKind } from '../../types';
+
+const VSIX_VERSION = '0.12.0',
+ OLD_VERSION = '0.9.0',
+ DOTNET_VERSION = '10.0.100',
+ EXEC_FAILED: ExecResult = { exitCode: 1, stdout: '', stderr: 'ENOENT' };
+
+interface MockExec {
+ readonly exec: ResolverExec;
+ readonly calls: ExecCommand[];
+}
+
+const success = ({ stdout }: { readonly stdout: string }): ExecResult => ({
+ exitCode: 0,
+ stdout,
+ stderr: '',
+});
+
+const failure = ({ stderr }: { readonly stderr: string }): ExecResult => ({
+ exitCode: 1,
+ stdout: '',
+ stderr,
+});
+
+const makeExec = ({ responses }: { readonly responses: readonly ExecResult[] }): MockExec => {
+ const calls: ExecCommand[] = [];
+ let index = 0;
+ const exec: ResolverExec = async (command) => {
+ calls.push(command);
+ const response = responses[index] ?? EXEC_FAILED;
+ index += 1;
+ await Promise.resolve();
+ return response;
+ };
+ return { exec, calls };
+};
+
+const consent =
+ ({ value }: { readonly value: boolean }) =>
+ async (): Promise => {
+ await Promise.resolve();
+ return value;
+ };
+
+const callAt = ({
+ calls,
+ index,
+}: {
+ readonly calls: readonly ExecCommand[];
+ readonly index: number;
+}): ExecCommand => {
+ const call = calls[index];
+ assert.ok(call);
+ return call;
+};
+
+suite('cliResolver', () => {
+ test('returns configured CLI path when version matches', async () => {
+ const mock = makeExec({ responses: [success({ stdout: `${VSIX_VERSION}\n` })] });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'darwin',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.ok(result.ok);
+ assert.strictEqual(result.value.cliPath, CLI_BINARY_NAME);
+ assert.deepStrictEqual(mock.calls, [{ command: CLI_BINARY_NAME, args: [CLI_VERSION_FLAG] }]);
+ });
+
+ test('updates dotnet tool when PATH version mismatches', async () => {
+ const mock = makeExec({
+ responses: [
+ success({ stdout: OLD_VERSION }),
+ success({ stdout: DOTNET_VERSION }),
+ success({ stdout: '' }),
+ success({ stdout: VSIX_VERSION }),
+ ],
+ });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'darwin',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.strictEqual(result.ok, true);
+ const toolCall = callAt({ calls: mock.calls, index: 2 });
+ assert.strictEqual(toolCall.command, CLI_DOTNET_CMD);
+ assert.ok(toolCall.args.includes(CLI_TOOL_UPDATE_ARG));
+ });
+
+ test('installs dotnet through brew before installing napper', async () => {
+ const mock = makeExec({
+ responses: [
+ EXEC_FAILED,
+ EXEC_FAILED,
+ success({ stdout: 'brew' }),
+ success({ stdout: '' }),
+ success({ stdout: DOTNET_VERSION }),
+ success({ stdout: '' }),
+ success({ stdout: VSIX_VERSION }),
+ ],
+ });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'darwin',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(callAt({ calls: mock.calls, index: 2 }).command, CLI_RESOLVER_PM_BREW);
+ assert.strictEqual(callAt({ calls: mock.calls, index: 3 }).command, CLI_RESOLVER_PM_BREW);
+ });
+
+ test('returns pm-missing when no package manager exists', async () => {
+ const mock = makeExec({ responses: [EXEC_FAILED, EXEC_FAILED, EXEC_FAILED] });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'linux',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.ok(!result.ok);
+ assert.strictEqual(result.error.kind, ResolverErrorKind.PmMissing);
+ assert.strictEqual(result.error.os, 'linux');
+ });
+
+ test('returns consent-declined when user declines dotnet install', async () => {
+ const mock = makeExec({ responses: [EXEC_FAILED, EXEC_FAILED, success({ stdout: 'brew' })] });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'darwin',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: false }),
+ });
+ assert.ok(!result.ok);
+ assert.strictEqual(result.error.kind, ResolverErrorKind.ConsentDeclined);
+ });
+
+ test('returns pm-install-failed when package manager install fails', async () => {
+ const mock = makeExec({
+ responses: [
+ EXEC_FAILED,
+ EXEC_FAILED,
+ success({ stdout: 'brew' }),
+ failure({ stderr: 'no recipe' }),
+ ],
+ });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'darwin',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.ok(!result.ok);
+ assert.strictEqual(result.error.kind, ResolverErrorKind.PmInstallFailed);
+ });
+
+ test('uses scoop first on Windows when dotnet is missing', async () => {
+ const mock = makeExec({
+ responses: [
+ EXEC_FAILED,
+ EXEC_FAILED,
+ success({ stdout: 'scoop' }),
+ success({ stdout: '' }),
+ success({ stdout: '' }),
+ success({ stdout: DOTNET_VERSION }),
+ success({ stdout: '' }),
+ success({ stdout: VSIX_VERSION }),
+ ],
+ });
+ const result = await resolveCli({
+ vsixVersion: VSIX_VERSION,
+ platform: 'win32',
+ exec: mock.exec,
+ confirmDotnetInstall: consent({ value: true }),
+ });
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(callAt({ calls: mock.calls, index: 2 }).command, CLI_RESOLVER_PM_SCOOP);
+ });
+});
diff --git a/src/Napper.VsCode/src/types.ts b/src/Napper.VsCode/src/types.ts
index cbd5d66..77b7819 100644
--- a/src/Napper.VsCode/src/types.ts
+++ b/src/Napper.VsCode/src/types.ts
@@ -39,6 +39,42 @@ export const err = (error: E): Result => ({
error,
});
+export const enum ResolverErrorKind {
+ PathMismatch = 'path-mismatch',
+ DotnetMissing = 'dotnet-missing',
+ ConsentDeclined = 'consent-declined',
+ PmMissing = 'pm-missing',
+ PmInstallFailed = 'pm-install-failed',
+ ToolInstallFailed = 'tool-install-failed',
+ RestartRequired = 'restart-required',
+}
+
+export type ResolverPlatform = 'darwin' | 'linux' | 'win32';
+
+export type PackageManager = 'brew' | 'scoop' | 'choco';
+
+export type ResolverError =
+ | {
+ readonly kind: ResolverErrorKind.PathMismatch;
+ readonly expected: string;
+ readonly actual: string;
+ }
+ | { readonly kind: ResolverErrorKind.DotnetMissing }
+ | { readonly kind: ResolverErrorKind.ConsentDeclined }
+ | { readonly kind: ResolverErrorKind.PmMissing; readonly os: ResolverPlatform }
+ | {
+ readonly kind: ResolverErrorKind.PmInstallFailed;
+ readonly pm: PackageManager;
+ readonly stderr: string;
+ readonly exitCode: number;
+ }
+ | {
+ readonly kind: ResolverErrorKind.ToolInstallFailed;
+ readonly stderr: string;
+ readonly exitCode: number;
+ }
+ | { readonly kind: ResolverErrorKind.RestartRequired };
+
export const enum RunState {
Idle,
Running,
diff --git a/src/Napper.Zed/src/lib.rs b/src/Napper.Zed/src/lib.rs
index 6e70803..75ed7bc 100644
--- a/src/Napper.Zed/src/lib.rs
+++ b/src/Napper.Zed/src/lib.rs
@@ -27,6 +27,9 @@ const NAP_LSP_ID: &str = "nap-lsp";
/// CLI binary name.
const NAP_CLI: &str = "nap";
+/// CLI binary name for the language server.
+const NAPPER_LSP_CLI: &str = "napper";
+
/// Usage message for the nap-run command.
const NAP_RUN_USAGE: &str = "Usage: /nap-run ";
@@ -39,8 +42,12 @@ const CLI_LAUNCH_ERROR: &str = "Is `nap` installed and on PATH?";
/// Stderr separator in error output.
const STDERR_SEPARATOR: &str = "\n--- stderr ---\n";
-/// LSP not-yet-available message.
-const LSP_NOT_AVAILABLE: &str = "Nap Language Server not yet available — install when released";
+/// LSP subcommand argument.
+const LSP_SUBCOMMAND: &str = "lsp";
+
+/// Error message when napper binary is not found on PATH.
+const NAPPER_NOT_FOUND: &str =
+ "napper not found on PATH — install via: dotnet tool install -g napper";
/// Nap Zed extension entry point — implements all Zed extension traits.
pub struct NapExtension;
@@ -56,8 +63,7 @@ impl zed::Extension for NapExtension {
language_server_id: &LanguageServerId,
worktree: &Worktree,
) -> Result {
- let _ = worktree;
- resolve_language_server(language_server_id.as_ref())
+ resolve_language_server(language_server_id.as_ref(), worktree.which(NAPPER_LSP_CLI))
}
fn language_server_initialization_options(
@@ -103,12 +109,23 @@ impl zed::Extension for NapExtension {
}
/// Resolve language server command by ID.
-fn resolve_language_server(id: &str) -> Result {
+/// Implements [LSP-ZED-CLIENT]: launches 'napper lsp' over stdio.
+fn resolve_language_server(id: &str, napper_path: Option) -> Result {
if id != NAP_LSP_ID {
return Err(format!("Unknown language server: {id}"));
}
- // TODO: LOUD — implement LSP binary discovery and launch
- Err(LSP_NOT_AVAILABLE.to_string())
+ napper_path
+ .map(build_language_server_command)
+ .ok_or_else(|| NAPPER_NOT_FOUND.to_string())
+}
+
+/// Build the command used to launch 'napper lsp'.
+fn build_language_server_command(napper: String) -> Command {
+ Command {
+ command: napper,
+ args: vec![LSP_SUBCOMMAND.to_string()],
+ env: Vec::default(),
+ }
}
/// Route slash command argument completions by command name.
diff --git a/src/Napper.Zed/src/tests/tests_pure.rs b/src/Napper.Zed/src/tests/tests_pure.rs
index 71a784e..41d31a2 100644
--- a/src/Napper.Zed/src/tests/tests_pure.rs
+++ b/src/Napper.Zed/src/tests/tests_pure.rs
@@ -155,6 +155,16 @@ fn cli_constant_is_nap() {
assert_eq!(NAP_CLI, "nap");
}
+#[test]
+fn lsp_cli_constant_is_napper() {
+ assert_eq!(NAPPER_LSP_CLI, "napper");
+}
+
+#[test]
+fn lsp_subcommand_constant_is_lsp() {
+ assert_eq!(LSP_SUBCOMMAND, "lsp");
+}
+
#[test]
fn command_constants_match_extension_toml() {
assert_eq!(NAP_RUN_COMMAND, "nap-run");
@@ -170,15 +180,24 @@ fn file_extension_constants() {
// ─── resolve_language_server ────────────────────────────────
#[test]
-fn resolve_known_lsp_returns_not_available() {
- let result = resolve_language_server(NAP_LSP_ID);
+fn resolve_known_lsp_returns_napper_lsp_command() {
+ let napper_path = "/usr/local/bin/napper".to_string();
+ let result = resolve_language_server(NAP_LSP_ID, Some(napper_path.clone())).unwrap();
+ assert_eq!(result.command, napper_path);
+ assert_eq!(result.args, vec![LSP_SUBCOMMAND.to_string()]);
+ assert!(result.env.is_empty());
+}
+
+#[test]
+fn resolve_known_lsp_without_path_returns_install_error() {
+ let result = resolve_language_server(NAP_LSP_ID, None);
let err = result.unwrap_err();
- assert_eq!(err, LSP_NOT_AVAILABLE);
+ assert_eq!(err, NAPPER_NOT_FOUND);
}
#[test]
fn resolve_unknown_lsp_returns_error_with_id() {
- let result = resolve_language_server("some-other-lsp");
+ let result = resolve_language_server("some-other-lsp", Some(NAPPER_LSP_CLI.to_string()));
let err = result.unwrap_err();
assert!(err.contains("Unknown language server"));
assert!(err.contains("some-other-lsp"));
From ff44c1f2e91144bded751cc9fd971acec505cd37 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 11:19:56 +1000
Subject: [PATCH 11/48] Formatting
---
src/Napper.Lsp/Server.fs | 9 ++++++---
src/Napper.VsCode/src/environmentAdapter.ts | 2 +-
src/Napper.VsCode/src/lspClient.ts | 15 +++++++++++----
3 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/src/Napper.Lsp/Server.fs b/src/Napper.Lsp/Server.fs
index f7076f5..3b70bac 100644
--- a/src/Napper.Lsp/Server.fs
+++ b/src/Napper.Lsp/Server.fs
@@ -336,13 +336,16 @@ module LspRunner =
let isSerializable = this.ExceptionStrategy = ExceptionProcessing.ISerializable
let data: obj =
- if isSerializable then (jex :> obj)
- else Protocol.CommonErrorData(jex)
+ if isSerializable then
+ (jex :> obj)
+ else
+ Protocol.CommonErrorData(jex)
Protocol.JsonRpcError.ErrorDetail(
Code = Protocol.JsonRpcErrorCode.ParseError,
Message = jex.Message,
- Data = data)
+ Data = data
+ )
| _ -> base.CreateErrorDetails(request, ex) }
/// Start the LSP server over the given streams. Returns the exit code.
diff --git a/src/Napper.VsCode/src/environmentAdapter.ts b/src/Napper.VsCode/src/environmentAdapter.ts
index 61c9a03..be441a5 100644
--- a/src/Napper.VsCode/src/environmentAdapter.ts
+++ b/src/Napper.VsCode/src/environmentAdapter.ts
@@ -50,7 +50,7 @@ export class EnvironmentStatusBar implements vscode.Disposable {
async showPicker(): Promise {
const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri;
- const envNames = rootUri !== undefined ? (await listEnvironments(rootUri)) ?? [] : [],
+ const envNames = rootUri !== undefined ? ((await listEnvironments(rootUri)) ?? []) : [],
items = envNames.map((name) => ({
label: name,
picked: name === this._currentEnv,
diff --git a/src/Napper.VsCode/src/lspClient.ts b/src/Napper.VsCode/src/lspClient.ts
index 9bbfa17..8ab4b75 100644
--- a/src/Napper.VsCode/src/lspClient.ts
+++ b/src/Napper.VsCode/src/lspClient.ts
@@ -48,7 +48,12 @@ export const startLspClient = (
}
const serverOptions = buildServerOptions(cliPath);
const clientOptions = buildClientOptions(outputChannel);
- const newClient = new LanguageClient(LSP_CLIENT_ID, LSP_CLIENT_NAME, serverOptions, clientOptions);
+ const newClient = new LanguageClient(
+ LSP_CLIENT_ID,
+ LSP_CLIENT_NAME,
+ serverOptions,
+ clientOptions,
+ );
client = newClient;
void newClient.start();
context.subscriptions.push(newClient);
@@ -74,9 +79,11 @@ export const requestInfo = async (
if (client === undefined) {
return undefined;
}
- const result = await client.sendRequest<
- { method: string; url: string; headers: Record } | null
- >('workspace/executeCommand', {
+ const result = await client.sendRequest<{
+ method: string;
+ url: string;
+ headers: Record;
+ } | null>('workspace/executeCommand', {
command: 'napper.requestInfo',
arguments: [uri.toString()],
});
From c8b3b5d11666ab1e89d66303205430b26146d596 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 14:47:41 +1000
Subject: [PATCH 12/48] LSP integration
---
.claude/skills/spec-check/SKILL.md | 300 +++++++++++++++++++++++++++++
.github/workflows/ci.yml | 121 +-----------
.vscode/settings.json | 10 +-
Makefile | 255 ++++++++++++++----------
coverage-thresholds.json | 25 +++
5 files changed, 492 insertions(+), 219 deletions(-)
create mode 100644 .claude/skills/spec-check/SKILL.md
create mode 100644 coverage-thresholds.json
diff --git a/.claude/skills/spec-check/SKILL.md b/.claude/skills/spec-check/SKILL.md
new file mode 100644
index 0000000..e8ac580
--- /dev/null
+++ b/.claude/skills/spec-check/SKILL.md
@@ -0,0 +1,300 @@
+---
+name: spec-check
+description: Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and matching logic. Use when the user says "check specs", "spec audit", or "verify specs".
+argument-hint: "[optional spec ID or filename filter]"
+---
+
+
+
+# spec-check
+
+> **Portable skill.** This skill adapts to the current repository. The agent MUST inspect the repo structure and use judgment to apply these instructions appropriately.
+
+Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and that the code logic matches the spec.
+
+## Arguments
+
+- `$ARGUMENTS` — optional spec name or ID to check (e.g., `AUTH-TOKEN-VERIFY` or `repo-standards`). If empty, check ALL specs. Spec IDs are descriptive slugs, NEVER numbered (see Step 1).
+
+## Instructions
+
+Follow these steps exactly. Be strict and pedantic. Stop on the first failure.
+
+---
+
+### Step 1: Validate spec ID structure
+
+Before checking code/test references, verify that the specs themselves are well-formed.
+
+1. Find all spec documents (see locations in Step 2).
+2. Extract every section ID using the regex `\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\]`.
+3. **Flag invalid IDs:**
+ - Numbered IDs (`[SPEC-001]`, `[REQ-003]`, `[CI-004]`) — must be renamed to descriptive hierarchical slugs.
+ - Single-word IDs (`[TIMEOUT]`) — must have a group prefix.
+ - IDs with trailing numbers (`[FEAT-AUTH-01]`) — the number is meaningless, remove it.
+4. **Check group clustering:** The first word of each ID is its group. All sections in the same group MUST appear together (adjacent) in the document. If they're scattered, flag it.
+5. **Check for missing IDs:** Any heading that defines a requirement or behavior should have an ID. Flag headings in spec files that look like they define behavior but lack an ID.
+
+If any ID violations are found, report them all and **STOP**:
+```
+SPEC ID VIOLATIONS:
+
+- docs/specs/AUTH-SPEC.md line 12: [SPEC-001] → rename to descriptive ID (e.g., [AUTH-LOGIN])
+- docs/specs/AUTH-SPEC.md line 30: [AUTH-TOKEN-VERIFY] and [AUTH-LOGIN] are not adjacent (scattered group)
+- docs/specs/CI-SPEC.md line 5: "## Coverage thresholds" has no spec ID
+
+Fix spec IDs first, then re-run spec-check.
+```
+
+If all IDs are valid, proceed to Step 2.
+
+---
+
+### Step 2: Find all spec/plan documents
+
+Search for markdown files that contain spec sections with IDs. Look in these locations:
+
+- `docs/*.md`
+- `docs/**/*.md`
+- `SPEC.md`
+- `PLAN.md`
+- `specs/*.md`
+
+Use Glob to find candidate files, then use Grep to confirm they contain spec IDs.
+
+**Spec ID patterns** — IDs appear in square brackets, typically at the start of a heading or section line. Match this regex pattern:
+
+```
+\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\]
+```
+
+Spec IDs are **hierarchical descriptive slugs, NEVER numbered.** The format is `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]`. The first word is the **group** — all sections sharing the same group MUST appear together in the spec's table of contents. IDs are uppercase, hyphen-separated, unique across the repo, and MUST NOT contain sequential numbers.
+
+The hierarchy depth varies by repo: two words for simple repos (`[AUTH-LOGIN]`), three for most (`[AUTH-TOKEN-VERIFY]`), four for complex domains (`[AUTH-OAUTH-REFRESH-FLOW]`). The hierarchy mirrors the spec document's heading structure.
+
+Examples of valid spec IDs (note how groups cluster):
+- `[AUTH-LOGIN]`, `[AUTH-TOKEN-VERIFY]`, `[AUTH-TOKEN-REFRESH]` — all in the AUTH group
+- `[CI-TIMEOUT]`, `[CI-LINT]`, `[CI-RELEASE]` — all in the CI group
+- `[LINT-ESLINT]`, `[LINT-RUFF]` — all in the LINT group
+- `[FEAT-DARK-MODE]`, `[FEAT-SEARCH-FILTER]` — all in the FEAT group
+
+Examples of INVALID spec IDs:
+- `[SPEC-001]` — numbered, meaningless
+- `[FEAT-AUTH-01]` — trailing number
+- `[REQ-003]` — sequential index, no group hierarchy
+- `[CI-004]` — numbered, tells the reader nothing
+- `[TIMEOUT]` — no group prefix, ungrouped
+
+For each file, extract every spec ID and its associated section title (the heading text after the ID) and the full section content (everything until the next heading of equal or higher level).
+
+---
+
+### Step 3: Filter specs
+
+- If `$ARGUMENTS` is non-empty, filter the discovered specs:
+ - If it matches a spec ID exactly (e.g., `AUTH-TOKEN-VERIFY`), check only that spec.
+ - If it matches a partial name (e.g., `repo-standards`), check all specs in files whose path contains that string.
+- If `$ARGUMENTS` is empty, process ALL discovered specs.
+
+If filtering produces zero specs, report an error:
+```
+ERROR: No specs found matching "$ARGUMENTS". Discovered spec files: [list them]
+```
+
+---
+
+### Step 4: Check each spec section
+
+For EACH spec section that has an ID, perform checks A, B, and C below. **Stop on the first failure.**
+
+#### Check A: Code references the spec ID
+
+Search the entire codebase for the spec ID string, **excluding** these directories:
+- `docs/`
+- `node_modules/`
+- `.git/`
+- `*.md` files (markdown is docs, not code)
+
+Use Grep with the literal spec ID (e.g., `[AUTH-TOKEN-VERIFY]`) to find references in code files.
+
+Code files should contain comments referencing the spec ID. The search must catch **all** comment styles across languages:
+
+**C-style `//` comments** (JavaScript, TypeScript, Rust, C#, F#, Java, Kotlin, Go, Swift, Dart):
+- `// Implements [AUTH-TOKEN-VERIFY]`
+- `// [AUTH-TOKEN-VERIFY]`
+- `// Tests [AUTH-TOKEN-VERIFY]` (also counts as a code reference)
+- `/// Implements [AUTH-TOKEN-VERIFY]` (doc comments)
+
+**Hash `#` comments** (Python, Ruby, Shell/Bash, YAML, TOML):
+- `# Implements [AUTH-TOKEN-VERIFY]`
+- `# [AUTH-TOKEN-VERIFY]`
+- `# Tests [AUTH-TOKEN-VERIFY]`
+
+**HTML/XML comments** (HTML, CSS, SVG, XML, XAML, JSX templates):
+- ``
+- ``
+
+**ML-style comments** (F#, OCaml):
+- `(* Implements [AUTH-TOKEN-VERIFY] *)`
+
+**Lua comments:**
+- `-- Implements [AUTH-TOKEN-VERIFY]`
+
+**CSS comments:**
+- `/* Implements [AUTH-TOKEN-VERIFY] */`
+
+**The key rule:** any comment in any language containing the exact spec ID string (e.g., `[AUTH-TOKEN-VERIFY]`) counts as a valid code reference. The Grep search uses the literal spec ID string, so it naturally matches all comment styles. Do NOT restrict the search to specific comment prefixes — just search for the spec ID string itself.
+
+**If NO code files reference the spec ID:**
+
+```
+SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no implementing code.
+
+Every spec section must have at least one code file that references it via a comment
+containing the spec ID (e.g., `// Implements [AUTH-TOKEN-VERIFY]`).
+
+ACTION REQUIRED: Add a comment referencing [AUTH-TOKEN-VERIFY] in the file(s) that implement
+this spec section, then re-run spec-check.
+```
+
+**STOP HERE. Do not continue to other checks.**
+
+#### Check B: Tests reference the spec ID
+
+Search test files for the spec ID. Test files are found in:
+- `test/`
+- `tests/`
+- `**/*.test.*`
+- `**/*.spec.*`
+- `**/*_test.*`
+- `**/test_*.*`
+- `**/*Tests.*`
+- `**/*Test.*`
+
+Use Grep to search these locations for the literal spec ID string.
+
+Tests should contain the spec ID in comments, test names, or annotations. The search must catch **all** test frameworks across languages:
+
+**JavaScript/TypeScript** (Jest, Mocha, Vitest, Playwright):
+- `// Tests [AUTH-TOKEN-VERIFY]`
+- `describe('[AUTH-TOKEN-VERIFY] Authentication flow', () => ...)`
+- `test('[AUTH-TOKEN-VERIFY] should verify token', () => ...)`
+- `it('[AUTH-TOKEN-VERIFY] verifies token', () => ...)`
+
+**Rust:**
+- `// Tests [AUTH-TOKEN-VERIFY]`
+- `#[test] // Tests [AUTH-TOKEN-VERIFY]`
+
+**C#** (xUnit, NUnit, MSTest):
+- `// Tests [AUTH-TOKEN-VERIFY]`
+- `[Fact] // Tests [AUTH-TOKEN-VERIFY]`
+- `[Test] // Tests [AUTH-TOKEN-VERIFY]`
+- `[TestMethod] // Tests [AUTH-TOKEN-VERIFY]`
+
+**F#** (xUnit, Expecto):
+- `// Tests [AUTH-TOKEN-VERIFY]`
+- `[] // Tests [AUTH-TOKEN-VERIFY]`
+- `testCase "[AUTH-TOKEN-VERIFY] description" <| fun () ->`
+
+**The key rule:** same as Check A — search for the literal spec ID string in test files. Any occurrence of the exact spec ID in a test file counts. Do NOT restrict to specific patterns — just search for the spec ID string itself.
+
+**If NO test files reference the spec ID:**
+
+```
+SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no tests.
+
+Every spec section must have corresponding tests that reference the spec ID.
+
+ACTION REQUIRED: Add tests for [AUTH-TOKEN-VERIFY] with a comment or test name containing
+the spec ID, then re-run spec-check.
+```
+
+**STOP HERE. Do not continue to other checks.**
+
+#### Check C: Code logic matches the spec
+
+This is the most critical check. You must:
+
+1. **Read the spec section content carefully.** Understand exactly what behavior, logic, ordering, conditions, and constraints the spec describes.
+
+2. **Read the implementing code.** Use the references found in Check A to locate the implementing files. Read the relevant functions/sections.
+
+3. **Compare spec vs. code.** Be SENSITIVE and PEDANTIC. Check for:
+ - **Ordering violations** — If the spec says A happens before B, the code must do A before B.
+ - **Missing conditions** — If the spec says "only when X", the code must have that condition.
+ - **Extra behavior** — If the code does something the spec doesn't mention, flag it only if it contradicts the spec.
+ - **Wrong logic** — If the spec says "greater than" but code uses "greater than or equal", that's a violation.
+ - **Missing steps** — If the spec describes 5 steps but code only implements 3, that's a violation.
+ - **Wrong defaults** — If the spec says "default to X" but code defaults to Y, that's a violation.
+
+4. **If the code deviates from the spec**, report a detailed error:
+
+```
+SPEC VIOLATION: [AUTH-TOKEN-VERIFY] Code does not match spec.
+
+SPEC SAYS:
+> "The authentication flow must verify the token expiry before checking permissions"
+> (from docs/specs/AUTH-SPEC.md, line 42)
+
+CODE DOES:
+> `if (hasPermission(user)) { verifyToken(token); }` (src/auth.ts:42)
+
+DEVIATION: The code checks permissions BEFORE verifying token expiry.
+The spec explicitly requires token expiry verification FIRST.
+
+ACTION REQUIRED: Reorder the logic in src/auth.ts to verify token expiry
+before checking permissions, as specified in [AUTH-TOKEN-VERIFY].
+```
+
+**STOP HERE. Do not continue to other specs.**
+
+5. **If the code matches the spec**, this check passes. Move to the next spec.
+
+---
+
+### Step 5: Report results
+
+#### On failure (any check fails):
+
+Output ONLY the first violation found. Use the exact error format shown above. Do not summarize other specs. Do not offer to fix the code. Just report the violation.
+
+End with:
+```
+spec-check FAILED. Fix the violation above and re-run.
+```
+
+#### On success (all specs pass):
+
+Output a summary table:
+
+```
+spec-check PASSED. All specs verified.
+
+| Spec ID | Title | Code References | Test References | Logic Match |
+|----------------|--------------------------|-----------------|-----------------|-------------|
+| [AUTH-TOKEN-VERIFY] | Authentication flow | src/auth.ts | tests/auth.test.ts | PASS |
+| [RATE-LIMIT-CONFIG] | Rate limiting | src/rate.ts | tests/rate.test.ts | PASS |
+| ... | ... | ... | ... | ... |
+
+Checked N spec sections across M files. All have implementing code, tests, and matching logic.
+```
+
+---
+
+## Search strategy summary
+
+1. **Validate spec IDs:** Check all IDs are hierarchical, descriptive, grouped, and non-numbered
+2. **Find spec files:** Glob for `docs/**/*.md`, `SPEC.md`, `PLAN.md`, `specs/**/*.md`
+3. **Extract spec IDs:** Grep for `\[[A-Z][A-Z0-9]*(-[A-Z0-9]+)+\]` in those files
+4. **Find code refs:** Grep for the literal spec ID in all files, excluding `docs/`, `node_modules/`, `.git/`, `*.md`
+5. **Find test refs:** Grep for the literal spec ID in test directories and test file patterns
+6. **Read and compare:** Read the spec section content and the implementing code, compare logic
+
+## Key principles
+
+- **Fail fast.** Stop on the first violation. One fix at a time.
+- **Be pedantic.** If the spec says it, the code must do it. No "close enough".
+- **Quote everything.** Always quote the spec text and the code in error messages so the developer sees exactly what's wrong.
+- **Be actionable.** Every error must tell the developer what file to change and what to do.
+- **Exclude docs from code search.** Markdown files are documentation, not implementation. Only search actual code files for spec references.
+- **No numbered IDs.** Spec IDs are hierarchical descriptive slugs (`[AUTH-TOKEN-VERIFY]`), NEVER sequential numbers (`[SPEC-001]`). The first word is the group — sections sharing a group must be adjacent in the TOC. If you encounter numbered or ungrouped IDs, flag them as a violation.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0385437..81ad77d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,3 +1,4 @@
+# agent-pmo:74cf183
name: CI
on:
@@ -162,124 +163,8 @@ jobs:
working-directory: src/Napper.Zed
run: cargo tarpaulin --out xml html --output-dir ../../coverage/rust/report --skip-clean
- - name: Extract TypeScript coverage percentage
- id: ts-coverage
- run: |
- COVERAGE=$(cd src/Napper.VsCode && npx c8 report --reporter text 2>/dev/null | grep 'All files' | awk '{print $4}' || echo "0")
- echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT"
-
- - name: Check TypeScript coverage threshold
- env:
- COVERAGE_THRESHOLD: ${{ vars.TS_COVERAGE_THRESHOLD }}
- run: |
- ACTUAL="${{ steps.ts-coverage.outputs.coverage }}"
- THRESHOLD="${COVERAGE_THRESHOLD}"
- echo "TypeScript coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
- if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then
- echo "No threshold set — skipping"
- exit 0
- fi
- if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then
- echo "::error::TypeScript coverage ${ACTUAL}% is below threshold ${THRESHOLD}%"
- exit 1
- fi
-
- - name: Extract Napper.Core coverage percentage
- id: napcore-coverage
- run: |
- COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/fsharp/report/Summary.txt || echo "0")
- echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT"
-
- - name: Check Napper.Core coverage threshold
- env:
- COVERAGE_THRESHOLD: ${{ vars.FSHARP_COVERAGE_THRESHOLD }}
- run: |
- ACTUAL="${{ steps.napcore-coverage.outputs.coverage }}"
- THRESHOLD="${COVERAGE_THRESHOLD}"
- echo "Napper.Core coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
- if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then
- echo "No threshold set — skipping"
- exit 0
- fi
- if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then
- echo "::error::Napper.Core coverage ${ACTUAL}% is below threshold ${THRESHOLD}%"
- exit 1
- fi
-
- - name: Extract DotHttp coverage percentage
- id: dothttp-coverage
- run: |
- COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/dothttp/report/Summary.txt || echo "0")
- echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT"
-
- - name: Check DotHttp coverage threshold
- env:
- COVERAGE_THRESHOLD: ${{ vars.DOTHTTP_COVERAGE_THRESHOLD }}
- run: |
- ACTUAL="${{ steps.dothttp-coverage.outputs.coverage }}"
- THRESHOLD="${COVERAGE_THRESHOLD}"
- echo "DotHttp coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
- if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then
- echo "No threshold set — skipping"
- exit 0
- fi
- if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then
- echo "::error::DotHttp coverage ${ACTUAL}% is below threshold ${THRESHOLD}%"
- exit 1
- fi
-
- - name: Extract Napper.Lsp coverage percentage
- id: lsp-coverage
- run: |
- if [ -f coverage/lsp/report/Summary.txt ]; then
- COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' coverage/lsp/report/Summary.txt || echo "0")
- else
- COVERAGE="0"
- fi
- echo "coverage=$COVERAGE" >> "$GITHUB_OUTPUT"
-
- - name: Check Napper.Lsp coverage threshold
- env:
- COVERAGE_THRESHOLD: ${{ vars.LSP_COVERAGE_THRESHOLD }}
- run: |
- ACTUAL="${{ steps.lsp-coverage.outputs.coverage }}"
- THRESHOLD="${COVERAGE_THRESHOLD}"
- echo "Napper.Lsp coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
- if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then
- echo "No threshold set — skipping"
- exit 0
- fi
- if [ "$ACTUAL" = "0" ] && grep -q 'Assemblies: 0' coverage/lsp/report/Summary.txt 2>/dev/null; then
- echo "LSP tests are integration tests (subprocess) — skipping coverage threshold"
- exit 0
- fi
- if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then
- echo "::error::Napper.Lsp coverage ${ACTUAL}% is below threshold ${THRESHOLD}%"
- exit 1
- fi
-
- - name: Extract Rust coverage percentage
- id: rust-coverage
- run: |
- COVERAGE=$(grep -oP 'line-rate="\K[0-9.]+' coverage/rust/report/cobertura.xml 2>/dev/null || echo "0")
- COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc -l | xargs printf "%.2f")
- echo "coverage=$COVERAGE_PCT" >> "$GITHUB_OUTPUT"
-
- - name: Check Rust coverage threshold
- env:
- COVERAGE_THRESHOLD: ${{ vars.RUST_COVERAGE_THRESHOLD }}
- run: |
- ACTUAL="${{ steps.rust-coverage.outputs.coverage }}"
- THRESHOLD="${COVERAGE_THRESHOLD}"
- echo "Rust coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
- if [ -z "$THRESHOLD" ] || [ "$THRESHOLD" = "0" ]; then
- echo "No threshold set — skipping"
- exit 0
- fi
- if (( $(echo "$ACTUAL < $THRESHOLD" | bc -l) )); then
- echo "::error::Rust coverage ${ACTUAL}% is below threshold ${THRESHOLD}%"
- exit 1
- fi
+ - name: Check coverage thresholds
+ run: make _coverage_check
- name: Upload TypeScript coverage
if: always()
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c07f42a..c26afea 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,10 @@
{
"basilisk.testExplorer.enabled": true,
- "basilisk.uv.enabled": true
-}
\ No newline at end of file
+ "basilisk.uv.enabled": true,
+ "workbench.colorCustomizations": {
+ "titleBar.activeBackground": "#1B4965",
+ "titleBar.activeForeground": "#FFFFFF",
+ "titleBar.inactiveBackground": "#163d52",
+ "titleBar.inactiveForeground": "#FFFFFFcc"
+ }
+}
diff --git a/Makefile b/Makefile
index aaecffe..6029331 100644
--- a/Makefile
+++ b/Makefile
@@ -1,32 +1,47 @@
+# agent-pmo:74cf183
# =============================================================================
# Standard Makefile — Napper
# All primary targets are language-agnostic. Language-specific helpers below.
# =============================================================================
-.PHONY: build test lint fmt fmt-check clean check ci coverage coverage-check \
+.PHONY: build test lint fmt clean ci setup \
build-all build-cli build-extension build-vsix build-zed \
clean-install-vsix dump-cli-help install-binaries package-vsix \
- test-fsharp test-rust test-vsix format
-
-SHELL := /usr/bin/env bash
-.SHELLFLAGS := -euo pipefail -c
-
-# --- Platform detection ---
-ARCH := $(shell uname -m)
-OS := $(shell uname -s)
+ test-fsharp test-rust test-vsix coverage fmt-check format
+
+# --- Cross-platform support ---
+ifeq ($(OS),Windows_NT)
+ SHELL := powershell.exe
+ .SHELLFLAGS := -NoProfile -Command
+ RM = Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
+ MKDIR = New-Item -ItemType Directory -Force
+ HOME ?= $(USERPROFILE)
+else
+ SHELL := /usr/bin/env bash
+ .SHELLFLAGS := -euo pipefail -c
+ RM = rm -rf
+ MKDIR = mkdir -p
+endif
-ifeq ($(OS),Darwin)
- ifeq ($(ARCH),arm64)
- NAP_RID ?= osx-arm64
- else ifeq ($(ARCH),x86_64)
- NAP_RID ?= osx-x64
+# --- Platform detection for .NET RID ---
+ifeq ($(OS),Windows_NT)
+ NAP_RID ?= win-x64
+else
+ ARCH := $(shell uname -m)
+ UNAME_S := $(shell uname -s)
+ ifeq ($(UNAME_S),Darwin)
+ ifeq ($(ARCH),arm64)
+ NAP_RID ?= osx-arm64
+ else ifeq ($(ARCH),x86_64)
+ NAP_RID ?= osx-x64
+ else
+ $(error Unsupported arch: $(ARCH))
+ endif
+ else ifeq ($(UNAME_S),Linux)
+ NAP_RID ?= linux-x64
else
- $(error Unsupported arch: $(ARCH))
+ $(error Unsupported OS: $(UNAME_S))
endif
-else ifeq ($(OS),Linux)
- NAP_RID ?= linux-x64
-else
- $(error Unsupported OS: $(OS))
endif
EXT_BIN := src/Napper.VsCode/bin
@@ -37,17 +52,14 @@ LSP_COVERAGE_DIR := coverage/lsp
TS_COVERAGE_DIR := coverage/typescript
RUST_COVERAGE_DIR := coverage/rust
-# Coverage threshold (override in CI via env var or per-repo)
-COVERAGE_THRESHOLD ?= 90
-
# =============================================================================
-# PRIMARY TARGETS (uniform interface — do not rename)
+# Standard Targets
# =============================================================================
## build: Compile/assemble all artifacts
build: build-all
-## test: Run full test suite with coverage
+## test: Run full test suite with coverage and threshold enforcement
test: test-fsharp test-rust test-vsix
@echo ""
@echo "========================================="
@@ -58,8 +70,9 @@ test: test-fsharp test-rust test-vsix
@echo " Rust: $(RUST_COVERAGE_DIR)/report/index.html"
@echo " TypeScript: $(TS_COVERAGE_DIR)/report/index.html"
@echo "========================================="
+ @$(MAKE) _coverage_check
-## lint: Run all linters (fails on any warning)
+## lint: Run all linters (read-only, no formatting)
lint:
@echo "==> F# build (warnings as errors)..."
dotnet build --nologo -warnaserror
@@ -79,75 +92,118 @@ fmt:
cargo fmt --manifest-path src/Napper.Zed/Cargo.toml
@echo "==> All projects formatted"
-## fmt-check: Check formatting without modifying (used in CI)
-fmt-check:
- @echo "==> Checking F# formatting (Fantomas)..."
- dotnet fantomas --check src/
- @echo "==> Checking TypeScript formatting (Prettier)..."
- cd src/Napper.VsCode && npx prettier --check "src/**/*.ts"
- @echo "==> Checking Rust formatting (cargo fmt)..."
- cargo fmt --manifest-path src/Napper.Zed/Cargo.toml -- --check
- @echo "==> All format checks passed"
-
## clean: Remove all build artifacts
clean:
@echo "==> Cleaning all build artifacts..."
- rm -rf out/
- rm -rf src/Napper.Core/bin/ src/Napper.Core/obj/
- rm -rf src/Napper.Cli/bin/ src/Napper.Cli/obj/
- rm -rf tests/Napper.Core.Tests/bin/ tests/Napper.Core.Tests/obj/
- rm -rf src/Napper.VsCode/bin/
- rm -rf src/Napper.VsCode/dist/
- rm -rf src/Napper.VsCode/out/
- rm -f src/Napper.VsCode/*.vsix
- rm -rf coverage/
+ $(RM) out/
+ $(RM) src/Napper.Core/bin/ src/Napper.Core/obj/
+ $(RM) src/Napper.Cli/bin/ src/Napper.Cli/obj/
+ $(RM) tests/Napper.Core.Tests/bin/ tests/Napper.Core.Tests/obj/
+ $(RM) src/Napper.VsCode/bin/
+ $(RM) src/Napper.VsCode/dist/
+ $(RM) src/Napper.VsCode/out/
+ $(RM) src/Napper.VsCode/*.vsix
+ $(RM) coverage/
@echo "==> Clean complete"
-## check: lint + test (pre-commit)
-check: lint test
-
## ci: lint + test + build (full CI simulation)
ci: lint test build
-## coverage: Generate and open coverage report
-coverage: test
- @echo "==> Opening coverage reports..."
-ifeq ($(OS),Darwin)
- @open "$(FSHARP_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
- @open "$(TS_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
-else
- @xdg-open "$(FSHARP_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
- @xdg-open "$(TS_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
-endif
+## setup: Install all dev tools and dependencies
+setup:
+ @echo "==> Installing .NET tools..."
+ dotnet tool restore
+ dotnet restore
+ @echo "==> Installing Node dependencies (VSCode extension)..."
+ cd src/Napper.VsCode && npm ci
+ @echo "==> Installing Node dependencies (website)..."
+ cd website && npm ci
+ @echo "==> Installing Rust toolchain components..."
+ rustup component add clippy rustfmt 2>/dev/null || true
+ @echo "==> Installing reportgenerator..."
+ dotnet tool install --global dotnet-reportgenerator-globaltool 2>/dev/null || true
+ @echo "==> Setup complete"
+
+# =============================================================================
+# Internal helpers (not in .PHONY — private)
+# =============================================================================
-## coverage-check: Assert thresholds (exits non-zero if below)
-coverage-check:
- @echo "==> Checking coverage thresholds..."
- @echo "--- F# Napper.Core ---"
- @if [ -f "$(FSHARP_COVERAGE_DIR)/report/Summary.txt" ]; then \
+_coverage_check:
+ @echo "==> Checking coverage thresholds (coverage-thresholds.json)..."
+ @THRESHOLD=$$(jq '.projects["src/Napper.Core.Tests"].threshold // .default_threshold' coverage-thresholds.json); \
+ echo "--- F# Napper.Core (threshold: $${THRESHOLD}%) ---"; \
+ if [ -f "$(FSHARP_COVERAGE_DIR)/report/Summary.txt" ]; then \
COV=$$(grep -oP 'Line coverage: \K[0-9.]+' "$(FSHARP_COVERAGE_DIR)/report/Summary.txt" 2>/dev/null || echo "0"); \
- echo " Line coverage: $${COV}% (threshold: $(COVERAGE_THRESHOLD)%)"; \
- if [ $$(echo "$${COV} < $(COVERAGE_THRESHOLD)" | bc -l) -eq 1 ]; then \
- echo " FAIL: $${COV}% < $(COVERAGE_THRESHOLD)%"; exit 1; \
+ echo " Line coverage: $${COV}%"; \
+ if [ $$(echo "$${COV} < $${THRESHOLD}" | bc -l) -eq 1 ]; then \
+ echo " FAIL: $${COV}% < $${THRESHOLD}%"; exit 1; \
else echo " OK"; fi; \
else echo " No coverage data found — run 'make test' first"; fi
- @echo "--- F# DotHttp ---"
- @if [ -f "$(DOTHTTP_COVERAGE_DIR)/report/Summary.txt" ]; then \
+ @THRESHOLD=$$(jq '.projects["src/DotHttp.Tests"].threshold // .default_threshold' coverage-thresholds.json); \
+ echo "--- F# DotHttp (threshold: $${THRESHOLD}%) ---"; \
+ if [ -f "$(DOTHTTP_COVERAGE_DIR)/report/Summary.txt" ]; then \
COV=$$(grep -oP 'Line coverage: \K[0-9.]+' "$(DOTHTTP_COVERAGE_DIR)/report/Summary.txt" 2>/dev/null || echo "0"); \
- echo " Line coverage: $${COV}% (threshold: $(COVERAGE_THRESHOLD)%)"; \
- if [ $$(echo "$${COV} < $(COVERAGE_THRESHOLD)" | bc -l) -eq 1 ]; then \
- echo " FAIL: $${COV}% < $(COVERAGE_THRESHOLD)%"; exit 1; \
+ echo " Line coverage: $${COV}%"; \
+ if [ $$(echo "$${COV} < $${THRESHOLD}" | bc -l) -eq 1 ]; then \
+ echo " FAIL: $${COV}% < $${THRESHOLD}%"; exit 1; \
else echo " OK"; fi; \
else echo " No coverage data found — run 'make test' first"; fi
- @echo "--- Rust ---"
- @if [ -f "$(RUST_COVERAGE_DIR)/report/cobertura.xml" ]; then \
+ @THRESHOLD=$$(jq '.projects["src/Napper.Lsp.Tests"].threshold // .default_threshold' coverage-thresholds.json); \
+ echo "--- F# Napper.Lsp (threshold: $${THRESHOLD}%) ---"; \
+ if [ -f "$(LSP_COVERAGE_DIR)/report/Summary.txt" ]; then \
+ COV=$$(grep -oP 'Line coverage: \K[0-9.]+' "$(LSP_COVERAGE_DIR)/report/Summary.txt" 2>/dev/null || echo "0"); \
+ echo " Line coverage: $${COV}%"; \
+ if [ $$(echo "$${COV} < $${THRESHOLD}" | bc -l) -eq 1 ]; then \
+ echo " FAIL: $${COV}% < $${THRESHOLD}%"; exit 1; \
+ else echo " OK"; fi; \
+ else echo " No coverage data found — run 'make test' first"; fi
+ @THRESHOLD=$$(jq '.projects["src/Napper.VsCode"].threshold // .default_threshold' coverage-thresholds.json); \
+ echo "--- TypeScript (threshold: $${THRESHOLD}%) ---"; \
+ if [ -f "$(TS_COVERAGE_DIR)/report/index.html" ]; then \
+ COV=$$(cd src/Napper.VsCode && npx c8 report --reporter text 2>/dev/null | grep 'All files' | awk '{print $$4}' | tr -d '%' || echo "0"); \
+ echo " Line coverage: $${COV}%"; \
+ if [ $$(echo "$${COV} < $${THRESHOLD}" | bc -l) -eq 1 ]; then \
+ echo " FAIL: $${COV}% < $${THRESHOLD}%"; exit 1; \
+ else echo " OK"; fi; \
+ else echo " No TypeScript coverage data found — run 'make test' first"; fi
+ @THRESHOLD=$$(jq '.projects["src/Napper.Zed"].threshold // .default_threshold' coverage-thresholds.json); \
+ echo "--- Rust (threshold: $${THRESHOLD}%) ---"; \
+ if [ -f "$(RUST_COVERAGE_DIR)/report/cobertura.xml" ]; then \
LINE_RATE=$$(sed -n 's/.*line-rate="\([0-9.]*\)".*/\1/p' "$(RUST_COVERAGE_DIR)/report/cobertura.xml" 2>/dev/null | head -1); \
COV=$$(echo "$${LINE_RATE:-0} * 100" | bc -l | xargs printf "%.1f"); \
- echo " Line coverage: $${COV}% (threshold: $(COVERAGE_THRESHOLD)%)"; \
- if [ $$(echo "$${COV} < $(COVERAGE_THRESHOLD)" | bc -l) -eq 1 ]; then \
- echo " FAIL: $${COV}% < $(COVERAGE_THRESHOLD)%"; exit 1; \
+ echo " Line coverage: $${COV}%"; \
+ if [ $$(echo "$${COV} < $${THRESHOLD}" | bc -l) -eq 1 ]; then \
+ echo " FAIL: $${COV}% < $${THRESHOLD}%"; exit 1; \
else echo " OK"; fi; \
- else echo " No coverage data found — run 'make test' first"; fi
+ else echo " No Rust coverage data found — run 'make test' first"; fi
+ @echo "==> Coverage thresholds OK"
+
+# =============================================================================
+# Repo-Specific Targets
+# =============================================================================
+
+## coverage: Generate and open coverage report (calls test first)
+coverage: test
+ @echo "==> Opening coverage reports..."
+ifeq ($(OS),Windows_NT)
+ @start "$(FSHARP_COVERAGE_DIR)/report/index.html" 2>$$null || true
+else ifeq ($(shell uname -s),Darwin)
+ @open "$(FSHARP_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
+ @open "$(TS_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
+else
+ @xdg-open "$(FSHARP_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
+ @xdg-open "$(TS_COVERAGE_DIR)/report/index.html" 2>/dev/null || true
+endif
+
+## fmt-check: Check formatting without modifying (used in CI)
+fmt-check:
+ @echo "==> Checking F# formatting (Fantomas)..."
+ dotnet fantomas --check src/
+ @echo "==> Checking TypeScript formatting (Prettier)..."
+ cd src/Napper.VsCode && npx prettier --check "src/**/*.ts"
+ @echo "==> Checking Rust formatting (cargo fmt)..."
+ cargo fmt --manifest-path src/Napper.Zed/Cargo.toml -- --check
+ @echo "==> All format checks passed"
# Keep `format` as an alias for backward compatibility
format: fmt
@@ -166,10 +222,10 @@ build-cli:
-o "out/$(NAP_RID)" \
--nologo
@echo "==> CLI built → out/$(NAP_RID)/"
- @mkdir -p "$(EXT_BIN)"
+ @$(MKDIR) "$(EXT_BIN)"
cp "out/$(NAP_RID)/napper" "$(EXT_BIN)/napper"
@echo "==> Copied CLI → $(EXT_BIN)/"
- @mkdir -p "$(HOME)/.local/bin"
+ @$(MKDIR) "$(HOME)/.local/bin"
cp "out/$(NAP_RID)/napper" "$(HOME)/.local/bin/napper"
chmod +x "$(HOME)/.local/bin/napper"
@echo "==> Installed CLI → ~/.local/bin/napper"
@@ -266,9 +322,9 @@ test-fsharp:
@echo "========================================="
@echo " Napper.Core Tests + Coverage"
@echo "========================================="
- mkdir -p "$(LOG_DIR)"
- rm -rf "$(FSHARP_COVERAGE_DIR)"
- mkdir -p "$(FSHARP_COVERAGE_DIR)"
+ $(MKDIR) "$(LOG_DIR)"
+ $(RM) "$(FSHARP_COVERAGE_DIR)"
+ $(MKDIR) "$(FSHARP_COVERAGE_DIR)"
@echo "==> Running Napper.Core tests with coverage..."
dotnet test src/Napper.Core.Tests --nologo \
--settings src/Napper.Core.Tests/coverage.runsettings \
@@ -287,8 +343,8 @@ test-fsharp:
@echo "========================================="
@echo " DotHttp Tests + Coverage"
@echo "========================================="
- rm -rf "$(DOTHTTP_COVERAGE_DIR)"
- mkdir -p "$(DOTHTTP_COVERAGE_DIR)"
+ $(RM) "$(DOTHTTP_COVERAGE_DIR)"
+ $(MKDIR) "$(DOTHTTP_COVERAGE_DIR)"
@echo "==> Running DotHttp tests with coverage..."
dotnet test src/DotHttp.Tests --nologo \
--settings src/DotHttp.Tests/coverage.runsettings \
@@ -307,8 +363,8 @@ test-fsharp:
@echo "========================================="
@echo " Napper.Lsp Tests + Coverage"
@echo "========================================="
- rm -rf "$(LSP_COVERAGE_DIR)"
- mkdir -p "$(LSP_COVERAGE_DIR)"
+ $(RM) "$(LSP_COVERAGE_DIR)"
+ $(MKDIR) "$(LSP_COVERAGE_DIR)"
@echo "==> Running Napper.Lsp tests with coverage..."
dotnet test src/Napper.Lsp.Tests --nologo \
--settings src/Napper.Lsp.Tests/coverage.runsettings \
@@ -328,9 +384,9 @@ test-rust:
@echo "========================================="
@echo " Rust Tests + Coverage (Napper.Zed)"
@echo "========================================="
- mkdir -p "$(LOG_DIR)"
- rm -rf "$(RUST_COVERAGE_DIR)"
- mkdir -p "$(RUST_COVERAGE_DIR)"
+ $(MKDIR) "$(LOG_DIR)"
+ $(RM) "$(RUST_COVERAGE_DIR)"
+ $(MKDIR) "$(RUST_COVERAGE_DIR)"
@echo "==> Running Rust checks..."
cargo fmt --manifest-path src/Napper.Zed/Cargo.toml -- --check 2>&1 | tee "$(LOG_DIR)/test-rust-fmt.log"
cargo clippy --manifest-path src/Napper.Zed/Cargo.toml 2>&1 | tee "$(LOG_DIR)/test-rust-clippy.log"
@@ -346,9 +402,9 @@ test-vsix: build-cli build-extension
@echo "========================================="
@echo " TypeScript Tests + Coverage"
@echo "========================================="
- mkdir -p "$(LOG_DIR)"
- rm -rf "$(TS_COVERAGE_DIR)"
- mkdir -p "$(TS_COVERAGE_DIR)"
+ $(MKDIR) "$(LOG_DIR)"
+ $(RM) "$(TS_COVERAGE_DIR)"
+ $(MKDIR) "$(TS_COVERAGE_DIR)"
cd src/Napper.VsCode && npm run compile && npm run compile:tests
@echo "==> Running unit tests..."
cd src/Napper.VsCode && NODE_V8_COVERAGE="../../$(TS_COVERAGE_DIR)/tmp" \
@@ -375,7 +431,7 @@ dump-cli-help:
fi; \
echo "==> Capturing CLI help output from $$CLI_PATH..."; \
HELP_OUTPUT=$$($$CLI_PATH help 2>&1); \
- mkdir -p docs; \
+ $(MKDIR) docs; \
{ \
echo '# Nap CLI Reference'; \
echo ''; \
@@ -462,17 +518,18 @@ dump-cli-help:
# HELP
# ============================================================
help:
- @echo "Available targets:"
+ @echo "Standard targets:"
@echo " build - Compile/assemble all artifacts"
- @echo " test - Run full test suite with coverage"
- @echo " lint - Run all linters (errors mode)"
+ @echo " test - Run full test suite with coverage + threshold enforcement"
+ @echo " lint - Run all linters (read-only, no formatting)"
@echo " fmt - Format all code in-place"
- @echo " fmt-check - Check formatting (no modification)"
@echo " clean - Remove build artifacts"
- @echo " check - lint + test (pre-commit)"
- @echo " ci - lint + test + build (full CI)"
+ @echo " ci - lint + test + build (full CI simulation)"
+ @echo " setup - Install dev tools and dependencies"
+ @echo ""
+ @echo "Repo-specific targets:"
@echo " coverage - Generate and open coverage report"
- @echo " coverage-check - Assert coverage thresholds"
+ @echo " fmt-check - Check formatting (no modification)"
@echo " build-cli - Build CLI binary only"
@echo " build-vsix - Build CLI + extension + package VSIX"
@echo " build-zed - Build Zed extension (WASM)"
diff --git a/coverage-thresholds.json b/coverage-thresholds.json
new file mode 100644
index 0000000..50aa470
--- /dev/null
+++ b/coverage-thresholds.json
@@ -0,0 +1,25 @@
+{
+ "_agent_pmo": "74cf183",
+ "_doc": "Single source of truth for code coverage thresholds. See REPO-STANDARDS-SPEC.md §3.3 [COVERAGE-THRESHOLDS-JSON]. NO GitHub repo variables. NO env vars. This file is read by the internal `_coverage_check` recipe inside `make test`. `make test` exits non-zero if measured coverage < threshold. Thresholds are monotonically increasing — only ratchet UP, never down.",
+ "default_threshold": 80,
+ "projects": {
+ "src/Napper.Core.Tests": {
+ "threshold": 80,
+ "include": "[Napper.Core]*"
+ },
+ "src/DotHttp.Tests": {
+ "threshold": 80,
+ "include": "[DotHttp]*"
+ },
+ "src/Napper.Lsp.Tests": {
+ "threshold": 80,
+ "include": "[Napper.Lsp]*"
+ },
+ "src/Napper.VsCode": {
+ "threshold": 80
+ },
+ "src/Napper.Zed": {
+ "threshold": 80
+ }
+ }
+}
From f0c3759b851d2c2f4dd8d76dcecc75ccf105a385 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 27 Apr 2026 15:12:53 +1000
Subject: [PATCH 13/48] Skills
---
.claude/skills/ci-prep/SKILL.md | 2 +-
.claude/skills/code-dedup/SKILL.md | 2 +-
.claude/skills/fix-bug/SKILL.md | 2 +
.claude/skills/submit-pr/SKILL.md | 2 +-
.claude/skills/upgrade-packages/SKILL.md | 144 ++++++++++++++++++
.claude/skills/website-audit/SKILL.md | 181 +++++++++++++++++++++++
.github/pull_request_template.md | 4 +-
Claude.md | 2 +-
8 files changed, 333 insertions(+), 6 deletions(-)
create mode 100644 .claude/skills/upgrade-packages/SKILL.md
create mode 100644 .claude/skills/website-audit/SKILL.md
diff --git a/.claude/skills/ci-prep/SKILL.md b/.claude/skills/ci-prep/SKILL.md
index ecd8eeb..81716e0 100644
--- a/.claude/skills/ci-prep/SKILL.md
+++ b/.claude/skills/ci-prep/SKILL.md
@@ -4,7 +4,7 @@ description: Prepares the current branch for CI by running the exact same steps
argument-hint: "[--failing] [optional job name to focus on]"
---
-
+
# CI Prep
diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md
index 0ce6c1b..51d179b 100644
--- a/.claude/skills/code-dedup/SKILL.md
+++ b/.claude/skills/code-dedup/SKILL.md
@@ -3,7 +3,7 @@ name: code-dedup
description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code.
---
-
+
# Code Dedup
diff --git a/.claude/skills/fix-bug/SKILL.md b/.claude/skills/fix-bug/SKILL.md
index 0bb15ce..1111d3b 100644
--- a/.claude/skills/fix-bug/SKILL.md
+++ b/.claude/skills/fix-bug/SKILL.md
@@ -5,6 +5,8 @@ argument-hint: "[bug description]"
allowed-tools: Read, Grep, Glob, Edit, Write, Bash
---
+
+
# Bug Fix Skill — Test-First Workflow
You MUST follow this exact workflow. Do NOT skip steps. Do NOT fix the bug before writing a failing test.
diff --git a/.claude/skills/submit-pr/SKILL.md b/.claude/skills/submit-pr/SKILL.md
index 2b733f4..24b0c86 100644
--- a/.claude/skills/submit-pr/SKILL.md
+++ b/.claude/skills/submit-pr/SKILL.md
@@ -4,7 +4,7 @@ description: Creates a pull request with a well-structured description after ver
disable-model-invocation: true
---
-
+
# Submit PR
diff --git a/.claude/skills/upgrade-packages/SKILL.md b/.claude/skills/upgrade-packages/SKILL.md
new file mode 100644
index 0000000..a3a2683
--- /dev/null
+++ b/.claude/skills/upgrade-packages/SKILL.md
@@ -0,0 +1,144 @@
+---
+name: upgrade-packages
+description: Upgrade all dependencies/packages to their latest versions for the detected language(s). Use when the user says "upgrade packages", "update dependencies", "bump versions", "update packages", or "upgrade deps".
+argument-hint: "[--check-only] [--major] [package-name]"
+---
+
+
+
+# Upgrade Packages
+
+Upgrade all project dependencies to their latest compatible (or latest major, if `--major`) versions.
+
+This repo uses F# (.NET/NuGet), TypeScript (npm), and Rust (cargo).
+
+## Arguments
+
+- `--check-only` — List outdated packages without upgrading. Stop after Step 2.
+- `--major` — Include major version bumps (breaking changes). Without this flag, stay within semver-compatible ranges.
+- Any other argument is treated as a specific package name to upgrade (instead of all packages).
+
+## Step 1 — Detect language and package manager
+
+Inspect the repo root and subdirectories for manifest files:
+
+| Manifest file | Language | Package manager |
+|---|---|---|
+| `Cargo.toml` | Rust | cargo |
+| `package.json` | Node.js / TypeScript | npm |
+| `*.csproj` / `*.fsproj` / `*.sln` | F# | NuGet (dotnet) |
+| `Directory.Build.props` | F# | NuGet (dotnet) |
+
+All three are present in this repo. Process each in order.
+
+**If you cannot detect any manifest file, stop and tell the user.**
+
+## Step 2 — List outdated packages
+
+Run the appropriate command to list what's outdated BEFORE upgrading anything. Show the user what will change.
+
+### F# / .NET (NuGet)
+```bash
+dotnet list package --outdated
+```
+For transitive dependencies too: `dotnet list package --outdated --include-transitive`
+
+**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-list-package
+
+### Node.js (npm)
+```bash
+npm outdated
+```
+**Read the docs:** https://docs.npmjs.com/cli/v10/commands/npm-update
+
+### Rust (cargo)
+```bash
+cargo outdated # install: cargo install cargo-outdated
+cargo update --dry-run
+```
+**Read the docs:** https://doc.rust-lang.org/cargo/commands/cargo-update.html
+
+If `--check-only` was passed, **stop here** and report the outdated list.
+
+## Step 3 — Read the official upgrade docs
+
+**Before running any upgrade command, you MUST fetch and read the official documentation URL listed above for the detected package manager.** Use WebFetch to retrieve the page. This ensures you use the correct flags and understand the behavior. Do not guess at flags or options from memory.
+
+## Step 4 — Upgrade packages
+
+Run the upgrade. If a specific package name was given as an argument, upgrade only that package.
+
+### F# / .NET (NuGet)
+There is NO single `dotnet upgrade-all` command. You must upgrade each package individually:
+```bash
+# For each outdated package from Step 2:
+dotnet add package # upgrades to latest
+# Or with specific version:
+dotnet add package --version
+```
+For `Directory.Build.props`, edit the version numbers directly in the XML.
+
+**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package
+
+Alternatively, use the dotnet-outdated global tool:
+```bash
+dotnet tool install --global dotnet-outdated-tool
+dotnet outdated --upgrade
+```
+**Read the docs:** https://github.com/dotnet-outdated/dotnet-outdated
+
+### Node.js (npm)
+```bash
+npm update # semver-compatible (within package.json ranges)
+# --major flag:
+npx npm-check-updates -u && npm install # bump package.json to latest majors
+```
+
+### Rust (cargo)
+```bash
+cargo update # semver-compatible updates
+# --major flag:
+cargo update --breaking # major version bumps (cargo 1.84+)
+```
+For workspace members, run from workspace root.
+
+## Step 5 — Verify the upgrade
+
+After upgrading, run the project's build and test suite to confirm nothing broke:
+
+```bash
+make ci
+```
+
+If tests fail:
+1. Read the failure output carefully
+2. Check the changelog / migration guide for the upgraded packages (fetch the release notes URL if available)
+3. Fix breaking changes in the code
+4. Re-run tests
+5. If stuck after 3 attempts on the same failure, report it to the user with the error details and the package that caused it
+
+## Step 6 — Report
+
+Provide a summary:
+
+- Packages upgraded (old version -> new version)
+- Packages skipped (and why, e.g., major version bump without `--major` flag)
+- Build/test result after upgrade
+- Any breaking changes that were fixed
+- Any packages that could not be upgraded (with error details)
+
+## Rules
+
+- **Always list outdated packages first** before upgrading anything
+- **Always read the official docs** for the package manager before running upgrade commands
+- **Always run tests after upgrading** to catch breakage immediately
+- **Never remove packages** unless they were explicitly deprecated and replaced
+- **Never downgrade packages** unless rolling back a broken upgrade
+- **Never modify lockfiles manually** (package-lock.json, Cargo.lock, etc.) — let the package manager regenerate them
+- **Commit nothing** — leave changes in the working tree for the user to review
+
+## Success criteria
+
+- All outdated packages upgraded to latest compatible (or latest major if `--major`)
+- `make ci` passes
+- User has a clear summary of what changed
diff --git a/.claude/skills/website-audit/SKILL.md b/.claude/skills/website-audit/SKILL.md
new file mode 100644
index 0000000..02a6af8
--- /dev/null
+++ b/.claude/skills/website-audit/SKILL.md
@@ -0,0 +1,181 @@
+---
+name: website-audit
+description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance".
+---
+
+
+
+# Website Audit
+
+Performs a comprehensive website audit and fixes issues affecting search visibility and AI discoverability.
+
+Copy this checklist and track your progress:
+
+```
+Audit Progress:
+- [ ] Step 1: Read guidelines
+- [ ] Step 2: Audit AI search readiness
+- [ ] Step 3: Audit SEO and keywords
+- [ ] Step 4: Audit crawling and indexing
+- [ ] Step 5: Audit broken links and canonicalization
+- [ ] Step 6: Audit mobile usability
+- [ ] Step 7: Audit structured data
+- [ ] Step 8: Audit social media cards
+- [ ] Step 9: Audit For Unsubstantiated Claims
+- [ ] Step 10: Audit Design Compliance
+- [ ] Step 11: Test with Playwright
+- [ ] Step 12: Report findings
+```
+
+- Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. - Don't just check the static content before the website is generated.
+- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. _site)
+- Never manually edit the generated website content directly
+- ENSURE THE FOOTER HAS A copyright link to nimblesite.co
+
+## Step 1 — Read guidelines
+
+Fetch and read each of these before auditing. These are the authoritative references for every step that follows.
+
+- [Google's guidance on using generative AI content](https://developers.google.com/search/docs/fundamentals/using-gen-ai-content)
+- [Top ways to ensure content performs well in Google's AI experiences](https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search)
+- [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide)
+
+If the repo has a business plan doc, take it into account
+
+Identify the website source files in the repo. Determine the framework (static site generator, Next.js, Hugo, etc.) so you know where to find templates, metadata, and content.
+
+## Step 2 — Audit AI search readiness
+
+Apply the guidance from the AI search article. Check:
+
+1. **Content quality** — Is content original, expert-level, and comprehensive? Flag thin or duplicated pages.
+2. **Clear structure** — Do pages use descriptive headings, lists, and concise answers to likely questions?
+3. **Entity clarity** — Are key terms, products, and concepts defined clearly so AI can extract them?
+4. **Freshness signals** — Are dates, update timestamps, and authorship present?
+
+Fix issues directly in the source files. For each fix, note what changed and why.
+
+## Step 3 — Audit SEO and keywords
+
+1. Search [Google Trends](https://trends.google.com/home) for trending keywords related to the website's content.
+2. Review each page's ``, ``, and `
` tags.
+3. Check for keyword opportunities — can trending terms be naturally inserted into headings, descriptions, or body content?
+4. Verify each page has a unique, descriptive title (50-60 chars) and meta description (150-160 chars).
+5. Check image `alt` attributes describe the image content and include relevant keywords where natural.
+
+Apply the [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) principles. Fix issues directly.
+
+## Step 4 — Audit crawling and indexing
+
+Reference: [Overview of crawling and indexing topics](https://developers.google.com/search/docs/crawling-indexing)
+
+1. **robots.txt** — Locate and review it. Verify it doesn't block important pages. Reference: [robots.txt spec](https://developers.google.com/search/docs/crawling-indexing/robots-txt)
+2. **Sitemap** — Locate the sitemap (or sitemap index). Verify all important pages are listed and no dead URLs are included. Reference: [Sitemap guidelines](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps)
+3. **Meta robots tags** — Check for unintended `noindex` or `nofollow` directives on pages that should be indexed.
+
+Note: robots.txt and sitemaps are often auto-generated. If so, check the generator config rather than the output file.
+
+## Step 5 — Audit broken links and canonicalization
+
+Reference: [What is canonicalization](https://developers.google.com/search/docs/crawling-indexing/canonicalization)
+
+1. Check all internal links resolve to valid pages (no 404s).
+2. Verify `` tags are present and point to the correct URL.
+3. Check for duplicate content accessible via multiple URLs (with/without trailing slash, www vs non-www).
+4. Verify redirects use 301 (permanent) not 302 (temporary) where appropriate.
+
+## Step 6 — Audit mobile usability
+
+Reference: [Mobile-first indexing best practices](https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing)
+
+1. Verify the `` tag is present and correctly configured.
+2. Check that content is identical between mobile and desktop (mobile-first indexing requires this).
+3. Verify touch targets are adequately sized (min 48x48px).
+4. Check font sizes are readable without zooming (min 16px body text).
+
+## Step 7 — Audit structured data
+
+Reference: [Structured data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies)
+
+1. Check for existing JSON-LD `