From 57d2d702abb59f9b8a289e1e3074fe1583133287 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Wed, 24 Jun 2026 11:24:00 +0200 Subject: [PATCH 01/11] change structure for github install --- .agents/plugins/marketplace.json | 23 ++ .claude-mcp.json | 25 ++ .claude-plugin/marketplace.json | 30 ++ .claude-plugin/plugin.json | 31 ++ .codex-plugin/plugin.json | 27 ++ .gitignore | 1 + .mcp.json | 28 ++ README.md | 77 ++-- docs/antigravity-research.md | 2 + docs/pi-mcp-research.md | 4 +- docs/plugin-marketplace-research.md | 19 +- docs/qa-guide.md | 220 +++++------- mcp_config.json | 28 ++ package.json | 6 +- packages/core/README.md | 16 +- packages/core/package.json | 11 +- packages/core/scripts/setup.mjs | 11 +- .../core/scripts/sync-shared-skill-assets.mjs | 31 +- packages/core/src/auth/auth-manager.ts | 7 +- packages/core/src/cli.ts | 27 +- packages/core/src/index.ts | 2 +- .../integration/auth/auth-manager.test.ts | 85 ++++- .../core/test/integration/cli-help.test.ts | 6 +- .../core/test/integration/installer.test.ts | 2 +- .../test/integration/plugin-assets.test.ts | 84 +---- .../core/test/unit/skills/fetch-asset.test.ts | 2 +- packages/pi-plugin/README.md | 2 +- packages/pi-plugin/index.js | 2 +- packages/pi-plugin/package.json | 15 +- packages/pi-plugin/test/manifest.test.ts | 17 +- plugin.json | 4 + plugins/templates/antigravity/README.md | 15 - plugins/templates/antigravity/package.json | 120 ------- .../templates/antigravity/scripts/install.js | 99 ------ plugins/templates/claude/README.md | 9 - plugins/templates/claude/package.json | 11 - plugins/templates/codex/README.md | 9 - plugins/templates/codex/package.json | 12 - pnpm-lock.yaml | 2 +- scripts/build-plugin-artifacts.mjs | 334 ------------------ scripts/materialize-github-marketplace.mjs | 183 ++++++++++ scripts/mcp-wrapper.js | 133 +++++++ scripts/plugin-generators.mjs | 77 +--- scripts/sync-plugin-assets.mjs | 12 +- scripts/test-marketplace-install.js | 84 ++--- .../_shared => skill-assets}/fetch-asset.cjs | 0 .../_shared => skill-assets}/save-report.cjs | 0 .../skills/_shared => skill-assets}/wait.cjs | 0 .../ns-advanced-memory-leak-hunter/SKILL.md | 0 .../fetch-asset.cjs | 0 .../save-report.cjs | 0 .../ns-advanced-memory-leak-hunter/wait.cjs | 0 .../ns-analyze-asset/SKILL.md | 0 .../ns-analyze-asset/save-report.cjs | 0 .../ns-analyze-asset/wait.cjs | 0 .../skills => skills}/ns-analyze-cpu/SKILL.md | 0 .../ns-analyze-cpu/fetch-asset.cjs | 0 .../ns-analyze-cpu/save-report.cjs | 0 .../skills => skills}/ns-analyze-cpu/wait.cjs | 0 .../ns-analyze-cpu/workspace-delta.cjs | 0 .../ns-analyze-event/SKILL.md | 0 .../ns-analyze-event/save-report.cjs | 0 .../ns-analyze-memory/SKILL.md | 0 .../ns-analyze-memory/fetch-asset.cjs | 0 .../ns-analyze-memory/save-report.cjs | 0 .../ns-analyze-memory/wait.cjs | 0 .../ns-analyze-tracing/SKILL.md | 0 .../ns-analyze-vulnerabilities/SKILL.md | 0 .../ns-audit-dependencies/SKILL.md | 0 .../collect-dependencies.cjs | 0 .../ns-benchmark-run/SKILL.md | 0 .../ns-benchmark-run/save-report.cjs | 0 .../ns-benchmark-run/wait.cjs | 0 .../ns-benchmark-validate/SKILL.md | 0 .../ns-benchmark-validate/save-report.cjs | 0 .../ns-benchmark-validate/wait.cjs | 0 .../ns-generate-asset/SKILL.md | 0 .../ns-generate-sbom/SKILL.md | 0 .../ns-node-upgrade/SKILL.md | 0 .../ns-node-upgrade/fetch-node-releases.cjs | 0 .../ns-replace-package/SKILL.md | 0 .../ns-upgrade-package/SKILL.md | 0 82 files changed, 887 insertions(+), 1058 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-mcp.json create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .codex-plugin/plugin.json create mode 100644 .mcp.json create mode 100644 mcp_config.json create mode 100644 plugin.json delete mode 100644 plugins/templates/antigravity/README.md delete mode 100644 plugins/templates/antigravity/package.json delete mode 100644 plugins/templates/antigravity/scripts/install.js delete mode 100644 plugins/templates/claude/README.md delete mode 100644 plugins/templates/claude/package.json delete mode 100644 plugins/templates/codex/README.md delete mode 100644 plugins/templates/codex/package.json delete mode 100644 scripts/build-plugin-artifacts.mjs create mode 100644 scripts/materialize-github-marketplace.mjs create mode 100644 scripts/mcp-wrapper.js rename {packages/core/skills/_shared => skill-assets}/fetch-asset.cjs (100%) rename {packages/core/skills/_shared => skill-assets}/save-report.cjs (100%) rename {packages/core/skills/_shared => skill-assets}/wait.cjs (100%) rename {packages/core/skills => skills}/ns-advanced-memory-leak-hunter/SKILL.md (100%) rename {packages/core/skills => skills}/ns-advanced-memory-leak-hunter/fetch-asset.cjs (100%) rename {packages/core/skills => skills}/ns-advanced-memory-leak-hunter/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-advanced-memory-leak-hunter/wait.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-asset/SKILL.md (100%) rename {packages/core/skills => skills}/ns-analyze-asset/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-asset/wait.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-cpu/SKILL.md (100%) rename {packages/core/skills => skills}/ns-analyze-cpu/fetch-asset.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-cpu/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-cpu/wait.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-cpu/workspace-delta.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-event/SKILL.md (100%) rename {packages/core/skills => skills}/ns-analyze-event/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-memory/SKILL.md (100%) rename {packages/core/skills => skills}/ns-analyze-memory/fetch-asset.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-memory/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-memory/wait.cjs (100%) rename {packages/core/skills => skills}/ns-analyze-tracing/SKILL.md (100%) rename {packages/core/skills => skills}/ns-analyze-vulnerabilities/SKILL.md (100%) rename {packages/core/skills => skills}/ns-audit-dependencies/SKILL.md (100%) rename {packages/core/skills => skills}/ns-audit-dependencies/collect-dependencies.cjs (100%) rename {packages/core/skills => skills}/ns-benchmark-run/SKILL.md (100%) rename {packages/core/skills => skills}/ns-benchmark-run/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-benchmark-run/wait.cjs (100%) rename {packages/core/skills => skills}/ns-benchmark-validate/SKILL.md (100%) rename {packages/core/skills => skills}/ns-benchmark-validate/save-report.cjs (100%) rename {packages/core/skills => skills}/ns-benchmark-validate/wait.cjs (100%) rename {packages/core/skills => skills}/ns-generate-asset/SKILL.md (100%) rename {packages/core/skills => skills}/ns-generate-sbom/SKILL.md (100%) rename {packages/core/skills => skills}/ns-node-upgrade/SKILL.md (100%) rename {packages/core/skills => skills}/ns-node-upgrade/fetch-node-releases.cjs (100%) rename {packages/core/skills => skills}/ns-replace-package/SKILL.md (100%) rename {packages/core/skills => skills}/ns-upgrade-package/SKILL.md (100%) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..081743e --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,23 @@ +{ + "name": "nodesource", + "interface": { + "displayName": "NodeSource" + }, + "plugins": [ + { + "name": "nsolid-plugin", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE", + "products": [ + "CODEX" + ] + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-mcp.json b/.claude-mcp.json new file mode 100644 index 0000000..7a7c3a7 --- /dev/null +++ b/.claude-mcp.json @@ -0,0 +1,25 @@ +{ + "mcpServers": { + "nsolid-console": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", + "nsolid-console" + ] + }, + "ns-benchmark": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", + "ns-benchmark" + ] + }, + "ncm": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js", + "ncm" + ] + } + } +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..30ffb7d --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,30 @@ +{ + "name": "nodesource", + "owner": { + "name": "NodeSource" + }, + "description": "NodeSource agent plugins", + "plugins": [ + { + "name": "nsolid-plugin", + "source": "./", + "displayName": "N|Solid Plugin", + "version": "1.0.0", + "description": "N|Solid performance & security skills + MCP servers", + "author": { + "name": "NodeSource" + }, + "homepage": "https://nodesource.com", + "repository": "https://github.com/NodeSource/nsolid-plugin", + "license": "MIT", + "category": "developer-tools", + "tags": [ + "nodesource", + "nsolid", + "nodejs", + "performance", + "security" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..9949cb1 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "nsolid-plugin", + "displayName": "N|Solid Plugin", + "version": "1.0.0", + "description": "N|Solid performance & security skills + MCP servers", + "author": { + "name": "NodeSource" + }, + "homepage": "https://nodesource.com", + "repository": "https://github.com/NodeSource/nsolid-plugin", + "license": "MIT", + "skills": [ + "./skills/ns-advanced-memory-leak-hunter", + "./skills/ns-analyze-asset", + "./skills/ns-analyze-cpu", + "./skills/ns-analyze-event", + "./skills/ns-analyze-memory", + "./skills/ns-analyze-tracing", + "./skills/ns-analyze-vulnerabilities", + "./skills/ns-audit-dependencies", + "./skills/ns-benchmark-run", + "./skills/ns-benchmark-validate", + "./skills/ns-generate-asset", + "./skills/ns-generate-sbom", + "./skills/ns-node-upgrade", + "./skills/ns-replace-package", + "./skills/ns-upgrade-package" + ], + "mcpServers": "./.claude-mcp.json" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..b1cb866 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "nsolid-plugin", + "version": "1.0.0", + "description": "N|Solid Plugin — AI skills and MCP servers for Codex", + "author": { + "name": "NodeSource", + "url": "https://nodesource.com" + }, + "homepage": "https://nodesource.com", + "repository": "https://github.com/NodeSource/nsolid-plugin", + "license": "MIT", + "keywords": [ + "nodesource", + "nsolid", + "nodejs", + "performance", + "security" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "N|Solid Plugin", + "shortDescription": "N|Solid performance & security", + "category": "Productivity", + "developerName": "NodeSource" + } +} diff --git a/.gitignore b/.gitignore index 6ccd621..9b5b922 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ test-results.log # Generated plugin artifacts/materialized package skills +packages/core/skills/ packages/pi-plugin/skills/ # OpenCode diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..246f7e3 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,28 @@ +{ + "mcpServers": { + "nsolid-console": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "nsolid-console" + ] + }, + "ns-benchmark": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "ns-benchmark" + ] + }, + "ncm": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "ncm" + ] + } + } +} diff --git a/README.md b/README.md index 5a5f9cd..dc26445 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Cross-harness plugin distribution for NodeSource AI skills and MCP servers. The | Harness | Plugin model | Trigger | |---|---|---| -| **Claude Code** | Generated marketplace/local artifact + `.claude-plugin/plugin.json` | Native plugin install, then explicit setup | -| **Codex CLI** | Generated marketplace/local artifact + `.codex-plugin/plugin.json` | Native plugin install, then explicit setup | +| **Claude Code** | Root GitHub marketplace/plugin + `.claude-plugin/plugin.json` | Native plugin install, then explicit setup | +| **Codex CLI** | Root GitHub marketplace/plugin + `.codex-plugin/plugin.json` | Native plugin install, then explicit setup | | **OpenCode** | CLI-only (user-level skills + MCP config) | `nsolid-plugin setup --harness opencode` (auth + direct install) | -| **Antigravity CLI** | Generated local artifact + `plugin.json` | `agy plugin install `, then `nsolid-plugin setup --harness antigravity` | -| **Pi Agent** | npm package + `pi.skills` | `pi install npm:@nodesource/pi-plugin`, `nsolid-plugin setup --harness pi`, then `pi install npm:pi-mcp-adapter` | +| **Antigravity CLI** | Root GitHub plugin + `plugin.json` | `agy plugin install `, then explicit setup | +| **Pi Agent** | npm package + `pi.skills` | `pi install npm:nsolid-pi-plugin`, `nsolid-plugin setup --harness pi`, then `pi install npm:pi-mcp-adapter` | No harness relies on npm `postinstall` hooks. See `openspec/changes/cross-harness-plugin-installer/design.md` and `openspec/changes/cross-harness-plugin-installer/specs/installation-and-auth.md` for the full design rationale. @@ -19,28 +19,30 @@ No harness relies on npm `postinstall` hooks. See `openspec/changes/cross-harnes ```text nsolid-plugin/ ├── packages/ -│ ├── core/ # Shared CLI/setup/fallback logic + canonical skills +│ ├── core/ # Shared CLI/setup/fallback logic + npm CLI package │ └── pi-plugin/ # Pi Agent package -├── plugins/templates/ # Source templates for generated native artifacts -├── dist/plugins/ # Generated plugin directories (not committed) -├── dist/artifacts/ # Generated .tgz artifacts (not committed) +├── .claude-plugin/ # Claude marketplace + plugin manifest +├── .agents/plugins/ # Codex marketplace manifest +├── .codex-plugin/ # Codex plugin manifest +├── skills/ # Canonical N|Solid skills and root plugin payload +├── skill-assets/ # Shared helper sources copied into skills/package artifacts ├── bundle.json # Canonical skill + MCP server descriptor +├── plugin.json # Antigravity root plugin manifest └── pnpm-workspace.yaml ``` ## Skill distribution model -Skills are canonical in `packages/core/skills/`. Claude, Codex, and Antigravity receive materialized skill copies only when `pnpm plugin:artifacts` generates self-contained release/local-install artifacts under `dist/`. Pi receives materialized skills only during package `prepack`. +Skills are canonical in the repository-root `skills/` directory. The repo root is also the GitHub-installable plugin payload for Claude, Codex, and Antigravity. Pi and the npm CLI package receive materialized skills during package `prepack`. | Harness | Skill owner | Installer responsibility | |---|---|---| -| **Claude** | Generated artifact | Native plugin artifact; `setup` for auth | -| **Codex** | Generated artifact | Native plugin artifact; `setup` for auth | -| **Antigravity** | Generated artifact | Native install script stages artifact assets; `setup` for auth | +| **Claude** | Root plugin | Native marketplace/plugin install; `setup` for auth | +| **Codex** | Root plugin | Native marketplace/plugin install; `setup` for auth | +| **Antigravity** | Root plugin | `agy plugin install `; `setup` for auth | | **Pi** | Pi npm package (`pi.skills`) | Pi package owns skills; `setup` writes auth/MCP config | | **OpenCode** | CLI-only harness copy | `setup` authenticates and copies skills/MCP config locally; `install` is no-browser fallback | -Codex artifacts currently include both a root `.codex-plugin/plugin.json` local marketplace container and a nested `plugins/nsolid-plugin/` plugin package. Both declare `skills: './skills/'` and both get materialized skill copies. ## Quick start @@ -85,35 +87,39 @@ On setup: ## Per-harness install -The commands below use local pre-release artifact directories generated by `pnpm plugin:artifacts`. Released native artifacts are `.tgz` files; extract an archive to a plugin-root directory before passing it to a harness CLI: +The GitHub marketplace flow installs from the repository root. + +The setup step requires the `nsolid-plugin` CLI on your PATH: ```bash -mkdir -p ./nsolid-claude-plugin -tar -xzf nsolid-claude-plugin.tgz -C ./nsolid-claude-plugin --strip-components=1 -claude plugin marketplace add ./nsolid-claude-plugin +npm i -g nsolid-plugin # stable release +# during pre-release: +npm i -g nsolid-plugin@next ``` +Or invoke it once-off with `npx -y nsolid-plugin setup --harness ` (`npx -y nsolid-plugin@next ...` during pre-release). + ### Claude Code ```bash -pnpm plugin:artifacts -claude plugin marketplace add ./dist/plugins/claude/nsolid-plugin -claude plugin install nsolid-plugin@nodesource-local +claude plugin marketplace add NodeSource/nsolid-plugin +claude plugin install nsolid-plugin@nodesource nsolid-plugin setup --harness claude ``` -Claude installs plugins through marketplaces, including local marketplace directories. The generated artifact root includes `.claude-plugin/marketplace.json`, so a downloaded/unpacked artifact can be added as a local marketplace before installing `nsolid-plugin@nodesource-local`. If marketplace/local plugin install is unavailable, `nsolid-plugin install --harness claude` is the fallback direct installer and does not open a browser. +Claude installs plugins through marketplaces. The repository root includes `.claude-plugin/marketplace.json`, so GitHub install works directly. If marketplace/local plugin install is unavailable, `nsolid-plugin install --harness claude` is the fallback direct installer and does not open a browser. + +> `nsolid-plugin` must be available on your PATH. Once published, install it globally (`npm i -g nsolid-plugin`) or invoke it via npx (`npx -y nsolid-plugin setup --harness claude`). During the pre-release window, use the prerelease tag (`npx -y nsolid-plugin@next ...` or `npm i -g nsolid-plugin@next`). ### Codex CLI ```bash -pnpm plugin:artifacts -codex plugin marketplace add ./dist/plugins/codex/nsolid-plugin -codex plugin add nsolid-plugin@codex-plugin +codex plugin marketplace add NodeSource/nsolid-plugin +codex plugin add nsolid-plugin@nodesource nsolid-plugin setup --harness codex ``` -Codex is marketplace/artifact-owned. A NodeSource-owned Git/local marketplace can be used as fallback if OpenAI curation is unavailable. Authentication remains explicit through `nsolid-plugin setup --harness codex`. +Codex is marketplace-owned. A NodeSource-owned Git/local marketplace can be used as fallback if OpenAI curation is unavailable. Authentication remains explicit through `nsolid-plugin setup --harness codex` (or `npx -y nsolid-plugin setup --harness codex`). ### OpenCode @@ -128,18 +134,17 @@ Use `nsolid-plugin install --harness opencode` only as a no-browser fallback/rep ### Antigravity CLI ```bash -pnpm plugin:artifacts -agy plugin install ./dist/plugins/antigravity/nsolid-plugin +agy plugin install https://github.com/NodeSource/nsolid-plugin.git nsolid-plugin setup --harness antigravity ``` -Antigravity uses artifact-owned skills and MCP wrappers from `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. The generated native install script stages the artifact bundle and prints the explicit setup command; it does not start auth. +Antigravity installs the repository root as a native plugin and stages skills/MCP wrappers under `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. Install does not start auth. ### Pi Agent ```bash # 1. Install the Pi package (skills are package-owned) -pi install npm:@nodesource/pi-plugin +pi install npm:nsolid-pi-plugin # 2. Authenticate and write Pi MCP config nsolid-plugin setup --harness pi @@ -190,16 +195,16 @@ pnpm lint # Lint all packages ### Bundle and plugin asset sync checks ```bash -pnpm --filter @nodesource/plugin-core bundle:check # Check if core bundle.json is in sync -pnpm --filter @nodesource/plugin-core bundle:sync # Copy root bundle.json into core +pnpm --filter nsolid-plugin bundle:check # Check if core bundle.json is in sync +pnpm --filter nsolid-plugin bundle:sync # Copy root bundle.json into core pnpm plugin:check # Check generated manifests/configs and verify no package skill copies are committed pnpm plugin:sync # Regenerate manifests/configs and remove materialized package skill copies -pnpm plugin:materialize # Copy core skills into plugin packages for pack/release artifacts -pnpm plugin:artifacts # Generate Claude/Codex/Antigravity plugin dirs and .tgz archives -pnpm plugin:artifacts:check # Verify generated plugin artifacts are current +pnpm plugin:materialize # Copy root skills into the Pi package for pack/release +pnpm plugin:root # Refresh root marketplace/plugin manifests from bundle.json +pnpm plugin:root:check # Fail if committed root manifests drift from bundle.json ``` -Run `pnpm plugin:check` in CI and before release. The source tree keeps one canonical skill copy under `packages/core/skills/`; package-local `skills/` directories are materialized only for pack/release artifacts and cleaned afterward by package `postpack` scripts. +Run `pnpm plugin:check` in CI and before release. The source tree keeps one canonical skill copy under root `skills/`; package-local `skills/` directories are materialized only for npm package release and cleaned afterward by package sync scripts. ## Troubleshooting @@ -257,7 +262,7 @@ nsolid-plugin restore --harness --backup ~/.agents/.config-backup/` is a no-browser fallback/direct installer. Claude, Codex, and Antigravity should normally use their generated native artifacts. OpenCode uses `nsolid-plugin setup --harness opencode` as its primary onboarding command because it authenticates and then performs the direct install; `install --harness opencode` is for repair/offline staging. Pi is package-owned: `pi install npm:@nodesource/pi-plugin` installs skills, while `nsolid-plugin install/setup --harness pi` only writes Pi MCP config. +`nsolid-plugin install --harness ` is a no-browser fallback/direct installer. Claude, Codex, and Antigravity should normally use native GitHub plugin install from the repository root. OpenCode uses `nsolid-plugin setup --harness opencode` as its primary onboarding command because it authenticates and then performs the direct install; `install --harness opencode` is for repair/offline staging. Pi is package-owned: `pi install npm:nsolid-pi-plugin` installs skills, while `nsolid-plugin install/setup --harness pi` only writes Pi MCP config. ### Verbose logging diff --git a/docs/antigravity-research.md b/docs/antigravity-research.md index 36e5347..2266db1 100644 --- a/docs/antigravity-research.md +++ b/docs/antigravity-research.md @@ -1,5 +1,7 @@ # Antigravity CLI — Plugin Research (Task 38) +> **Amendment (2026-06-24):** The shared core was renamed `@nodesource/plugin-core` → `nsolid-plugin`. The `scripts/install.js` approach described here was superseded — Antigravity now installs directly from the GitHub repository root. See `docs/qa-guide.md` §9. + **Status:** Complete (June 2026). **Verdict:** Antigravity has no install-time/session-start hook, so the plugin must ship a one-time `scripts/install.js` that the user runs manually. That script copies the plugin directory for discovery and invokes the shared core installer so auth, MCP config, and skills work consistently with the other harnesses. diff --git a/docs/pi-mcp-research.md b/docs/pi-mcp-research.md index 54692d6..60055af 100644 --- a/docs/pi-mcp-research.md +++ b/docs/pi-mcp-research.md @@ -1,5 +1,7 @@ # Pi Agent MCP Support Research +> **Amendment (2026-06-24):** Packages were renamed — `@nodesource/plugin-core` → `nsolid-plugin` and `@nodesource/pi-plugin` → `nsolid-pi-plugin`. References below use the old names; the analysis itself is unchanged. + ## Problem The NodeSource cross-harness plugin installer supports five AI harnesses. Four of them (Claude Code, Codex CLI, OpenCode, Antigravity) have native or file-based MCP configuration mechanisms. Pi Agent does not natively support the Model Context Protocol (MCP). This means MCP-backed NodeSource skills have no tools available when the user installs `@nodesource/pi-plugin`. @@ -154,7 +156,7 @@ Pi users should install in this order: ```bash # 1. Install the NodeSource plugin (writes ~/.pi/agent/mcp.json and skills) -pi install npm:@nodesource/pi-plugin +pi install npm:nsolid-pi-plugin # 2. Install pi-mcp-adapter so Pi can use the configured servers. # It reads ~/.pi/agent/mcp.json automatically. diff --git a/docs/plugin-marketplace-research.md b/docs/plugin-marketplace-research.md index 90c3279..09a31e5 100644 --- a/docs/plugin-marketplace-research.md +++ b/docs/plugin-marketplace-research.md @@ -1,6 +1,23 @@ # Plugin Marketplace and Artifact Research -Date: 2026-06-19 + Date: 2026-06-19 + +> **Amendment (2026-06-24):** The generated-artifact direction described below +> was **superseded**. Claude, Codex, and Antigravity no longer produce +> `dist/plugins/...` roots or `.tgz` archives via `pnpm plugin:artifacts` +> (that script and `plugins/templates/` were removed). Instead, the **repository +> root is itself the installable plugin** — mirroring `addyosmani/agent-skills` — +> with `.claude-plugin/{plugin,marketplace}.json`, +> `.agents/plugins/marketplace.json` + `.codex-plugin/plugin.json`, and an +> Antigravity `plugin.json` all pointing at the shared root `skills/` and +> `scripts/mcp-wrapper.js`. Harnesses install directly: +> `claude plugin marketplace add NodeSource/nsolid-plugin`, +> `codex plugin marketplace add NodeSource/nsolid-plugin`, +> `agy plugin install https://github.com/NodeSource/nsolid-plugin.git`. +> Root manifests are refreshed by `pnpm plugin:root` +> (`scripts/materialize-github-marketplace.mjs`). Pi and OpenCode conclusions +> below remain accurate. Treat the rest of this doc as historical context for +> why the original refactor was attempted, not as the current distribution model. ## Purpose diff --git a/docs/qa-guide.md b/docs/qa-guide.md index ce95e35..29c42c7 100644 --- a/docs/qa-guide.md +++ b/docs/qa-guide.md @@ -31,8 +31,6 @@ Install any missing harness before running that harness section. pnpm install --frozen-lockfile pnpm build pnpm plugin:check -pnpm plugin:artifacts -pnpm plugin:artifacts:check pnpm lint pnpm test pnpm test:marketplace @@ -40,63 +38,29 @@ pnpm test:marketplace Expected: every command exits `0`. -## 2. Plugin artifact QA inputs +## 2. Real HOME CLI QA setup -For local pre-release QA, generate the release-shaped plugin artifacts from this repo and install those generated directories: - -```bash -pnpm plugin:artifacts -export CLAUDE_PLUGIN_ARTIFACT="$(pwd)/dist/plugins/claude/nsolid-plugin" -export CODEX_PLUGIN_ARTIFACT="$(pwd)/dist/plugins/codex/nsolid-plugin" -export ANTIGRAVITY_PLUGIN_ARTIFACT="$(pwd)/dist/plugins/antigravity/nsolid-plugin" -``` - -For production/release QA, use released plugin artifacts, not source package directories. A release should provide one archive per native plugin-owned harness: - -```text -nsolid-claude-plugin.tgz # plugin root contains .claude-plugin/{plugin,marketplace}.json, .mcp.json, scripts/, skills/ -nsolid-codex-plugin.tgz # plugin root contains .codex-plugin/plugin.json, .mcp.json, .agents/plugins/marketplace.json, skills/ -nsolid-antigravity-plugin.tgz # plugin root contains plugin.json, mcp_config.json, scripts/, skills/ -``` +This QA flow uses your real HOME and modifies real harness configs. -Extract the downloaded archives into plugin-root directories and point QA at them: +Local build (default — QA the code in this checkout): ```bash -export ARTIFACT_DIR="$(mktemp -d -t nsolid-plugin-artifacts-XXXXXX)" -mkdir -p "$ARTIFACT_DIR/claude" "$ARTIFACT_DIR/codex" "$ARTIFACT_DIR/antigravity" -tar -xzf /path/to/nsolid-claude-plugin.tgz -C "$ARTIFACT_DIR/claude" --strip-components=1 -tar -xzf /path/to/nsolid-codex-plugin.tgz -C "$ARTIFACT_DIR/codex" --strip-components=1 -tar -xzf /path/to/nsolid-antigravity-plugin.tgz -C "$ARTIFACT_DIR/antigravity" --strip-components=1 -export CLAUDE_PLUGIN_ARTIFACT="$ARTIFACT_DIR/claude" -export CODEX_PLUGIN_ARTIFACT="$ARTIFACT_DIR/codex" -export ANTIGRAVITY_PLUGIN_ARTIFACT="$ARTIFACT_DIR/antigravity" +export REPO_ROOT="$(pwd)" +export REAL_HOME="$(node -e 'console.log(require("node:os").homedir())')" +export HOME="$REAL_HOME" +export CLI="node $REPO_ROOT/packages/core/dist/src/cli.js" +echo "Using real HOME: $HOME" ``` -Validate the selected artifacts: +Published package (once `nsolid-plugin` is on npm — QA the shipped artifact instead): ```bash -test -f "$CLAUDE_PLUGIN_ARTIFACT/.claude-plugin/plugin.json" -test -f "$CLAUDE_PLUGIN_ARTIFACT/.claude-plugin/marketplace.json" -test -f "$CLAUDE_PLUGIN_ARTIFACT/.mcp.json" -test -f "$CODEX_PLUGIN_ARTIFACT/.codex-plugin/plugin.json" -test -f "$CODEX_PLUGIN_ARTIFACT/.mcp.json" -test -f "$CODEX_PLUGIN_ARTIFACT/.agents/plugins/marketplace.json" -test -f "$ANTIGRAVITY_PLUGIN_ARTIFACT/plugin.json" -test -f "$ANTIGRAVITY_PLUGIN_ARTIFACT/mcp_config.json" -find "$CLAUDE_PLUGIN_ARTIFACT" "$CODEX_PLUGIN_ARTIFACT" "$ANTIGRAVITY_PLUGIN_ARTIFACT" -path '*/skills/*/SKILL.md' | sort | wc -l -``` - -Expected: all `test` commands exit `0`; skill count is non-zero and matches generated artifact expectations. - -## 3. Real HOME CLI QA setup - -This QA flow uses your real HOME and modifies real harness configs. - -```bash -export REPO_ROOT="$(pwd)" export REAL_HOME="$(node -e 'console.log(require("node:os").homedir())')" export HOME="$REAL_HOME" -export CLI="node $REPO_ROOT/packages/core/dist/src/cli.js" +# stable release: +export CLI="npx -y nsolid-plugin" +# prerelease (published under the next dist-tag): +export CLI="npx -y nsolid-plugin@next" echo "Using real HOME: $HOME" ``` @@ -108,7 +72,7 @@ $CLI --help Expected: help lists `setup`, `install`, `uninstall`, `logout`, `doctor`, and `restore`. -## 4. CLI auth/setup flow +## 3. CLI auth/setup flow Use a real NodeSource account. @@ -119,7 +83,7 @@ $CLI logout $CLI setup --harness claude --yes ``` -Staging accounts: +Staging accounts (use this one for the initial QA): ```bash $CLI logout @@ -136,7 +100,7 @@ $CLI setup --harness codex --yes $CLI setup --harness antigravity --yes ``` -Staging accounts: +Staging accounts (use this one for the initial QA): ```bash test -f "$HOME/.agents/.nodesource-auth.json" @@ -146,44 +110,14 @@ $CLI setup --harness antigravity --yes --staging Expected: no second browser if credentials are still valid; output says credentials are ready. -## 5. CLI fallback install must not open browser - -```bash -$CLI logout -for h in claude codex antigravity opencode pi; do - $CLI install --harness "$h" --yes || true -done -``` - -Expected: no browser opens. Missing auth should print `nsolid-plugin setup --harness `, not start OAuth. - -Required end-of-step cleanup before continuing. This removes fallback user-level MCP entries so native plugin QA does not show duplicate MCP servers: - -```bash -for h in claude codex antigravity pi; do - $CLI uninstall --harness "$h" --keep-credentials || true -done -``` - -Expected: fallback artifacts for Claude/Codex/Antigravity/Pi are removed; credentials are kept. - -Now authenticate again for full fallback checks. +## 4. CLI doctor / uninstall / logout -Production accounts: +Install the opencode mcp and skills firts ```bash -$CLI setup --harness opencode --yes +$CLI install --harness opencode ``` -Staging accounts: - -```bash -$CLI setup --harness opencode --yes --staging -``` - -Expected: browser opens if logged out; OpenCode fallback skills/config are installed after auth. - -## 6. CLI doctor / uninstall / logout ```bash $CLI doctor --harness opencode || true @@ -197,36 +131,55 @@ Expected: - `uninstall` removes fallback OpenCode artifacts. - `logout` removes credentials or says none were found. -## 7. Claude Code native install QA +## 5. Published CLI package QA + +Once `nsolid-plugin` is published to npm, QA the shipped package itself — not the local build — to confirm the `bin` works, the right files ship, and skills/bundle are included. + +```bash +# prerelease: +npx -y nsolid-plugin@next --help +# stable release: +npx -y nsolid-plugin --help + +# Inspect what the package actually ships (files included in the tarball): +npm pack nsolid-plugin@next --dry-run 2>&1 | sed -n '/Tarball Contents/,/Tarball Details/p' +``` + +Expected: +- `--help` runs without a local build; the `nsolid-plugin` bin is executable. +- Tarball contents include `dist/src/`, `skills/`, `bundle.json`, and `scripts/`. +- The `bin` entry resolves to `./dist/src/cli.js` and that file is executable. +- No `test/`, no source `*.ts`, no `tsconfig` shipped. + +## 6. Claude Code native install QA + +Claude installs from the GitHub plugin root. The local path can be the repo checkout itself, or a clone for closer-to-release coverage. +We use the branch for now. Production accounts: ```bash -export HOME="$REAL_HOME" -claude plugin validate "$CLAUDE_PLUGIN_ARTIFACT" -claude plugin marketplace add "$CLAUDE_PLUGIN_ARTIFACT" -claude plugin install nsolid-plugin@nodesource-local +claude plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +claude plugin install nsolid-plugin@nodesource $CLI setup --harness claude --yes claude plugin list -claude plugin details nsolid-plugin@nodesource-local +claude plugin details nsolid-plugin@nodesource ``` -Staging accounts: +Staging accounts (use this one for the initial QA): ```bash -export HOME="$REAL_HOME" -claude plugin validate "$CLAUDE_PLUGIN_ARTIFACT" -claude plugin marketplace add "$CLAUDE_PLUGIN_ARTIFACT" -claude plugin install nsolid-plugin@nodesource-local +claude plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +claude plugin install nsolid-plugin@nodesource $CLI setup --harness claude --yes --staging claude plugin list -claude plugin details nsolid-plugin@nodesource-local +claude plugin details nsolid-plugin@nodesource ``` Expected: - Marketplace add/plugin install does not open browser. - `setup` is the only step that may open browser. -- Plugin appears in Claude plugin list as `nsolid-plugin@nodesource-local`. +- Plugin appears in Claude plugin list as `nsolid-plugin@nodesource`. - `claude plugin details` shows the N|Solid component inventory. - mcps are listed and connected, check with `/mcp` - skills are listed, check with `/skills` @@ -235,32 +188,29 @@ Cleanup: ```bash claude plugin uninstall nsolid-plugin || true -claude plugin marketplace remove nodesource-local || true +claude plugin marketplace remove nodesource || true $CLI uninstall --harness claude --keep-credentials || true ``` -## 8. Codex native install QA +## 7. Codex native install QA + +Codex installs from the GitHub plugin root. The marketplace name is `nodesource`. +We use the branch for now. Production accounts: ```bash -export HOME="$REAL_HOME" -codex plugin remove nsolid-plugin@codex-plugin || true -rm -rf "$HOME/.codex/plugins/cache/codex-plugin/nsolid-plugin" -codex plugin marketplace add "$CODEX_PLUGIN_ARTIFACT" -codex plugin add nsolid-plugin@codex-plugin +codex plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +codex plugin add nsolid-plugin@nodesource $CLI setup --harness codex --yes codex /plugins ``` -Staging accounts: +Staging accounts (use this one for the initial QA): ```bash -export HOME="$REAL_HOME" -codex plugin remove nsolid-plugin@codex-plugin || true -rm -rf "$HOME/.codex/plugins/cache/codex-plugin/nsolid-plugin" -codex plugin marketplace add "$CODEX_PLUGIN_ARTIFACT" -codex plugin add nsolid-plugin@codex-plugin +codex plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +codex plugin add nsolid-plugin@nodesource $CLI setup --harness codex --yes --staging codex /plugins ``` @@ -269,38 +219,37 @@ Expected: - Marketplace/plugin install does not open browser. - `setup` is the only step that may open browser. - Plugin appears in Codex plugins UI/list. -- Local pre-release reruns clear only the N|Solid Codex plugin cache so regenerated artifacts are recopied. +- Local pre-release reruns clear only the N|Solid Codex plugin cache so recopied assets are recopied. - mcps are listed and connected, check with `/mcp` - skills are listed, check with `/skills` Cleanup: ```bash -codex plugin remove nsolid-plugin@codex-plugin || true -rm -rf "$HOME/.codex/plugins/cache/codex-plugin/nsolid-plugin" +codex plugin remove nsolid-plugin@nodesource $CLI uninstall --harness codex --keep-credentials || true ``` -If `codex plugin remove nsolid-plugin@codex-plugin` is unavailable, remove it through `codex /plugins`. +If `codex plugin remove nsolid-plugin@nodesource` is unavailable, remove it through `codex /plugins`. + +## 8. Antigravity native install QA -## 9. Antigravity native install QA +Antigravity installs directly from the GitHub plugin root (a git URL). +We use the branch for now. Production accounts: ```bash -export HOME="$REAL_HOME" -agy plugin validate "$ANTIGRAVITY_PLUGIN_ARTIFACT" -agy plugin install "$ANTIGRAVITY_PLUGIN_ARTIFACT" +agy plugin install https://github.com/nodesource/nsolid-plugin/tree/cesar/github-install-test agy plugin list $CLI setup --harness antigravity --yes ``` -Staging accounts: +Staging accounts (use this one for the initial QA): ```bash -export HOME="$REAL_HOME" -agy plugin validate "$ANTIGRAVITY_PLUGIN_ARTIFACT" -agy plugin install "$ANTIGRAVITY_PLUGIN_ARTIFACT" + +agy plugin install https://github.com/nodesource/nsolid-plugin/tree/cesar/github-install-test agy plugin list $CLI setup --harness antigravity --yes --staging ``` @@ -320,14 +269,11 @@ agy plugin uninstall nsolid-plugin || true $CLI uninstall --harness antigravity --keep-credentials || true ``` -## 10. Pi package install QA +## 9. Pi package install QA Local pre-release package QA with production accounts: ```bash -export HOME="$REAL_HOME" -$CLI uninstall --harness pi --keep-credentials || true -find "$HOME/.pi/agent/skills" "$HOME/.agents/skills" -maxdepth 1 -name 'ns-*' -exec rm -rf {} + 2>/dev/null || true pnpm plugin:materialize pi install ./packages/pi-plugin --no-approve $CLI setup --harness pi --yes @@ -335,12 +281,9 @@ pi install npm:pi-mcp-adapter $CLI doctor --harness pi || true ``` -Local pre-release package QA with staging accounts: +Local pre-release package QA with staging accounts (use this one for the initial qa): ```bash -export HOME="$REAL_HOME" -$CLI uninstall --harness pi --keep-credentials || true -find "$HOME/.pi/agent/skills" "$HOME/.agents/skills" -maxdepth 1 -name 'ns-*' -exec rm -rf {} + 2>/dev/null || true pnpm plugin:materialize pi install ./packages/pi-plugin --no-approve $CLI setup --harness pi --yes --staging @@ -351,10 +294,11 @@ $CLI doctor --harness pi || true Production package QA uses the published Pi package instead of the local package directory: ```bash -export HOME="$REAL_HOME" $CLI uninstall --harness pi --keep-credentials || true -find "$HOME/.pi/agent/skills" "$HOME/.agents/skills" -maxdepth 1 -name 'ns-*' -exec rm -rf {} + 2>/dev/null || true -pi install npm:@nodesource/pi-plugin +# stable release: +pi install npm:nsolid-pi-plugin +# prerelease (published under the next dist-tag) — pin the exact version, e.g.: +# pi install npm:nsolid-pi-plugin@1.0.0-next.0 $CLI setup --harness pi --yes pi install npm:pi-mcp-adapter $CLI doctor --harness pi || true @@ -383,23 +327,21 @@ $CLI uninstall --harness pi --keep-credentials || true pnpm plugin:clean ``` -## 11. OpenCode CLI-only install QA +## 10. OpenCode CLI-only install QA OpenCode is CLI-only. `setup --harness opencode` is the primary onboarding command because it authenticates and then performs the direct install; `install --harness opencode` is a no-browser fallback/repair path. Production accounts: ```bash -export HOME="$REAL_HOME" $CLI setup --harness opencode --yes $CLI doctor --harness opencode || true opencode --version ``` -Staging accounts: +Staging accounts (use this one for the initial qa): ```bash -export HOME="$REAL_HOME" $CLI setup --harness opencode --yes --staging $CLI doctor --harness opencode || true opencode --version @@ -417,7 +359,7 @@ Cleanup: $CLI uninstall --harness opencode --keep-credentials || true ``` -## 12. Final cleanup +## 11. Final cleanup ```bash $CLI logout || true diff --git a/mcp_config.json b/mcp_config.json new file mode 100644 index 0000000..fe9b1a7 --- /dev/null +++ b/mcp_config.json @@ -0,0 +1,28 @@ +{ + "mcpServers": { + "nsolid-console": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "nsolid-console" + ] + }, + "ns-benchmark": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "ns-benchmark" + ] + }, + "ncm": { + "command": "node", + "args": [ + "-e", + "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)", + "ncm" + ] + } + } +} diff --git a/package.json b/package.json index e81692f..47ce2b7 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,13 @@ "lint": "pnpm -r lint", "test:marketplace": "node scripts/test-marketplace-install.js", "plugin:sync": "node scripts/sync-plugin-assets.mjs", - "plugin:check": "node scripts/sync-plugin-assets.mjs --check", + "plugin:check": "node scripts/sync-plugin-assets.mjs --check && node scripts/materialize-github-marketplace.mjs --check", "prepack": "pnpm plugin:sync", "prepare": "husky", "plugin:materialize": "node scripts/sync-plugin-assets.mjs --materialize-skills", "plugin:clean": "node scripts/sync-plugin-assets.mjs", - "plugin:artifacts": "node scripts/build-plugin-artifacts.mjs", - "plugin:artifacts:check": "node scripts/build-plugin-artifacts.mjs --check" + "plugin:root": "node scripts/materialize-github-marketplace.mjs", + "plugin:root:check": "node scripts/materialize-github-marketplace.mjs --check" }, "engines": { "node": ">=22.3.0" diff --git a/packages/core/README.md b/packages/core/README.md index f2208b0..b57cf4a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,10 +1,10 @@ -# @nodesource/plugin-core +# nsolid-plugin (core) Shared CLI/setup/fallback installation logic for the N|Solid cross-harness plugin distribution. ## What it does -`@nodesource/plugin-core` provides `setup()`, fallback `install()`, `uninstall()`, and `doctor()` functions. Claude, Codex, and Antigravity native plugin artifacts are generated from `plugins/templates/` by `pnpm plugin:artifacts`; Pi remains a real package; OpenCode is CLI-only. +`nsolid-plugin` provides `setup()`, fallback `install()`, `uninstall()`, and `doctor()` functions. Claude, Codex, and Antigravity install from the GitHub plugin root; Pi remains a real package; OpenCode is CLI-only. Install/setup semantics are intentionally split: @@ -17,7 +17,7 @@ Install/setup semantics are intentionally split: ## Public API ```ts -import { install, uninstall, doctor, getAdapter } from '@nodesource/plugin-core' +import { install, uninstall, doctor, getAdapter } from 'nsolid-plugin' // Install for a specific harness const result = await install({ @@ -50,7 +50,7 @@ Each harness has an adapter that provides its config and skills paths: | Codex | Plugin-owned `.mcp.json` | Plugin-owned `skills/` | Yes | | OpenCode | `~/.config/opencode/opencode.jsonc` | `~/.config/opencode/skills/` | Yes | | Antigravity | Plugin-owned `~/.gemini/antigravity-cli/plugins/nsolid-plugin/mcp_config.json` | Plugin-owned `~/.gemini/antigravity-cli/plugins/nsolid-plugin/skills/` | Yes | -| Pi | `~/.pi/agent/mcp.json` | Package-owned `@nodesource/pi-plugin/skills/` | Yes | +| Pi | `~/.pi/agent/mcp.json` | Package-owned `nsolid-pi-plugin/skills/` | Yes | ## CLI @@ -73,7 +73,7 @@ nsolid-plugin restore --harness claude --list nsolid-plugin restore --harness claude --backup ~/.agents/.config-backup/claude/1234567890.json ``` -Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer generated native artifacts from `pnpm plugin:artifacts`; `install --harness` is a fallback direct installer only. For Pi, install `@nodesource/pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for primary onboarding because it authenticates and then installs user-level skills plus MCP config. +Use `--verbose` (or `NSOLID_PLUGIN_VERBOSE=1`) for detailed, timestamped logs written to stderr. Verbose mode redacts tokens and auth headers. For Claude Code, Codex, and Antigravity, prefer native GitHub plugin install from the repository root; `install --harness` is a fallback direct installer only. For Pi, install `nsolid-pi-plugin` for package-owned skills; CLI install/setup only writes MCP config. OpenCode is CLI-only and uses `setup --harness opencode` for primary onboarding because it authenticates and then installs user-level skills plus MCP config. ## Config backups @@ -115,7 +115,7 @@ pnpm lint # Sync checks pnpm plugin:check # source hygiene; no committed package skill copies pnpm plugin:sync # clean materialized Pi package skills -pnpm plugin:materialize # copy core skills into Pi package for pack -pnpm plugin:artifacts # generate Claude/Codex/Antigravity artifacts under dist/ -pnpm plugin:artifacts:check # validate generated artifacts are current +pnpm plugin:materialize # copy root skills into Pi package for pack +pnpm plugin:root # refresh root GitHub marketplace/plugin manifests +pnpm plugin:root:check # fail if root manifests drift from bundle.json ``` diff --git a/packages/core/package.json b/packages/core/package.json index 5d5f8c5..982451a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { - "name": "@nodesource/plugin-core", - "version": "0.1.0", + "name": "nsolid-plugin", + "version": "1.0.0-next.0", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -22,7 +22,12 @@ "skills:check": "node scripts/sync-shared-skill-assets.mjs --check", "bundle:sync": "node scripts/check-bundle-sync.mjs --sync", "bundle:check": "node scripts/check-bundle-sync.mjs --check", - "prepack": "pnpm run skills:sync && pnpm run bundle:sync" + "prepublishOnly": "pnpm run build", + "prepack": "pnpm run build && pnpm run skills:sync && pnpm run bundle:sync && node -e \"require('node:fs').chmodSync('dist/src/cli.js', 0o755)\"" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" }, "dependencies": { "smol-toml": "^1.3.1", diff --git a/packages/core/scripts/setup.mjs b/packages/core/scripts/setup.mjs index 3edbe52..6bb7bce 100644 --- a/packages/core/scripts/setup.mjs +++ b/packages/core/scripts/setup.mjs @@ -2,11 +2,16 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' +import { existsSync } from 'node:fs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const corePkgRoot = path.resolve(__dirname, '..') -const bundlePath = path.join(corePkgRoot, 'bundle.json') -const skillsSource = corePkgRoot +const repoRoot = path.resolve(corePkgRoot, '..', '..') +const defaultSourceRoot = existsSync(path.join(repoRoot, 'bundle.json')) && existsSync(path.join(repoRoot, 'skills')) + ? repoRoot + : corePkgRoot +const bundlePath = path.join(defaultSourceRoot, 'bundle.json') +const skillsSource = defaultSourceRoot const harness = process.env.NSOLID_HARNESS const action = process.argv[2] ?? 'install' @@ -29,7 +34,7 @@ if (!VALID_HARNESS.includes(harness)) { process.exit(1) } -const { install, setup, uninstall } = await import('@nodesource/plugin-core') +const { install, setup, uninstall } = await import('nsolid-plugin') const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) diff --git a/packages/core/scripts/sync-shared-skill-assets.mjs b/packages/core/scripts/sync-shared-skill-assets.mjs index 6fbf8bc..a2a4457 100644 --- a/packages/core/scripts/sync-shared-skill-assets.mjs +++ b/packages/core/scripts/sync-shared-skill-assets.mjs @@ -1,14 +1,22 @@ #!/usr/bin/env node -import { readFileSync, copyFileSync, existsSync } from 'node:fs' +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + copyFileSync, +} from 'node:fs' import { createHash } from 'node:crypto' import { join, resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) - -const SHARED_DIR = resolve(__dirname, '..', 'skills', '_shared') -const SKILLS_DIR = resolve(__dirname, '..', 'skills') +const REPO_ROOT = resolve(__dirname, '..', '..', '..') +const SOURCE_SKILLS_DIR = resolve(REPO_ROOT, 'skills') +const SOURCE_SHARED_DIR = resolve(REPO_ROOT, 'skill-assets') +const PACKAGE_SKILLS_DIR = resolve(__dirname, '..', 'skills') const manifestPath = join(__dirname, 'skill-assets.manifest.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) @@ -23,12 +31,12 @@ let synced = 0 let drift = false for (const [file, skillNames] of Object.entries(manifest)) { - const source = join(SHARED_DIR, file) + const source = join(SOURCE_SHARED_DIR, file) const expectedHash = hashFile(source) for (const skillName of skillNames) { - const dest = join(SKILLS_DIR, skillName, file) + const dest = join(SOURCE_SKILLS_DIR, skillName, file) if (!existsSync(dest) || hashFile(dest) !== expectedHash) { - console.error(`DRIFT: skills/${skillName}/${file} differs from _shared/${file}`) + console.error(`DRIFT: skills/${skillName}/${file} differs from skill-assets/${file}`) drift = true } if (!checking) { @@ -40,14 +48,17 @@ for (const [file, skillNames] of Object.entries(manifest)) { if (drift) { if (checking) { - console.error('check: FAILED — some per-skill copies have drifted from _shared/. Run "pnpm run skills:sync" to fix.') + console.error('check: FAILED — some per-skill copies have drifted from skill-assets/. Run "pnpm run skills:sync" to fix.') process.exit(1) } console.error('Auto-fixed by running sync.') } if (!checking) { - console.log(`${synced} files synced from _shared/ to ${Object.keys(manifest).length} shared asset(s)`) + rmSync(PACKAGE_SKILLS_DIR, { recursive: true, force: true }) + mkdirSync(dirname(PACKAGE_SKILLS_DIR), { recursive: true }) + cpSync(SOURCE_SKILLS_DIR, PACKAGE_SKILLS_DIR, { recursive: true, dereference: true }) + console.log(`${synced} files synced from skill-assets/; materialized package skills/`) } else { - console.log('check: OK — all per-skill copies match _shared/') + console.log('check: OK — all per-skill copies match skill-assets/') } diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index 37b3426..46ab93f 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -73,8 +73,13 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg if (err instanceof PermissionError) { throw err } - // API unavailable - fall through to re-authenticate + // API unavailable (unreachable, non-JSON response, timeout): trust the + // unexpired, origin-matching stored credentials rather than forcing a + // re-login on every setup. Mirrors the optimistic-storage path after + // OAuth. A 401/403 is NOT thrown — validateToken returns { valid: false } + // for that — so revoked tokens still trigger re-authentication below. logger?.warn('auth.credentials.validationUnavailable', { error: (err as Error).message }) + return existing } } } diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 0aa7499..6ce79ef 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -3,6 +3,7 @@ import { parseArgs } from 'node:util' import { createInterface } from 'node:readline/promises' import path from 'node:path' +import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { install, setup, uninstall, logout, doctor, restore } from './index.js' import type { AuthConfirmation, HarnessType } from './types.js' @@ -11,9 +12,6 @@ import { formatPluginError } from './errors.js' import { listConfigBackups } from './utils/backup.js' import { C, supportsColor } from './utils/format.js' import { createConsoleProgress, silentProgress } from './utils/progress.js' -const CLAUDE_RELEASE_ARTIFACT_TARGET = '' -const CODEX_RELEASE_ARTIFACT_TARGET = '' -const ANTIGRAVITY_RELEASE_ARTIFACT_TARGET = '' const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) const PACKAGE_OWNED_SKILL_HARNESSES = new Set(['pi']) const HARNESS_SPECIFIC_SKILL_HARNESSES = new Set(['opencode']) @@ -23,7 +21,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) // bundle.json and skills/ ship at the package root (per package.json "files"), // not under dist/ — resolve up two levels to reach the package root. const CORE_PKG_ROOT = path.resolve(__dirname, '..', '..') -const BUNDLE_PATH = path.join(CORE_PKG_ROOT, 'bundle.json') +const REPO_ROOT = path.resolve(CORE_PKG_ROOT, '..', '..') +const DEFAULT_SOURCE_ROOT = existsSync(path.join(REPO_ROOT, 'bundle.json')) && existsSync(path.join(REPO_ROOT, 'skills')) + ? REPO_ROOT + : CORE_PKG_ROOT +const BUNDLE_PATH = path.join(DEFAULT_SOURCE_ROOT, 'bundle.json') const HARNESS_LABELS: Record = { claude: 'Claude Code', @@ -77,7 +79,7 @@ Options: --help Show this help message Distribution notes: - Claude/Codex/Antigravity: native plugin artifacts are generated with pnpm plugin:artifacts; install is fallback-only. + Claude/Codex/Antigravity: install from the GitHub plugin root; setup is auth-only. Pi: use pi install for package-owned skills; CLI install/setup only writes MCP config. OpenCode: use setup --harness opencode for auth + direct install; install is fallback/repair. Auth: only setup/login may open a browser.`) @@ -256,7 +258,7 @@ async function main (): Promise { const bundlePath = values.bundle ? path.resolve(values.bundle) : BUNDLE_PATH const skillsSource = values['skills-source'] ? path.resolve(values['skills-source']) - : CORE_PKG_ROOT + : DEFAULT_SOURCE_ROOT const commonOptions = { verbose: values.verbose === true } const color = values['no-color'] !== true && supportsColor() @@ -342,16 +344,11 @@ async function main (): Promise { : 'Credentials present' console.log(`${paint.green('✓')} ${HARNESS_LABELS[installHarness]} — fallback direct install. ${authNote}`) if (installHarness === 'claude') { - console.log(` ${paint.dim('Native plugin artifact:')} extract ${paint.yellow('nsolid-claude-plugin.tgz')} to a plugin root, then run:`) - console.log(` ${paint.yellow(`claude plugin marketplace add ${CLAUDE_RELEASE_ARTIFACT_TARGET}`)}`) - console.log(` ${paint.yellow('claude plugin install nsolid-plugin@nodesource-local')}`) + console.log(` ${paint.dim('Native plugin:')} ${paint.yellow('claude plugin marketplace add NodeSource/nsolid-plugin && claude plugin install nsolid-plugin@nodesource')}`) } else if (installHarness === 'codex') { - console.log(` ${paint.dim('Native plugin artifact:')} extract ${paint.yellow('nsolid-codex-plugin.tgz')} to a plugin root, then run:`) - console.log(` ${paint.yellow(`codex plugin marketplace add ${CODEX_RELEASE_ARTIFACT_TARGET}`)}`) - console.log(` ${paint.yellow('codex plugin add nsolid-plugin@codex-plugin')}`) + console.log(` ${paint.dim('Native plugin:')} ${paint.yellow('codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource')}`) } else if (installHarness === 'antigravity') { - console.log(` ${paint.dim('Native plugin artifact:')} extract ${paint.yellow('nsolid-antigravity-plugin.tgz')} to a plugin root, then run:`) - console.log(` ${paint.yellow(`agy plugin install ${ANTIGRAVITY_RELEASE_ARTIFACT_TARGET}`)}`) + console.log(` ${paint.dim('Native plugin:')} ${paint.yellow('agy plugin install https://github.com/NodeSource/nsolid-plugin.git')}`) } continue } @@ -369,7 +366,7 @@ async function main (): Promise { console.log(fmt(' pi install npm:pi-mcp-adapter')) console.log(fmt(` MCP config written to ${getAdapter('pi').getMcpConfigPath()}`)) console.log(fmt(' Install/reload the Pi package to load package-owned skills:')) - console.log(fmt(' pi install npm:@nodesource/pi-plugin')) + console.log(fmt(' pi install npm:nsolid-pi-plugin')) console.log(fmt(' (local QA: pi install ./packages/pi-plugin --no-approve, then /reload)')) } continue diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f36641a..a4e7d17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,7 +47,7 @@ import { toPluginError } from './errors.js' const KNOWN_MCP_SERVERS = ['ns-benchmark', 'nsolid-console', 'ncm'] const STAGING_ACCOUNTS_URL = 'https://staging.accounts.nodesource.com' const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) -const PI_PLUGIN_PACKAGE_NAME = '@nodesource/pi-plugin' +const PI_PLUGIN_PACKAGE_NAME = 'nsolid-pi-plugin' function formatBundleSummary (bundle: BundleDescriptor, options: { packageOwnedSkills?: boolean }): string { if (options.packageOwnedSkills === true) { diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 057b3a5..4f77699 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -209,7 +209,7 @@ describe('ensureAuthenticated', () => { assert.strictEqual(result.organizationId, 'org-456') }) - it('falls back to OAuth when API is unavailable during fast path', { timeout: 10000 }, async () => { + it('trusts stored credentials when validation API is unavailable during fast path', async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'valid-token', @@ -218,17 +218,18 @@ describe('ensureAuthenticated', () => { consoleUrl: 'https://valid.saas.nodesource.io', mcpUrl: 'https://org-123.mcp.saas.nodesource.io', expiresAt: new Date(Date.now() + 86400000).toISOString(), + permissions: ['nsolid:benchmark:run'], } saveCredentials(creds) + // The validation API is unreachable (ECONNREFUSED) or returns a non-JSON + // payload (e.g. an SPA shell). In both cases validateToken THROWS, and the + // fast path must trust the unexpired, origin-matching stored credentials + // instead of forcing a re-login on every setup. globalThis.fetch = mock.fn(async () => { throw new Error('ECONNREFUSED') }) as unknown as typeof fetch - // The API being unavailable triggers an expected logger.warn from - // auth-manager ("Could not validate token. Storing credentials optimistically."). - // Inject a capturing logger so the warning doesn't pollute the test reporter's - // dot row, and assert it fired — this documents the degraded-mode behavior. const warnings: string[] = [] const logger = { debug: () => {}, @@ -238,7 +239,44 @@ describe('ensureAuthenticated', () => { } const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig, logger) + const result = await ensureAuthenticated(authConfig, logger) + + // Stored credentials returned verbatim — no OAuth re-authentication. + assert.strictEqual(result.serviceToken, 'valid-token') + assert.strictEqual(result.organizationId, 'org-123') + assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API is unavailable') + assert.ok( + warnings.some((w) => w.includes('auth.credentials.validationUnavailable')), + 'expected a validationUnavailable warning when validation API is unavailable' + ) + }) + + it('re-authenticates when validation API returns 401/403 during fast path', async () => { + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + const creds: Credentials = { + serviceToken: 'revoked-token', + organizationId: 'org-123', + saasToken: 'revoked-saas', + consoleUrl: 'https://valid.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + } + saveCredentials(creds) + + // A revoked token gets 401/403 from the API — validateToken returns + // { valid: false } (does NOT throw), so the fast path must fall through to + // re-authenticate. This preserves revocation detection. The fresh OAuth + // token from the callback then validates OK. + globalThis.fetch = mock.fn(async (input: RequestInfo | URL) => { + const target = typeof input === 'string' ? input : input.toString() + const revoked = target.includes('tokenId=revoked-token') + return revoked + ? { ok: false, status: 401, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({}) } + : { ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ permissions: [] }) } + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const promise = ensureAuthenticated(authConfig) await new Promise((resolve) => setTimeout(resolve, 50)) const state = getStateFromExecFileCall() @@ -247,10 +285,37 @@ describe('ensureAuthenticated', () => { assert.strictEqual(result.serviceToken, 'oauth-token') assert.strictEqual(result.organizationId, 'org-456') - assert.ok( - warnings.some((w) => w.includes('Could not validate token')), - 'expected an optimistic-storage warning when validation API is unavailable' - ) + }) + + it('trusts stored credentials when validation API returns an HTML shell (200 text/html)', async () => { + // Regression: the accounts API serves its SPA index.html (HTTP 200, + // content-type text/html) for server-side callers, which made validateToken + // throw "unexpected content type" on every setup and forced a re-login. + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + const creds: Credentials = { + serviceToken: 'valid-token', + organizationId: 'org-123', + saasToken: 'valid-saas', + consoleUrl: 'https://valid.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + permissions: ['nsolid:benchmark:run'], + } + saveCredentials(creds) + + globalThis.fetch = mock.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/html' }), + text: async () => '...', + })) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const result = await ensureAuthenticated(authConfig) + + assert.strictEqual(result.serviceToken, 'valid-token') + assert.strictEqual(result.organizationId, 'org-123') + assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API returns an HTML shell') }) }) diff --git a/packages/core/test/integration/cli-help.test.ts b/packages/core/test/integration/cli-help.test.ts index dd57e5b..113b9e9 100644 --- a/packages/core/test/integration/cli-help.test.ts +++ b/packages/core/test/integration/cli-help.test.ts @@ -8,15 +8,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const CLI_PATH = join(__dirname, '..', '..', 'src', 'cli.ts') describe('CLI help', () => { - it('describes Codex as a native-artifact harness with fallback install only', () => { + it('describes Codex as a root native-plugin harness with fallback install only', () => { const result = spawnSync(process.execPath, ['--import', 'tsx/esm', CLI_PATH, '--help'], { encoding: 'utf-8', }) assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) const output = result.stdout - assert.match(output, /Claude\/Codex\/Antigravity: native plugin artifacts/, 'help must group Codex with native plugin artifact harnesses') - assert.match(output, /install is fallback-only/, 'help must identify install as fallback-only for native plugin harnesses') + assert.match(output, /Claude\/Codex\/Antigravity: install from the GitHub plugin root/, 'help must group Codex with root native plugin harnesses') + assert.match(output, /setup is auth-only/, 'help must identify setup as auth-only for native plugin harnesses') assert.doesNotMatch(output, /OpenCode\/Codex/, 'help must not list Codex as a user-level skill harness') }) }) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 0b64a04..d31183b 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -778,7 +778,7 @@ describe('doctor()', () => { const packageRoot = join(tmpDir, 'pi-package') mkdirSync(join(packageRoot, 'skills', 'ns-test-skill'), { recursive: true }) writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ - name: '@nodesource/pi-plugin', + name: 'nsolid-pi-plugin', pi: { skills: ['./skills'] }, })) writeFileSync(join(packageRoot, 'skills', 'ns-test-skill', 'SKILL.md'), '# ns-test-skill') diff --git a/packages/core/test/integration/plugin-assets.test.ts b/packages/core/test/integration/plugin-assets.test.ts index 3b0bab2..986d3ab 100644 --- a/packages/core/test/integration/plugin-assets.test.ts +++ b/packages/core/test/integration/plugin-assets.test.ts @@ -2,7 +2,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { - cpSync, existsSync, mkdirSync, mkdtempSync, @@ -17,7 +16,6 @@ import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = join(__dirname, '..', '..', '..', '..') const SYNC_SCRIPT = join(REPO_ROOT, 'scripts', 'sync-plugin-assets.mjs') -const ARTIFACT_SCRIPT = join(REPO_ROOT, 'scripts', 'build-plugin-artifacts.mjs') function makeWorkspaceRoot (): string { return mkdtempSync(join(tmpdir(), 'plugin-assets-test-')) @@ -51,15 +49,11 @@ function writeBundle (root: string, skills: string[], mcpServers: string[]): voi } function writeSkill (root: string, name: string, content: string): void { - const skillDir = join(root, 'packages', 'core', 'skills', name) + const skillDir = join(root, 'skills', name) mkdirSync(skillDir, { recursive: true }) writeFileSync(join(skillDir, 'SKILL.md'), content) } -function copyTemplates (root: string): void { - cpSync(join(REPO_ROOT, 'plugins'), join(root, 'plugins'), { recursive: true }) -} - function runSync (root: string, args: string[] = []): ReturnType { return spawnSync(process.execPath, [SYNC_SCRIPT, ...args], { cwd: root, @@ -68,23 +62,11 @@ function runSync (root: string, args: string[] = []): ReturnType { - return spawnSync(process.execPath, [ARTIFACT_SCRIPT, ...args], { - cwd: root, - env: { ...process.env, NSOLID_PLUGIN_ARTIFACTS_ROOT: root }, - encoding: 'utf-8', - }) -} - -function readJson (path: string): Record { - return JSON.parse(readFileSync(path, 'utf8')) as Record -} - function outputText (output: string | Buffer | null | undefined): string { return typeof output === 'string' ? output : output?.toString('utf8') ?? '' } -describe('plugin source hygiene and artifact generation', () => { +describe('plugin source hygiene', () => { let root: string beforeEach(() => { @@ -102,9 +84,10 @@ describe('plugin source hygiene and artifact generation', () => { const materialize = runSync(root, ['--materialize-skills']) assert.strictEqual(materialize.status, 0, outputText(materialize.stderr)) - const expected = readFileSync(join(root, 'packages', 'core', 'skills', 'ns-alpha', 'SKILL.md')) + const expected = readFileSync(join(root, 'skills', 'ns-alpha', 'SKILL.md')) const actual = readFileSync(join(root, 'packages', 'pi-plugin', 'skills', 'ns-alpha', 'SKILL.md')) assert.deepStrictEqual(actual, expected) + assert.strictEqual(existsSync(join(root, 'packages', 'core', 'skills')), false) assert.strictEqual(existsSync(join(root, 'packages', 'claude-plugin', 'skills')), false) assert.strictEqual(existsSync(join(root, 'packages', 'codex-plugin', 'skills')), false) assert.strictEqual(existsSync(join(root, 'packages', 'antigravity-plugin', 'skills')), false) @@ -121,66 +104,11 @@ describe('plugin source hygiene and artifact generation', () => { assert.strictEqual(check.status, 0, outputText(check.stderr)) }) - it('fails check when a bundle skill is missing from core skills', () => { + it('fails check when a bundle skill is missing from root skills', () => { writeBundle(root, ['ns-missing'], ['ns-one']) const check = runSync(root, ['--check']) - assert.notStrictEqual(check.status, 0, 'check should fail when core skill is missing') + assert.notStrictEqual(check.status, 0, 'check should fail when root skill is missing') assert.match(outputText(check.stderr), /Missing core skill directory/) }) - - it('builds self-contained Claude, Codex, and Antigravity artifacts', () => { - writeSkill(root, 'ns-alpha', '# alpha') - writeSkill(root, 'ns-beta', '# beta') - writeBundle(root, ['ns-alpha', 'ns-beta'], ['ns-one', 'ns-two']) - copyTemplates(root) - - const result = runArtifacts(root) - assert.strictEqual(result.status, 0, outputText(result.stderr)) - - for (const harness of ['claude', 'codex', 'antigravity']) { - const artifactRoot = join(root, 'dist', 'plugins', harness, 'nsolid-plugin') - assert.ok(existsSync(artifactRoot), `${harness} artifact root exists`) - assert.ok(existsSync(join(artifactRoot, 'skills', 'ns-alpha', 'SKILL.md')), `${harness} skill alpha exists`) - assert.ok(existsSync(join(artifactRoot, 'skills', 'ns-beta', 'SKILL.md')), `${harness} skill beta exists`) - } - - const claudeManifest = readJson(join(root, 'dist', 'plugins', 'claude', 'nsolid-plugin', '.claude-plugin', 'plugin.json')) - assert.deepStrictEqual(claudeManifest.skills, ['./skills/ns-alpha', './skills/ns-beta']) - assert.strictEqual(claudeManifest.hooks, undefined) - assert.strictEqual(existsSync(join(root, 'dist', 'plugins', 'claude', 'nsolid-plugin', 'hooks')), false) - const claudeMarketplace = readJson(join(root, 'dist', 'plugins', 'claude', 'nsolid-plugin', '.claude-plugin', 'marketplace.json')) - assert.strictEqual(claudeMarketplace.name, 'nodesource-local') - assert.deepStrictEqual(claudeMarketplace.plugins, [{ - name: 'nsolid-plugin', - source: './', - description: 'N|Solid performance & security skills + MCP servers', - }]) - - const codexManifest = readJson(join(root, 'dist', 'plugins', 'codex', 'nsolid-plugin', '.codex-plugin', 'plugin.json')) - assert.strictEqual(codexManifest.skills, './skills/') - assert.strictEqual(codexManifest.mcpServers, './.mcp.json') - const codexMcp = readJson(join(root, 'dist', 'plugins', 'codex', 'nsolid-plugin', '.mcp.json')) - assert.match(JSON.stringify(codexMcp), /\.codex.*plugins.*cache/) - assert.doesNotMatch(JSON.stringify(codexMcp), /PLUGIN_ROOT/) - assert.ok(existsSync(join(root, 'dist', 'plugins', 'codex', 'nsolid-plugin', 'plugins', 'nsolid-plugin', 'skills', 'ns-alpha', 'SKILL.md'))) - - assert.strictEqual(codexManifest.hooks, undefined) - assert.strictEqual(existsSync(join(root, 'dist', 'plugins', 'codex', 'nsolid-plugin', 'hooks')), false) - - const antigravityInstall = readFileSync(join(root, 'dist', 'plugins', 'antigravity', 'nsolid-plugin', 'scripts', 'install.js'), 'utf8') - assert.doesNotMatch(antigravityInstall, /login|openBrowser|ensureAuthenticated|open\(/i) - assert.match(antigravityInstall, /nsolid-plugin setup/) - assert.doesNotMatch(antigravityInstall, /'hooks\.json'/) - assert.match(antigravityInstall, /'skills'/) - assert.strictEqual(existsSync(join(root, 'dist', 'plugins', 'antigravity', 'nsolid-plugin', 'hooks.json')), false) - assert.strictEqual(existsSync(join(root, 'dist', 'plugins', 'antigravity', 'nsolid-plugin', 'scripts', 'setup.js')), false) - - assert.ok(existsSync(join(root, 'dist', 'artifacts', 'nsolid-claude-plugin.tgz'))) - assert.ok(existsSync(join(root, 'dist', 'artifacts', 'nsolid-codex-plugin.tgz'))) - assert.ok(existsSync(join(root, 'dist', 'artifacts', 'nsolid-antigravity-plugin.tgz'))) - - const check = runArtifacts(root, ['--check']) - assert.strictEqual(check.status, 0, outputText(check.stderr)) - }) }) diff --git a/packages/core/test/unit/skills/fetch-asset.test.ts b/packages/core/test/unit/skills/fetch-asset.test.ts index 3b428f6..3a738ac 100644 --- a/packages/core/test/unit/skills/fetch-asset.test.ts +++ b/packages/core/test/unit/skills/fetch-asset.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) -const fetchAssetPath = join(__dirname, '../../../skills/_shared/fetch-asset.cjs') +const fetchAssetPath = join(__dirname, '../../../../../skill-assets/fetch-asset.cjs') let originalAllowInsecure: string | undefined diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 4c67495..ea24f77 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -5,7 +5,7 @@ nsolid-plugin — N|Solid performance & security skills for Node.js. ## Install ```bash -pi install npm:@nodesource/pi-plugin +pi install npm:nsolid-pi-plugin nsolid-plugin setup --harness pi pi install npm:pi-mcp-adapter ``` diff --git a/packages/pi-plugin/index.js b/packages/pi-plugin/index.js index f70c88e..8353bad 100644 --- a/packages/pi-plugin/index.js +++ b/packages/pi-plugin/index.js @@ -4,7 +4,7 @@ export default async function nodesourcePiPlugin () { return { - name: 'nsolid-plugin', + name: 'nsolid-pi-plugin', skills: 'package-owned', setup: 'nsolid-plugin setup --harness pi', } diff --git a/packages/pi-plugin/package.json b/packages/pi-plugin/package.json index c819e21..0284ce3 100644 --- a/packages/pi-plugin/package.json +++ b/packages/pi-plugin/package.json @@ -1,7 +1,6 @@ { - "name": "@nodesource/pi-plugin", - "version": "0.1.0", - "private": true, + "name": "nsolid-pi-plugin", + "version": "1.0.0-next.0", "type": "module", "description": "N|Solid Plugin for Pi Agent", "keywords": [ @@ -17,7 +16,15 @@ }, "license": "MIT", "dependencies": { - "@nodesource/plugin-core": "workspace:*" + "nsolid-plugin": "workspace:*" + }, + "files": [ + "index.js", + "skills/" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" }, "scripts": { "lint": "eslint index.js test/", diff --git a/packages/pi-plugin/test/manifest.test.ts b/packages/pi-plugin/test/manifest.test.ts index 7c71a38..eaf4048 100644 --- a/packages/pi-plugin/test/manifest.test.ts +++ b/packages/pi-plugin/test/manifest.test.ts @@ -7,7 +7,7 @@ describe('Pi plugin', () => { const pkg = JSON.parse(readFileSync(path.resolve('packages/pi-plugin/package.json'), 'utf8')) it('keeps the npm package name and uses N|Solid Plugin in description', () => { - assert.strictEqual(pkg.name, '@nodesource/pi-plugin') + assert.strictEqual(pkg.name, 'nsolid-pi-plugin') assert.match(pkg.description, /N\|Solid Plugin/) }) @@ -22,17 +22,18 @@ describe('Pi plugin', () => { assert.ok(pkg.pi.skills.includes('./skills')) }) - it('depends on plugin-core', () => { - assert.ok(pkg.dependencies?.['@nodesource/plugin-core']) + it('depends on nsolid-plugin (core)', () => { + assert.ok(pkg.dependencies?.['nsolid-plugin']) }) - it('is private', () => { - assert.strictEqual(pkg.private, true) + it('is publishable (not private)', () => { + assert.notStrictEqual(pkg.private, true) }) - it('uses canonical core skills instead of committed package skill copies', () => { + it('uses canonical root skills instead of committed package skill copies', () => { assert.strictEqual(existsSync(path.resolve('packages/pi-plugin/skills')), false, 'source tree should not keep generated package skill copies') - assert.ok(existsSync(path.resolve('packages/core/skills/ns-analyze-cpu/SKILL.md')), 'canonical core skill must exist') + assert.strictEqual(existsSync(path.resolve('packages/core/skills')), false, 'source tree should not keep generated core package skill copies') + assert.ok(existsSync(path.resolve('skills/ns-analyze-cpu/SKILL.md')), 'canonical root skill must exist') }) it('index.js exists and has no install/setup side effects', () => { @@ -40,7 +41,7 @@ describe('Pi plugin', () => { assert.ok(existsSync(indexPath)) const source = readFileSync(indexPath, 'utf8') assert.match(source, /side-effect free/) - assert.doesNotMatch(source, /@nodesource\/plugin-core/) + assert.doesNotMatch(source, /require\(|import\(/) assert.doesNotMatch(source, /install\(|setup\(/) }) }) diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..ec47289 --- /dev/null +++ b/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "nsolid-plugin", + "description": "N|Solid performance & security skills + MCP servers" +} diff --git a/plugins/templates/antigravity/README.md b/plugins/templates/antigravity/README.md deleted file mode 100644 index 24b69df..0000000 --- a/plugins/templates/antigravity/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# N|Solid Plugin for Antigravity CLI - -Local plugin artifact for Antigravity CLI. - -Install the plugin from this artifact: - -```bash -agy plugin install ./dist/plugins/antigravity/nsolid-plugin -``` - -Then authenticate with: - -```bash -nsolid-plugin setup --harness antigravity -``` diff --git a/plugins/templates/antigravity/package.json b/plugins/templates/antigravity/package.json deleted file mode 100644 index 11051c9..0000000 --- a/plugins/templates/antigravity/package.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "name": "@nodesource/antigravity-plugin", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "N|Solid Plugin for Antigravity CLI", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.14", - "@modelcontextprotocol/sdk": "^1.25.3", - "@nodesource/plugin-core": "workspace:*", - "accepts": "^2.0.0", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "array-flatten": "^1.1.1", - "body-parser": "^2.3.0", - "bundle-name": "^4.1.0", - "bytes": "^3.1.2", - "call-bind-apply-helpers": "^1.0.2", - "call-bound": "^1.0.4", - "content-disposition": "^1.1.0", - "content-type": "^1.0.5", - "cookie": "^0.7.2", - "cookie-signature": "^1.2.2", - "cors": "^2.8.6", - "cross-spawn": "^7.0.6", - "debug": "^4.4.3", - "default-browser": "^5.5.0", - "default-browser-id": "^5.0.1", - "define-lazy-prop": "^3.0.0", - "depd": "^2.0.0", - "destroy": "^1.2.0", - "dunder-proto": "^1.0.1", - "ee-first": "^1.1.1", - "encodeurl": "^2.0.0", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.2", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "eventsource": "^3.0.7", - "eventsource-parser": "^3.1.0", - "express": "^5.2.1", - "express-rate-limit": "^7.5.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.1.2", - "finalhandler": "^2.1.1", - "forwarded": "^0.2.0", - "fresh": "^2.0.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.4", - "hono": "^4.12.26", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "inherits": "^2.0.4", - "ipaddr.js": "^1.9.1", - "is-docker": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-promise": "^4.0.0", - "is-wsl": "^3.1.1", - "isexe": "^2.0.0", - "jose": "^6.2.3", - "json-schema-traverse": "^1.0.0", - "json-schema-typed": "^8.0.2", - "math-intrinsics": "^1.1.0", - "mcp-remote": "0.1.38", - "media-typer": "^1.1.0", - "merge-descriptors": "^2.0.0", - "methods": "^1.1.1", - "mime": "^1.6.0", - "mime-db": "^1.54.0", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "negotiator": "^1.0.0", - "object-assign": "^4.1.1", - "object-inspect": "^1.13.4", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "open": "^10.2.0", - "parseurl": "^1.3.3", - "path-key": "^3.1.1", - "path-to-regexp": "^8.4.2", - "pkce-challenge": "^5.0.1", - "proxy-addr": "^2.0.7", - "qs": "^6.15.2", - "range-parser": "^1.2.1", - "raw-body": "^3.0.2", - "require-from-string": "^2.0.2", - "router": "^2.2.0", - "run-applescript": "^7.1.0", - "safe-buffer": "^5.2.1", - "safer-buffer": "^2.1.2", - "send": "^1.2.1", - "serve-static": "^2.2.1", - "setprototypeof": "^1.1.2", - "shebang-command": "^2.0.0", - "shebang-regex": "^3.0.1", - "side-channel": "^1.1.1", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2", - "statuses": "^2.0.2", - "strict-url-sanitise": "^0.0.1", - "toidentifier": "^1.0.1", - "type-is": "^2.1.0", - "undici": "^7.28.0", - "unpipe": "^1.0.0", - "utils-merge": "^1.1.2", - "vary": "^1.1.2", - "which": "^2.0.2", - "wrappy": "^1.0.2", - "wsl-utils": "^0.0.0", - "zod": "^4.4.3", - "zod-to-json-schema": "^3.25.2" - } -} diff --git a/plugins/templates/antigravity/scripts/install.js b/plugins/templates/antigravity/scripts/install.js deleted file mode 100644 index 0c55bce..0000000 --- a/plugins/templates/antigravity/scripts/install.js +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node - -import { - cpSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, -} from 'node:fs' -import { spawnSync } from 'node:child_process' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { homedir } from 'node:os' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const pluginDir = resolve(__dirname, '..') -const targetDir = join(homedir(), '.gemini', 'antigravity-cli', 'plugins', 'nsolid-plugin') - -const PLUGIN_OWNED_ASSETS = ['plugin.json', 'mcp_config.json', 'scripts', 'skills'] - -console.log(`Installing NodeSource plugin to ${targetDir}...`) - -// Clean and recreate the target plugin directory so stale assets are removed. -if (existsSync(targetDir)) { - for (const entry of readdirSync(targetDir)) { - rmSync(join(targetDir, entry), { recursive: true, force: true }) - } -} -mkdirSync(targetDir, { recursive: true }) - -// Copy plugin-owned assets from this package. -for (const asset of PLUGIN_OWNED_ASSETS) { - const src = join(pluginDir, asset) - const dst = join(targetDir, asset) - if (!existsSync(src)) { - console.error(` missing required asset: ${asset}`) - process.exit(1) - } - cpSync(src, dst, { recursive: true, dereference: true }) - console.log(` copied ${asset}`) -} - -// Skills are copied from this self-contained generated artifact. Do not resolve -// or copy from @nodesource/plugin-core here; native install must be offline and -// non-interactive apart from dependency installation. - -// Install runtime dependencies so the MCP wrapper can resolve mcp-remote after -// the plugin is installed in Antigravity's plugin directory. mcp-remote 0.1.38 -// imports a few packages from its dist bundle that are not declared as runtime -// dependencies, so install the package plus explicit runtime companions into a -// regular node_modules tree instead of relying on pnpm workspace symlinks. -installMcpRuntimeDependencies(pluginDir, targetDir) - -console.log('') -console.log(`Installed NodeSource plugin to ${targetDir}`) -console.log('Restart Antigravity to load the plugin-owned skills and MCPs.') -console.log('') -console.log('To authenticate with NodeSource, run:') -console.log(' nsolid-plugin setup --harness antigravity') - -function installMcpRuntimeDependencies (pluginDir, targetDir) { - const pkg = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')) - const runtimePackages = [ - 'mcp-remote', - 'express', - 'open', - 'strict-url-sanitise', - 'undici', - '@modelcontextprotocol/sdk', - 'ajv', - 'ajv-formats', - ] - - const installSpecs = [] - for (const name of runtimePackages) { - const version = pkg.dependencies?.[name] - if (!version) { - console.warn(` ${name} not declared in package.json; wrapper may not resolve dependencies`) - continue - } - installSpecs.push(`${name}@${version}`) - } - - const result = spawnSync( - 'npm', - ['install', '--no-package-lock', '--no-audit', '--no-fund', ...installSpecs], - { cwd: targetDir, encoding: 'utf-8' } - ) - - if (result.status !== 0) { - console.error(' npm install MCP runtime dependencies failed') - if (result.stderr) console.error(result.stderr.trim()) - process.exit(1) - } - - console.log(' installed MCP runtime dependencies') -} - diff --git a/plugins/templates/claude/README.md b/plugins/templates/claude/README.md deleted file mode 100644 index 799e6ba..0000000 --- a/plugins/templates/claude/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# N|Solid Plugin for Claude Code - -Marketplace/local plugin artifact for Claude Code. - -Install the plugin through Claude's plugin system, then authenticate with: - -```bash -nsolid-plugin setup --harness claude -``` diff --git a/plugins/templates/claude/package.json b/plugins/templates/claude/package.json deleted file mode 100644 index 1f2f930..0000000 --- a/plugins/templates/claude/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@nodesource/claude-plugin", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "N|Solid Plugin for Claude Code", - "license": "MIT", - "dependencies": { - "mcp-remote": "0.1.38" - } -} diff --git a/plugins/templates/codex/README.md b/plugins/templates/codex/README.md deleted file mode 100644 index e465a5a..0000000 --- a/plugins/templates/codex/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# N|Solid Plugin for Codex CLI - -Marketplace/local plugin artifact for Codex CLI. - -Install the plugin through Codex's plugin system, then authenticate with: - -```bash -nsolid-plugin setup --harness codex -``` diff --git a/plugins/templates/codex/package.json b/plugins/templates/codex/package.json deleted file mode 100644 index 72b1be0..0000000 --- a/plugins/templates/codex/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@nodesource/codex-plugin", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "N|Solid Plugin for Codex CLI", - "license": "MIT", - "dependencies": { - "@nodesource/plugin-core": "workspace:*", - "mcp-remote": "0.1.38" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3543568..88b99d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: packages/pi-plugin: dependencies: - '@nodesource/plugin-core': + nsolid-plugin: specifier: workspace:* version: link:../core diff --git a/scripts/build-plugin-artifacts.mjs b/scripts/build-plugin-artifacts.mjs deleted file mode 100644 index 980eee5..0000000 --- a/scripts/build-plugin-artifacts.mjs +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env node -/** - * Build generated plugin artifacts for marketplace/local install flows. - * - * Source of truth: - * - bundle.json - * - packages/core/skills/ - * - plugins/templates/ - * - * Output: - * dist/plugins//nsolid-plugin/ - * dist/artifacts/nsolid--plugin.tgz - * - * Usage: - * node scripts/build-plugin-artifacts.mjs - * node scripts/build-plugin-artifacts.mjs --check - * node scripts/build-plugin-artifacts.mjs --harness antigravity - * node scripts/build-plugin-artifacts.mjs --no-archive - */ - -import { - cpSync, - existsSync, - mkdirSync, - readdirSync, - rmSync, - statSync, - readFileSync, - writeFileSync, - renameSync, -} from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawnSync } from 'node:child_process' -import { - loadBundle, - generateClaudePluginJson, - generateClaudeMarketplaceJson, - generateClaudeMcpJson, - generateClaudeWrapper, - generateAntigravityPluginJson, - generateAntigravityMcpJson, - generateAntigravityWrapper, - generateCodexPluginJson, - generateCodexMarketplaceJson, - generateCodexMcpJson, - generateCodexWrapper, - readPluginPkgVersion, -} from './plugin-generators.mjs' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT = process.env.NSOLID_PLUGIN_ARTIFACTS_ROOT - ? path.resolve(process.env.NSOLID_PLUGIN_ARTIFACTS_ROOT) - : path.resolve(__dirname, '..') - -const CHECK_MODE = process.argv.includes('--check') -const NO_ARCHIVE = process.argv.includes('--no-archive') -const HARNESS_ARG = process.argv.find((arg, i) => i > 0 && process.argv[i - 1] === '--harness') - -const CORE_SKILLS_DIR = path.join(ROOT, 'packages', 'core', 'skills') -const TEMPLATES_DIR = path.join(ROOT, 'plugins', 'templates') -const DIST_DIR = path.join(ROOT, 'dist') -const PLUGINS_DIST_DIR = path.join(DIST_DIR, 'plugins') -const ARTIFACTS_DIR = path.join(DIST_DIR, 'artifacts') - -const bundle = loadBundle(ROOT) -const skillNames = bundle.skills.map((skill) => skill.name) -const skillNamesSet = new Set(skillNames) - -const HARNESSES = [ - { - id: 'claude', - artifactName: 'nsolid-claude-plugin', - templateDir: path.join(TEMPLATES_DIR, 'claude'), - generatedFiles: { - '.claude-plugin/plugin.json': () => generateClaudePluginJson(readPluginPkgVersion(path.join(TEMPLATES_DIR, 'claude')), bundle), - '.claude-plugin/marketplace.json': () => generateClaudeMarketplaceJson(bundle), - '.mcp.json': () => generateClaudeMcpJson(bundle), - 'scripts/mcp-wrapper.js': generateClaudeWrapper, - }, - }, - { - id: 'antigravity', - artifactName: 'nsolid-antigravity-plugin', - templateDir: path.join(TEMPLATES_DIR, 'antigravity'), - generatedFiles: { - 'plugin.json': () => generateAntigravityPluginJson(bundle), - 'mcp_config.json': () => generateAntigravityMcpJson(bundle), - 'scripts/mcp-wrapper.js': generateAntigravityWrapper, - }, - }, - { - id: 'codex', - artifactName: 'nsolid-codex-plugin', - templateDir: path.join(TEMPLATES_DIR, 'codex'), - generatedFiles: { - '.agents/plugins/marketplace.json': () => generateCodexMarketplaceJson(bundle), - '.codex-plugin/plugin.json': () => generateCodexPluginJson(readPluginPkgVersion(path.join(TEMPLATES_DIR, 'codex')), bundle), - '.mcp.json': () => generateCodexMcpJson(bundle), - 'scripts/mcp-wrapper.js': generateCodexWrapper, - }, - nestedPlugin: { - dir: path.join('plugins', 'nsolid-plugin'), - generatedFiles: { - '.codex-plugin/plugin.json': () => generateCodexPluginJson(readPluginPkgVersion(path.join(TEMPLATES_DIR, 'codex')), bundle), - '.mcp.json': () => generateCodexMcpJson(bundle), - 'scripts/mcp-wrapper.js': generateCodexWrapper, - }, - }, - }, -] - -if (HARNESS_ARG && !HARNESSES.some((h) => h.id === HARNESS_ARG)) { - console.error(`Invalid harness: ${HARNESS_ARG}. Valid options: ${HARNESSES.map((h) => h.id).join(', ')}`) - process.exit(1) -} - -const harnessesToBuild = HARNESS_ARG - ? HARNESSES.filter((h) => h.id === HARNESS_ARG) - : HARNESSES - -let driftDetected = false - -for (const harness of harnessesToBuild) { - const artifactDir = path.join(PLUGINS_DIST_DIR, harness.id, 'nsolid-plugin') - - if (!CHECK_MODE) { - if (existsSync(artifactDir)) { - rmSync(artifactDir, { recursive: true, force: true }) - } - mkdirSync(artifactDir, { recursive: true }) - } - - // Copy template tree. - const templateDrift = copyTemplate(harness.templateDir, artifactDir, CHECK_MODE) - if (templateDrift) driftDetected = true - - // Generate harness-specific files. - const fileDrift = syncGeneratedFiles(harness.generatedFiles, artifactDir, CHECK_MODE) - if (fileDrift) driftDetected = true - - // Materialize skills. - const skillsDrift = materializeSkills(artifactDir, CHECK_MODE) - if (skillsDrift) driftDetected = true - - // Generate nested Codex marketplace plugin if configured. - if (harness.nestedPlugin) { - const nestedDir = path.join(artifactDir, harness.nestedPlugin.dir) - const nestedFileDrift = syncGeneratedFiles(harness.nestedPlugin.generatedFiles, nestedDir, CHECK_MODE) - if (nestedFileDrift) driftDetected = true - - const nestedSkillsDrift = materializeSkills(nestedDir, CHECK_MODE) - if (nestedSkillsDrift) driftDetected = true - } - - // Build archive. - if (!CHECK_MODE && !NO_ARCHIVE) { - buildArchive(artifactDir, harness.artifactName) - } -} - -if (driftDetected) { - if (CHECK_MODE) { - console.error('plugin:artifacts:check failed: generated plugin artifacts are out of sync.') - process.exit(1) - } - console.log('plugin:artifacts completed.') -} else { - console.log('plugin:artifacts up to date.') -} - -function copyTemplate (src, dst, checkMode) { - if (!existsSync(src)) { - if (checkMode) { - console.error(`Missing template dir: ${path.relative(ROOT, src)}`) - return true - } - throw new Error(`Missing template dir: ${src}`) - } - - if (checkMode) { - // In check mode verify every template file is present and byte-identical in - // the artifact. The artifact also contains generated files and skills, so - // the full directory will intentionally have extra entries. - if (!existsSync(dst)) { - console.error(`Missing artifact dir: ${path.relative(ROOT, dst)}`) - return true - } - const drift = !templateSubsetEquals(src, dst) - if (drift) console.error(`Template drift detected: ${path.relative(ROOT, dst)}`) - return drift - } - - cpSync(src, dst, { recursive: true, dereference: true }) - return false -} - -function syncGeneratedFiles (generatedFiles, targetDir, checkMode) { - let drift = false - for (const [relPath, generator] of Object.entries(generatedFiles)) { - const targetPath = path.join(targetDir, relPath) - const expected = generator() - const actual = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null - if (actual !== expected) { - drift = true - if (checkMode) { - console.error(`Drift detected: ${path.relative(ROOT, targetPath)}`) - } else { - mkdirSync(path.dirname(targetPath), { recursive: true }) - writeFileSync(targetPath, expected, 'utf8') - } - } - } - return drift -} - -function materializeSkills (targetDir, checkMode) { - const destSkillsDir = path.join(targetDir, 'skills') - let drift = false - - if (existsSync(destSkillsDir)) { - for (const entry of readdirSync(destSkillsDir)) { - const entryPath = path.join(destSkillsDir, entry) - const stat = statSync(entryPath) - if (stat.isDirectory() && !skillNamesSet.has(entry)) { - drift = true - if (checkMode) { - console.error(`Stale skill dir: ${path.relative(ROOT, entryPath)}`) - } else { - rmSync(entryPath, { recursive: true, force: true }) - } - } - } - } - - for (const skillName of skillNames) { - const srcDir = path.join(CORE_SKILLS_DIR, skillName) - const dstDir = path.join(destSkillsDir, skillName) - - if (!directoryEquals(srcDir, dstDir)) { - drift = true - if (checkMode) { - console.error(`Drift detected: ${path.relative(ROOT, dstDir)}`) - } else { - mkdirSync(destSkillsDir, { recursive: true }) - rmSync(dstDir, { recursive: true, force: true }) - cpSync(srcDir, dstDir, { recursive: true, dereference: true }) - } - } - } - - return drift -} - -function templateSubsetEquals (src, dst) { - if (!existsSync(dst)) return false - - for (const entry of readdirSync(src)) { - const srcPath = path.join(src, entry) - const dstPath = path.join(dst, entry) - if (!existsSync(dstPath)) return false - - const srcStat = statSync(srcPath) - const dstStat = statSync(dstPath) - if (srcStat.isDirectory() !== dstStat.isDirectory()) return false - - if (srcStat.isDirectory()) { - if (!templateSubsetEquals(srcPath, dstPath)) return false - } else { - const srcBuf = readFileSync(srcPath) - const dstBuf = readFileSync(dstPath) - if (Buffer.compare(srcBuf, dstBuf) !== 0) return false - } - } - - return true -} - -function directoryEquals (src, dst) { - if (!existsSync(dst)) return false - - const srcEntries = readdirSync(src).sort() - const dstEntries = readdirSync(dst).sort() - - if (srcEntries.length !== dstEntries.length) return false - - for (let i = 0; i < srcEntries.length; i++) { - if (srcEntries[i] !== dstEntries[i]) return false - - const srcPath = path.join(src, srcEntries[i]) - const dstPath = path.join(dst, dstEntries[i]) - const srcStat = statSync(srcPath) - const dstStat = statSync(dstPath) - - if (srcStat.isDirectory() !== dstStat.isDirectory()) return false - - if (srcStat.isDirectory()) { - if (!directoryEquals(srcPath, dstPath)) return false - } else { - const srcBuf = readFileSync(srcPath) - const dstBuf = readFileSync(dstPath) - if (Buffer.compare(srcBuf, dstBuf) !== 0) return false - } - } - - return true -} - -function buildArchive (artifactDir, artifactName) { - mkdirSync(ARTIFACTS_DIR, { recursive: true }) - const result = spawnSync( - 'npm', - ['pack', artifactDir, '--pack-destination', ARTIFACTS_DIR], - { encoding: 'utf-8', stdio: 'pipe', shell: true } - ) - - if (result.status !== 0) { - throw new Error(`npm pack failed for ${artifactName}: ${result.stderr}`) - } - - // npm pack produces a file named like @nodesource-claude-plugin-0.1.0.tgz. - // Rename it to the canonical artifact name. - const pkg = JSON.parse(readFileSync(path.join(artifactDir, 'package.json'), 'utf8')) - const producedName = pkg.name.replace('@', '').replace('/', '-') + `-${pkg.version}.tgz` - const producedPath = path.join(ARTIFACTS_DIR, producedName) - const targetPath = path.join(ARTIFACTS_DIR, `${artifactName}.tgz`) - - if (!existsSync(producedPath)) { - throw new Error(`npm pack produced no tarball at expected path: ${producedPath}`) - } - if (existsSync(targetPath)) rmSync(targetPath) - renameSync(producedPath, targetPath) - console.log(`Wrote ${path.relative(ROOT, targetPath)}`) -} diff --git a/scripts/materialize-github-marketplace.mjs b/scripts/materialize-github-marketplace.mjs new file mode 100644 index 0000000..0bf67ec --- /dev/null +++ b/scripts/materialize-github-marketplace.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Materialize (or check) the GitHub-installable root marketplace/plugin layout. + * + * The repository root is intentionally both: + * - a Claude marketplace root (.claude-plugin/marketplace.json) + * - a Codex marketplace root (.agents/plugins/marketplace.json) + * - an Antigravity plugin root (plugin.json) + * + * This mirrors repos like addyosmani/agent-skills and lets the same GitHub URL + * work across all three harnesses. + * + * Generated output at repo root: + * .claude-plugin/marketplace.json + * .claude-plugin/plugin.json + * .agents/plugins/marketplace.json + * .codex-plugin/plugin.json + * .claude-mcp.json + * .mcp.json + * plugin.json + * mcp_config.json + * scripts/mcp-wrapper.js + * + * Canonical skill source (not generated here): + * skills/ + * + * Usage: + * node scripts/materialize-github-marketplace.mjs # write root manifests + * node scripts/materialize-github-marketplace.mjs --check # fail if committed manifests drift from bundle.json + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + generateAntigravityMcpJson, + generateAntigravityPluginJson, + generateClaudeMcpJson, + generateClaudePluginJson, + generateClaudeWrapper, + generateCodexMcpJson, + generateCodexPluginJson, + loadBundle, + stableJson, +} from './plugin-generators.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = process.env.NSOLID_PLUGIN_MARKETPLACE_ROOT + ? path.resolve(process.env.NSOLID_PLUGIN_MARKETPLACE_ROOT) + : path.resolve(__dirname, '..') + +const MARKETPLACE_NAME = process.env.NSOLID_PLUGIN_MARKETPLACE_NAME ?? 'nodesource' +const SKILLS_DIR = path.join(ROOT, 'skills') +const CHECK_MODE = process.argv.includes('--check') + +const bundle = loadBundle(ROOT) + +validateCanonicalSkills() + +const expectedFiles = buildExpectedFiles() +const drifted = CHECK_MODE ? checkFiles(expectedFiles) : writeFiles(expectedFiles) + +if (CHECK_MODE) { + if (drifted.length > 0) { + console.error('plugin:root:check FAILED — root manifests are out of sync with bundle.json.') + for (const rel of drifted) console.error(` DRIFT: ${rel}`) + console.error('Run "pnpm plugin:root" to regenerate, then commit the result.') + process.exit(1) + } + console.log('plugin:root:check OK — root manifests match bundle.json.') +} else { + console.log(`Materialized GitHub marketplace/plugin layout at repository root (${expectedFiles.size} files).`) +} + +function buildExpectedFiles () { + const files = new Map() + + files.set('.claude-plugin/marketplace.json', stableJson({ + name: MARKETPLACE_NAME, + owner: { name: 'NodeSource' }, + description: 'NodeSource agent plugins', + plugins: [ + { + name: bundle.name, + source: './', + displayName: 'N|Solid Plugin', + version: bundle.version, + description: 'N|Solid performance & security skills + MCP servers', + author: { name: 'NodeSource' }, + homepage: 'https://nodesource.com', + repository: 'https://github.com/NodeSource/nsolid-plugin', + license: 'MIT', + category: 'developer-tools', + tags: ['nodesource', 'nsolid', 'nodejs', 'performance', 'security'], + }, + ], + })) + + files.set('.agents/plugins/marketplace.json', stableJson({ + name: MARKETPLACE_NAME, + interface: { + displayName: 'NodeSource', + }, + plugins: [ + { + name: bundle.name, + source: { + source: 'local', + path: './', + }, + policy: { + installation: 'AVAILABLE', + authentication: 'ON_USE', + products: ['CODEX'], + }, + category: 'Developer Tools', + }, + ], + })) + + const claudeManifest = JSON.parse(generateClaudePluginJson(bundle.version, bundle)) + claudeManifest.mcpServers = './.claude-mcp.json' + files.set('.claude-plugin/plugin.json', stableJson(claudeManifest)) + + const codexManifest = JSON.parse(generateCodexPluginJson(bundle.version, bundle)) + codexManifest.mcpServers = './.mcp.json' + files.set('.codex-plugin/plugin.json', stableJson(codexManifest)) + + files.set('.claude-mcp.json', generateClaudeMcpJson(bundle)) + files.set('.mcp.json', generateCodexMcpJson(bundle)) + files.set('plugin.json', generateAntigravityPluginJson(bundle)) + files.set('mcp_config.json', generateAntigravityMcpJson(bundle)) + files.set('scripts/mcp-wrapper.js', generateSharedWrapper()) + + return files +} + +function validateCanonicalSkills () { + for (const skill of bundle.skills) { + const skillPath = path.join(SKILLS_DIR, skill.name, 'SKILL.md') + if (!existsSync(skillPath)) { + throw new Error(`Missing canonical skill: ${path.relative(ROOT, skillPath)}`) + } + } +} + +function generateSharedWrapper () { + return generateClaudeWrapper() + .replace( + "const SETUP_COMMAND = 'npx -y @nodesource/nsolid-plugin setup --harness claude'", + "const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness '" + ) +} + +function writeFiles (files) { + for (const [relPath, content] of files) writeFile(relPath, content) + return [] +} + +function checkFiles (files) { + const drifted = [] + for (const [relPath, expected] of files) { + const targetPath = path.join(ROOT, relPath) + let actual = '' + if (existsSync(targetPath)) { + actual = readFileSync(targetPath, 'utf8') + } + if (actual !== expected) drifted.push(relPath) + } + return drifted +} + +function writeFile (relPath, content) { + const targetPath = path.join(ROOT, relPath) + const parent = path.dirname(targetPath) + if (!existsSync(parent)) mkdirSync(parent, { recursive: true }) + writeFileSync(targetPath, content, 'utf8') +} diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js new file mode 100644 index 0000000..fc15719 --- /dev/null +++ b/scripts/mcp-wrapper.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { existsSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') +const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness ' + +const SERVER_NAMES = new Set(["nsolid-console","ns-benchmark","ncm"]) +const serverName = process.argv[2] + +if (!SERVER_NAMES.has(serverName)) { + fail(`Unknown NodeSource MCP server: ${serverName ?? '(missing)'}`) +} + +const credentials = readCredentials() +const server = resolveServer(serverName, credentials) +await runMcpRemote(server.url, server.headers) + +function readCredentials () { + if (!existsSync(AUTH_FILE)) { + fail(`NodeSource credentials not found. Run: ${SETUP_COMMAND}`) + } + + let parsed + try { + parsed = JSON.parse(readFileSync(AUTH_FILE, 'utf8')) + } catch (err) { + fail(`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && ${SETUP_COMMAND}. ${err.message}`) + } + + const required = ['serviceToken', 'organizationId', 'consoleUrl', 'expiresAt'] + const missing = required.filter((key) => typeof parsed?.[key] !== 'string' || parsed[key].length === 0) + if (missing.length > 0) { + fail(`NodeSource credentials are incomplete (${missing.join(', ')} missing). Run: ${SETUP_COMMAND}`) + } + + const expiresAt = Date.parse(parsed.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + fail(`NodeSource credentials are expired. Run: ${SETUP_COMMAND}`) + } + + return parsed +} + +function resolveServer (name, credentials) { + switch (name) { + case 'nsolid-console': { + const derivedUrl = deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) + const url = derivedUrl ?? credentials.mcpUrl + if (!url) { + fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND}`) + } + return { + url, + headers: { + 'X-Nsolid-Service-Token': credentials.serviceToken, + }, + } + } + case 'ns-benchmark': + return { + url: 'https://benchmark.mcp.saas.nodesource.io/mcp', + headers: { + 'X-Nsolid-Org-Id': credentials.organizationId, + 'X-Nsolid-Service-Token': credentials.serviceToken, + }, + } + case 'ncm': + return { + url: 'https://mcp.ncm.nodesource.com', + headers: { + 'X-Nsolid-Service-Token': credentials.serviceToken, + }, + } + default: + fail(`Unknown NodeSource MCP server: ${name}`) + } +} + +function deriveMcpUrlFromConsoleUrl (consoleUrl) { + let parsed + try { + parsed = new URL(consoleUrl) + } catch { + return null + } + + const host = parsed.hostname + let mcpHost = null + + if (host.endsWith('.staging.saas.nodesource.io')) { + mcpHost = host.replace(/\.staging\.saas\.nodesource\.io$/, '.mcp.staging.saas.nodesource.io') + } else if (host.endsWith('.saas.nodesource.io')) { + mcpHost = host.replace(/\.saas\.nodesource\.io$/, '.mcp.saas.nodesource.io') + } + + return mcpHost ? `${parsed.protocol}//${mcpHost}/` : null +} + +async function runMcpRemote (url, headers) { + const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}:${value}`]) + + const require = createRequire(import.meta.url) + try { + const proxyPath = require.resolve('mcp-remote/dist/proxy.js') + process.argv = [process.execPath, proxyPath, url, ...headerArgs, '--transport', 'http-first', '--silent'] + await import(pathToFileURL(proxyPath).href) + return + } catch (err) { + if (err?.code !== 'MODULE_NOT_FOUND' && !String(err?.message ?? '').includes('Cannot find module')) { + throw err + } + } + + const child = spawn('npx', ['-y', 'mcp-remote@0.1.38', url, ...headerArgs, '--transport', 'http-first', '--silent'], { + stdio: 'inherit', + env: process.env, + }) + await new Promise((resolve, reject) => { + child.on('error', reject) + child.on('exit', (code) => code === 0 ? resolve() : reject(new Error('mcp-remote exited with code ' + (code ?? 1)))) + }) +} + +function fail (message) { + console.error(`[nsolid-plugin] ${message}`) + process.exit(1) +} diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index 7932642..dff4ede 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -9,7 +9,7 @@ * the expected file contents as strings. Callers decide where to write them. */ -import { readFileSync, existsSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -43,29 +43,15 @@ export function generateClaudePluginJson (pluginPkgVersion, bundle) { description: 'N|Solid performance & security skills + MCP servers', author: { name: 'NodeSource' }, homepage: 'https://nodesource.com', + repository: 'https://github.com/NodeSource/nsolid-plugin', license: 'MIT', skills: b.skills.map((skill) => `./skills/${skill.name}`), + mcpServers: './.mcp.json', } return stableJson(manifest) } -export function generateClaudeMarketplaceJson (bundle) { - const b = getBundle(bundle) - return stableJson({ - name: 'nodesource-local', - owner: { name: 'NodeSource' }, - description: 'Local NodeSource plugin artifacts', - plugins: [ - { - name: b.name, - source: './', - description: 'N|Solid performance & security skills + MCP servers', - }, - ], - }) -} - export function generateClaudeMcpJson (bundle) { return generateMcpConfig('$' + '{CLAUDE_PLUGIN_ROOT}/scripts/mcp-wrapper.js', bundle) } @@ -95,16 +81,17 @@ export function generateAntigravityMcpJson (bundle) { return stableJson({ mcpServers }) } -export function generateAntigravityWrapper () { - return generateMcpWrapper('antigravity') -} - export function generateCodexPluginJson (pluginPkgVersion, bundle) { const b = getBundle(bundle) return stableJson({ name: b.name, version: pluginPkgVersion ?? b.version, description: 'N|Solid Plugin — AI skills and MCP servers for Codex', + author: { name: 'NodeSource', url: 'https://nodesource.com' }, + homepage: 'https://nodesource.com', + repository: 'https://github.com/NodeSource/nsolid-plugin', + license: 'MIT', + keywords: ['nodesource', 'nsolid', 'nodejs', 'performance', 'security'], skills: './skills/', mcpServers: './.mcp.json', interface: { @@ -116,31 +103,6 @@ export function generateCodexPluginJson (pluginPkgVersion, bundle) { }) } -export function generateCodexMarketplaceJson (bundle) { - const b = getBundle(bundle) - return stableJson({ - name: 'codex-plugin', - interface: { - displayName: 'NodeSource local', - }, - plugins: [ - { - name: b.name, - source: { - source: 'local', - path: './plugins/nsolid-plugin', - }, - policy: { - installation: 'AVAILABLE', - authentication: 'ON_USE', - products: ['CODEX'], - }, - category: 'Developer Tools', - }, - ], - }) -} - export function generateCodexMcpJson (bundle) { const b = getBundle(bundle) const bootstrap = generateCodexBootstrap() @@ -154,16 +116,16 @@ export function generateCodexMcpJson (bundle) { return stableJson({ mcpServers }) } -export function generateCodexWrapper () { - return generateMcpWrapper('codex') -} - export function generateCodexBootstrap () { - return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`))||candidates[0];if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add && codex plugin add nsolid-plugin@codex-plugin');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" + // Fail closed: only trust wrappers positively identified as this plugin's + // install root (a path segment matching `nsolid-plugin`). Never fall back to + // an unrelated discovered scripts/mcp-wrapper.js. + // eslint-disable-next-line no-template-curly-in-string -- codegen: ${path.sep} must stay literal in the generated bootstrap string, it is evaluated at runtime in the host process + return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const roots=[path.join(os.homedir(),'.codex','plugins','cache'),process.cwd()];const candidates=[];for(const root of roots){try{const stack=[root];while(stack.length){const dir=stack.pop();if(!fs.existsSync(dir))continue;const direct=path.join(dir,...rel);if(fs.existsSync(direct))candidates.push(direct);for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(entry.isDirectory())stack.push(path.join(dir,entry.name))}}}catch{}}const wrapper=candidates.find((p)=>p.includes(`${path.sep}nsolid-plugin${path.sep}`));if(!wrapper){console.error('[nsolid-plugin] Could not locate Codex MCP wrapper. Reinstall with: codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" } export function generateAntigravityBootstrap () { - return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install ./dist/plugins/antigravity/nsolid-plugin');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" + return "const fs=require('node:fs');const os=require('node:os');const path=require('node:path');const {pathToFileURL}=require('node:url');const serverName=process.argv[1];const rel=['scripts','mcp-wrapper.js'];const candidates=[path.join(os.homedir(),'.gemini','config','plugins','nsolid-plugin',...rel),path.join(os.homedir(),'.gemini','antigravity-cli','plugins','nsolid-plugin',...rel),path.join(process.cwd(),'packages','antigravity-plugin',...rel),path.join(process.cwd(),...rel)];const wrapper=candidates.find((p)=>fs.existsSync(p));if(!wrapper){console.error('[nsolid-plugin] Could not locate Antigravity MCP wrapper. Reinstall with: agy plugin install https://github.com/NodeSource/nsolid-plugin.git');process.exit(1)}process.argv=[process.execPath,wrapper,serverName];import(pathToFileURL(wrapper).href)" } export function generateMcpConfig (wrapperPath, bundle) { @@ -190,7 +152,7 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SETUP_COMMAND = 'nsolid-plugin setup --harness ${harness}' +const SETUP_COMMAND = 'npx -y @nodesource/nsolid-plugin setup --harness ${harness}' const SERVER_NAMES = new Set(${JSON.stringify(serverNames)}) const serverName = process.argv[2] @@ -212,7 +174,7 @@ function readCredentials () { try { parsed = JSON.parse(readFileSync(AUTH_FILE, 'utf8')) } catch (err) { - fail(\`NodeSource credentials are unreadable. Run: nsolid-plugin logout && \${SETUP_COMMAND}. \${err.message}\`) + fail(\`NodeSource credentials are unreadable. Run: npx -y nsolid-plugin logout && \${SETUP_COMMAND}. \${err.message}\`) } const required = ['serviceToken', 'organizationId', 'consoleUrl', 'expiresAt'] @@ -315,10 +277,3 @@ function fail (message) { } ` } - -export function readPluginPkgVersion (packageDir) { - const pkgPath = path.join(packageDir, 'package.json') - if (!existsSync(pkgPath)) return defaultBundle.version - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) - return pkg.version ?? defaultBundle.version -} diff --git a/scripts/sync-plugin-assets.mjs b/scripts/sync-plugin-assets.mjs index ef0d5a8..287ab33 100644 --- a/scripts/sync-plugin-assets.mjs +++ b/scripts/sync-plugin-assets.mjs @@ -4,13 +4,12 @@ * * Source of truth: * - bundle.json - * - packages/core/skills/ + * - skills/ * * Remaining committed workspace package: * - packages/pi-plugin * - * Generated plugin artifacts (Claude/Codex/Antigravity) are produced by - * scripts/build-plugin-artifacts.mjs instead. + * Claude/Codex/Antigravity install directly from the repository root. * * Usage: * node scripts/sync-plugin-assets.mjs # clean materialized skill copies @@ -38,7 +37,7 @@ const ROOT = process.env.NSOLID_PLUGIN_SYNC_ROOT const CHECK_MODE = process.argv.includes('--check') const MATERIALIZE_SKILLS = process.argv.includes('--materialize-skills') -const CORE_SKILLS_DIR = path.join(ROOT, 'packages', 'core', 'skills') +const CORE_SKILLS_DIR = path.join(ROOT, 'skills') const PI_PLUGIN_DIR = path.join(ROOT, 'packages', 'pi-plugin') const bundle = loadBundle(ROOT) @@ -145,7 +144,10 @@ function materializePiSkills () { function checkSourceHygiene () { let drift = false - // Reject any package-local skills dirs outside the canonical core source. + // Reject any package-local skills dirs in removed/legacy plugin packages. + // NOTE: packages/core is intentionally excluded — it legitimately materializes + // a skills/ dir during prepack (skills:sync). Its presence is governed by + // packages/core/scripts/sync-shared-skill-assets.mjs, not this hygiene check. const packageRoots = [ path.join(ROOT, 'packages', 'claude-plugin'), path.join(ROOT, 'packages', 'codex-plugin'), diff --git a/scripts/test-marketplace-install.js b/scripts/test-marketplace-install.js index 687cc3e..949e1a6 100644 --- a/scripts/test-marketplace-install.js +++ b/scripts/test-marketplace-install.js @@ -45,7 +45,7 @@ const authStub = await startAuthStub() let failed = 0 try { - verifyPluginOwnedPackages() + verifyRootPluginAssets() for (const h of LEGACY_HARNESSES) { const home = mkdtempSync(join(tmpdir(), `nsolid-manual-${h.name}-`)) @@ -133,64 +133,42 @@ function testEnv (home, harness) { } } -function verifyPluginOwnedPackages () { - const artifacts = spawnSync(process.execPath, ['scripts/build-plugin-artifacts.mjs'], { - cwd: REPO_ROOT, - encoding: 'utf8', - timeout: SETUP_TIMEOUT_MS, - }) - if (artifacts.status !== 0) { - failed++ - console.error(`\x1b[31m✗ generated plugin artifacts FAILED: ${(artifacts.stderr || artifacts.stdout || '').trim()}\x1b[0m`) - return - } - +function verifyRootPluginAssets () { try { - const claudeRoot = join(REPO_ROOT, 'dist/plugins/claude/nsolid-plugin') - const claudeManifest = readJson(join(claudeRoot, '.claude-plugin/plugin.json')) - assert(claudeManifest.name === 'nsolid-plugin', '[claude artifact] manifest name') - const claudeMarketplace = readJson(join(claudeRoot, '.claude-plugin/marketplace.json')) - assert(claudeMarketplace.name === 'nodesource-local', '[claude artifact] local marketplace name') - assert(claudeMarketplace.plugins?.[0]?.source === './', '[claude artifact] local marketplace source') - assert(existsSync(join(claudeRoot, '.mcp.json')), '[claude artifact] plugin-owned MCP config') - assert(claudeManifest.hooks === undefined, '[claude artifact] no startup hooks') - assert(!existsSync(join(claudeRoot, 'hooks/hooks.json')), '[claude artifact] no setup hook config') - assert(!existsSync(join(claudeRoot, 'scripts/setup.js')), '[claude artifact] no setup hook script') - assert(existsSync(join(claudeRoot, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[claude artifact] materialized skill') - console.log('\x1b[32m✓ claude generated artifact assets OK\x1b[0m') + const claudeManifest = readJson(join(REPO_ROOT, '.claude-plugin/plugin.json')) + assert(claudeManifest.name === 'nsolid-plugin', '[claude root] manifest name') + assert(claudeManifest.mcpServers === './.claude-mcp.json', '[claude root] manifest declares MCP config') + const claudeMarketplace = readJson(join(REPO_ROOT, '.claude-plugin/marketplace.json')) + assert(claudeMarketplace.name === 'nodesource', '[claude root] marketplace name') + assert(claudeMarketplace.plugins?.[0]?.source === './', '[claude root] marketplace source') + assert(existsSync(join(REPO_ROOT, '.claude-mcp.json')), '[claude root] plugin-owned MCP config') + assert(existsSync(join(REPO_ROOT, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[claude root] canonical skill') + console.log('\x1b[32m✓ claude root plugin assets OK\x1b[0m') } catch (err) { failed++ - console.error(`\x1b[31m✗ claude generated artifact FAILED: ${err.message}\x1b[0m`) + console.error(`\x1b[31m✗ claude root plugin FAILED: ${err.message}\x1b[0m`) } try { - const codexRoot = join(REPO_ROOT, 'dist/plugins/codex/nsolid-plugin') - const codexManifest = readJson(join(codexRoot, '.codex-plugin/plugin.json')) - assert(codexManifest.name === 'nsolid-plugin', '[codex artifact] manifest name') - assert(existsSync(join(codexRoot, '.mcp.json')), '[codex artifact] plugin-owned MCP config') - assert(existsSync(join(codexRoot, '.agents/plugins/marketplace.json')), '[codex artifact] local marketplace manifest') - assert(codexManifest.skills === './skills/', '[codex artifact] manifest declares skills') - assert(codexManifest.mcpServers === './.mcp.json', '[codex artifact] manifest declares MCP config') - assert(codexManifest.hooks === undefined, '[codex artifact] no startup hooks') - assert(!existsSync(join(codexRoot, 'hooks/hooks.json')), '[codex artifact] no setup hook config') - assert(!existsSync(join(codexRoot, 'scripts/setup.js')), '[codex artifact] no setup hook script') - const codexMcp = readJson(join(codexRoot, '.mcp.json')) - assert(JSON.stringify(codexMcp).includes('.codex'), '[codex artifact] MCP wrapper resolves via Codex cache') - assert(!JSON.stringify(codexMcp).includes('PLUGIN_ROOT'), '[codex artifact] MCP wrapper avoids unsupported PLUGIN_ROOT interpolation') - assert(existsSync(join(codexRoot, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[codex artifact] materialized root skill') - assert(existsSync(join(codexRoot, 'plugins/nsolid-plugin/skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[codex artifact] materialized nested skill') - console.log('\x1b[32m✓ codex generated artifact assets OK\x1b[0m') + const codexManifest = readJson(join(REPO_ROOT, '.codex-plugin/plugin.json')) + assert(codexManifest.name === 'nsolid-plugin', '[codex root] manifest name') + assert(codexManifest.skills === './skills/', '[codex root] manifest declares skills') + assert(codexManifest.mcpServers === './.mcp.json', '[codex root] manifest declares MCP config') + assert(existsSync(join(REPO_ROOT, '.mcp.json')), '[codex root] plugin-owned MCP config') + assert(existsSync(join(REPO_ROOT, '.agents/plugins/marketplace.json')), '[codex root] marketplace manifest') + console.log('\x1b[32m✓ codex root plugin assets OK\x1b[0m') } catch (err) { failed++ - console.error(`\x1b[31m✗ codex generated artifact FAILED: ${err.message}\x1b[0m`) + console.error(`\x1b[31m✗ codex root plugin FAILED: ${err.message}\x1b[0m`) } try { const piManifest = readJson(join(REPO_ROOT, 'packages/pi-plugin/package.json')) - assert(piManifest.name === '@nodesource/pi-plugin', '[pi-plugin] package name') + assert(piManifest.name === 'nsolid-pi-plugin', '[pi-plugin] package name') assert(piManifest.pi?.skills?.includes('./skills'), '[pi-plugin] package-owned skills manifest') assert(!existsSync(join(REPO_ROOT, 'packages/pi-plugin/skills')), '[pi-plugin] generated skills should not be committed') - assert(existsSync(join(REPO_ROOT, 'packages/core/skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[pi-plugin] canonical skill source') + assert(!existsSync(join(REPO_ROOT, 'packages/core/skills')), '[pi-plugin] generated core package skills should not be committed') + assert(existsSync(join(REPO_ROOT, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[pi-plugin] canonical skill source') console.log('\x1b[32m✓ pi package-owned skill assets OK\x1b[0m') } catch (err) { failed++ @@ -198,17 +176,15 @@ function verifyPluginOwnedPackages () { } try { - const antigravityRoot = join(REPO_ROOT, 'dist/plugins/antigravity/nsolid-plugin') - const antigravityManifest = readJson(join(antigravityRoot, 'plugin.json')) - assert(antigravityManifest.name === 'nsolid-plugin', '[antigravity artifact] manifest name') - assert(existsSync(join(antigravityRoot, 'mcp_config.json')), '[antigravity artifact] plugin-owned MCP config') - assert(!existsSync(join(antigravityRoot, 'hooks.json')), '[antigravity artifact] no setup hook config') - assert(!existsSync(join(antigravityRoot, 'scripts/setup.js')), '[antigravity artifact] no setup hook script') - assert(existsSync(join(antigravityRoot, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[antigravity artifact] materialized skill') - console.log('\x1b[32m✓ antigravity generated artifact assets OK\x1b[0m') + const antigravityManifest = readJson(join(REPO_ROOT, 'plugin.json')) + assert(antigravityManifest.name === 'nsolid-plugin', '[antigravity root] manifest name') + assert(existsSync(join(REPO_ROOT, 'mcp_config.json')), '[antigravity root] plugin-owned MCP config') + assert(existsSync(join(REPO_ROOT, 'scripts/mcp-wrapper.js')), '[antigravity root] MCP wrapper') + assert(existsSync(join(REPO_ROOT, 'skills/ns-advanced-memory-leak-hunter/SKILL.md')), '[antigravity root] canonical skill') + console.log('\x1b[32m✓ antigravity root plugin assets OK\x1b[0m') } catch (err) { failed++ - console.error(`\x1b[31m✗ antigravity generated artifact FAILED: ${err.message}\x1b[0m`) + console.error(`\x1b[31m✗ antigravity root plugin FAILED: ${err.message}\x1b[0m`) } } diff --git a/packages/core/skills/_shared/fetch-asset.cjs b/skill-assets/fetch-asset.cjs similarity index 100% rename from packages/core/skills/_shared/fetch-asset.cjs rename to skill-assets/fetch-asset.cjs diff --git a/packages/core/skills/_shared/save-report.cjs b/skill-assets/save-report.cjs similarity index 100% rename from packages/core/skills/_shared/save-report.cjs rename to skill-assets/save-report.cjs diff --git a/packages/core/skills/_shared/wait.cjs b/skill-assets/wait.cjs similarity index 100% rename from packages/core/skills/_shared/wait.cjs rename to skill-assets/wait.cjs diff --git a/packages/core/skills/ns-advanced-memory-leak-hunter/SKILL.md b/skills/ns-advanced-memory-leak-hunter/SKILL.md similarity index 100% rename from packages/core/skills/ns-advanced-memory-leak-hunter/SKILL.md rename to skills/ns-advanced-memory-leak-hunter/SKILL.md diff --git a/packages/core/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs b/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs similarity index 100% rename from packages/core/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs rename to skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs diff --git a/packages/core/skills/ns-advanced-memory-leak-hunter/save-report.cjs b/skills/ns-advanced-memory-leak-hunter/save-report.cjs similarity index 100% rename from packages/core/skills/ns-advanced-memory-leak-hunter/save-report.cjs rename to skills/ns-advanced-memory-leak-hunter/save-report.cjs diff --git a/packages/core/skills/ns-advanced-memory-leak-hunter/wait.cjs b/skills/ns-advanced-memory-leak-hunter/wait.cjs similarity index 100% rename from packages/core/skills/ns-advanced-memory-leak-hunter/wait.cjs rename to skills/ns-advanced-memory-leak-hunter/wait.cjs diff --git a/packages/core/skills/ns-analyze-asset/SKILL.md b/skills/ns-analyze-asset/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-asset/SKILL.md rename to skills/ns-analyze-asset/SKILL.md diff --git a/packages/core/skills/ns-analyze-asset/save-report.cjs b/skills/ns-analyze-asset/save-report.cjs similarity index 100% rename from packages/core/skills/ns-analyze-asset/save-report.cjs rename to skills/ns-analyze-asset/save-report.cjs diff --git a/packages/core/skills/ns-analyze-asset/wait.cjs b/skills/ns-analyze-asset/wait.cjs similarity index 100% rename from packages/core/skills/ns-analyze-asset/wait.cjs rename to skills/ns-analyze-asset/wait.cjs diff --git a/packages/core/skills/ns-analyze-cpu/SKILL.md b/skills/ns-analyze-cpu/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-cpu/SKILL.md rename to skills/ns-analyze-cpu/SKILL.md diff --git a/packages/core/skills/ns-analyze-cpu/fetch-asset.cjs b/skills/ns-analyze-cpu/fetch-asset.cjs similarity index 100% rename from packages/core/skills/ns-analyze-cpu/fetch-asset.cjs rename to skills/ns-analyze-cpu/fetch-asset.cjs diff --git a/packages/core/skills/ns-analyze-cpu/save-report.cjs b/skills/ns-analyze-cpu/save-report.cjs similarity index 100% rename from packages/core/skills/ns-analyze-cpu/save-report.cjs rename to skills/ns-analyze-cpu/save-report.cjs diff --git a/packages/core/skills/ns-analyze-cpu/wait.cjs b/skills/ns-analyze-cpu/wait.cjs similarity index 100% rename from packages/core/skills/ns-analyze-cpu/wait.cjs rename to skills/ns-analyze-cpu/wait.cjs diff --git a/packages/core/skills/ns-analyze-cpu/workspace-delta.cjs b/skills/ns-analyze-cpu/workspace-delta.cjs similarity index 100% rename from packages/core/skills/ns-analyze-cpu/workspace-delta.cjs rename to skills/ns-analyze-cpu/workspace-delta.cjs diff --git a/packages/core/skills/ns-analyze-event/SKILL.md b/skills/ns-analyze-event/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-event/SKILL.md rename to skills/ns-analyze-event/SKILL.md diff --git a/packages/core/skills/ns-analyze-event/save-report.cjs b/skills/ns-analyze-event/save-report.cjs similarity index 100% rename from packages/core/skills/ns-analyze-event/save-report.cjs rename to skills/ns-analyze-event/save-report.cjs diff --git a/packages/core/skills/ns-analyze-memory/SKILL.md b/skills/ns-analyze-memory/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-memory/SKILL.md rename to skills/ns-analyze-memory/SKILL.md diff --git a/packages/core/skills/ns-analyze-memory/fetch-asset.cjs b/skills/ns-analyze-memory/fetch-asset.cjs similarity index 100% rename from packages/core/skills/ns-analyze-memory/fetch-asset.cjs rename to skills/ns-analyze-memory/fetch-asset.cjs diff --git a/packages/core/skills/ns-analyze-memory/save-report.cjs b/skills/ns-analyze-memory/save-report.cjs similarity index 100% rename from packages/core/skills/ns-analyze-memory/save-report.cjs rename to skills/ns-analyze-memory/save-report.cjs diff --git a/packages/core/skills/ns-analyze-memory/wait.cjs b/skills/ns-analyze-memory/wait.cjs similarity index 100% rename from packages/core/skills/ns-analyze-memory/wait.cjs rename to skills/ns-analyze-memory/wait.cjs diff --git a/packages/core/skills/ns-analyze-tracing/SKILL.md b/skills/ns-analyze-tracing/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-tracing/SKILL.md rename to skills/ns-analyze-tracing/SKILL.md diff --git a/packages/core/skills/ns-analyze-vulnerabilities/SKILL.md b/skills/ns-analyze-vulnerabilities/SKILL.md similarity index 100% rename from packages/core/skills/ns-analyze-vulnerabilities/SKILL.md rename to skills/ns-analyze-vulnerabilities/SKILL.md diff --git a/packages/core/skills/ns-audit-dependencies/SKILL.md b/skills/ns-audit-dependencies/SKILL.md similarity index 100% rename from packages/core/skills/ns-audit-dependencies/SKILL.md rename to skills/ns-audit-dependencies/SKILL.md diff --git a/packages/core/skills/ns-audit-dependencies/collect-dependencies.cjs b/skills/ns-audit-dependencies/collect-dependencies.cjs similarity index 100% rename from packages/core/skills/ns-audit-dependencies/collect-dependencies.cjs rename to skills/ns-audit-dependencies/collect-dependencies.cjs diff --git a/packages/core/skills/ns-benchmark-run/SKILL.md b/skills/ns-benchmark-run/SKILL.md similarity index 100% rename from packages/core/skills/ns-benchmark-run/SKILL.md rename to skills/ns-benchmark-run/SKILL.md diff --git a/packages/core/skills/ns-benchmark-run/save-report.cjs b/skills/ns-benchmark-run/save-report.cjs similarity index 100% rename from packages/core/skills/ns-benchmark-run/save-report.cjs rename to skills/ns-benchmark-run/save-report.cjs diff --git a/packages/core/skills/ns-benchmark-run/wait.cjs b/skills/ns-benchmark-run/wait.cjs similarity index 100% rename from packages/core/skills/ns-benchmark-run/wait.cjs rename to skills/ns-benchmark-run/wait.cjs diff --git a/packages/core/skills/ns-benchmark-validate/SKILL.md b/skills/ns-benchmark-validate/SKILL.md similarity index 100% rename from packages/core/skills/ns-benchmark-validate/SKILL.md rename to skills/ns-benchmark-validate/SKILL.md diff --git a/packages/core/skills/ns-benchmark-validate/save-report.cjs b/skills/ns-benchmark-validate/save-report.cjs similarity index 100% rename from packages/core/skills/ns-benchmark-validate/save-report.cjs rename to skills/ns-benchmark-validate/save-report.cjs diff --git a/packages/core/skills/ns-benchmark-validate/wait.cjs b/skills/ns-benchmark-validate/wait.cjs similarity index 100% rename from packages/core/skills/ns-benchmark-validate/wait.cjs rename to skills/ns-benchmark-validate/wait.cjs diff --git a/packages/core/skills/ns-generate-asset/SKILL.md b/skills/ns-generate-asset/SKILL.md similarity index 100% rename from packages/core/skills/ns-generate-asset/SKILL.md rename to skills/ns-generate-asset/SKILL.md diff --git a/packages/core/skills/ns-generate-sbom/SKILL.md b/skills/ns-generate-sbom/SKILL.md similarity index 100% rename from packages/core/skills/ns-generate-sbom/SKILL.md rename to skills/ns-generate-sbom/SKILL.md diff --git a/packages/core/skills/ns-node-upgrade/SKILL.md b/skills/ns-node-upgrade/SKILL.md similarity index 100% rename from packages/core/skills/ns-node-upgrade/SKILL.md rename to skills/ns-node-upgrade/SKILL.md diff --git a/packages/core/skills/ns-node-upgrade/fetch-node-releases.cjs b/skills/ns-node-upgrade/fetch-node-releases.cjs similarity index 100% rename from packages/core/skills/ns-node-upgrade/fetch-node-releases.cjs rename to skills/ns-node-upgrade/fetch-node-releases.cjs diff --git a/packages/core/skills/ns-replace-package/SKILL.md b/skills/ns-replace-package/SKILL.md similarity index 100% rename from packages/core/skills/ns-replace-package/SKILL.md rename to skills/ns-replace-package/SKILL.md diff --git a/packages/core/skills/ns-upgrade-package/SKILL.md b/skills/ns-upgrade-package/SKILL.md similarity index 100% rename from packages/core/skills/ns-upgrade-package/SKILL.md rename to skills/ns-upgrade-package/SKILL.md From 36ea20d092e1ee091e70b918e02cb65583ec7106 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Thu, 25 Jun 2026 12:29:04 +0200 Subject: [PATCH 02/11] fix(doctor): detect plugins installed via native harness mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doctor only read the CLI tracking file (~/.agents/.nodesource-installed.json), written by the direct install path. For plugin/package-owned harnesses the recommended path is the native mechanism (codex/claude/agy plugin install, pi install), which records state in harness-specific locations the doctor never inspected — so it reported everything as missing even when `codex plugin list` showed installed, enabled. Add an optional detectNativePlugin() to HarnessAdapter and implement it per harness (codex config.toml, claude installed_plugins.json, antigravity staged dir, pi package). When a native plugin is present, skills/MCPs are reported green from the plugin and a new Plugin line shows status + install hint. Health still allows the direct fallback install on plugin-owned harnesses. --- .../core/src/harnesses/antigravity-adapter.ts | 26 +++- packages/core/src/harnesses/claude-adapter.ts | 75 +++++++++- packages/core/src/harnesses/codex-adapter.ts | 51 ++++++- .../core/src/harnesses/harness-adapter.ts | 24 ++++ packages/core/src/harnesses/index.ts | 2 +- packages/core/src/harnesses/pi-adapter.ts | 13 +- .../core/src/harnesses/pi-plugin-detector.ts | 115 +++++++++++++++ packages/core/src/index.ts | 128 ++++++----------- packages/core/src/types.ts | 7 + packages/core/src/utils/format.ts | 31 ++++ .../core/test/integration/installer.test.ts | 133 ++++++++++++++++++ packages/core/test/unit/utils/format.test.ts | 35 +++++ 12 files changed, 553 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/harnesses/pi-plugin-detector.ts diff --git a/packages/core/src/harnesses/antigravity-adapter.ts b/packages/core/src/harnesses/antigravity-adapter.ts index 46e0124..0881bce 100644 --- a/packages/core/src/harnesses/antigravity-adapter.ts +++ b/packages/core/src/harnesses/antigravity-adapter.ts @@ -1,8 +1,12 @@ -import type { HarnessAdapter, McpConfig } from './harness-adapter.js' +import { existsSync } from 'node:fs' +import path from 'node:path' +import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' +const PLUGIN_NAME = 'nsolid-plugin' + export class AntigravityAdapter implements HarnessAdapter { readonly name: HarnessType = 'antigravity' @@ -10,6 +14,11 @@ export class AntigravityAdapter implements HarnessAdapter { return resolveHome('~/.gemini/antigravity-cli/mcp_config.json') } + getPluginsPath (): string { + // `agy plugin install` stages native plugins under the antigravity-cli root. + return resolveHome('~/.gemini/antigravity-cli/plugins') + } + getSkillsPath (): string { // Antigravity loads global skills from ~/.gemini/antigravity-cli/skills/ (per // https://antigravity.google/docs/cli-plugins), the same root as the MCP config @@ -34,4 +43,19 @@ export class AntigravityAdapter implements HarnessAdapter { // symmetric with the Claude/Codex/OpenCode adapters. writeAdapterMcpConfig(this.name, config) } + + /** + * Antigravity stages a native plugin at + * `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. There is no separate + * enable flag, so `installed` follows from the staged directory existing. + */ + detectNativePlugin (): NativePluginStatus { + const status: NativePluginStatus = { installed: false, label: PLUGIN_NAME } + const staged = path.join(this.getPluginsPath(), PLUGIN_NAME) + if (existsSync(staged)) { + status.installed = true + status.enabled = true + } + return status + } } diff --git a/packages/core/src/harnesses/claude-adapter.ts b/packages/core/src/harnesses/claude-adapter.ts index 7206dfc..c2d9507 100644 --- a/packages/core/src/harnesses/claude-adapter.ts +++ b/packages/core/src/harnesses/claude-adapter.ts @@ -1,7 +1,13 @@ -import type { HarnessAdapter, McpConfig } from './harness-adapter.js' +import { existsSync } from 'node:fs' +import path from 'node:path' +import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' +import { readJsonFile } from '../utils/config.js' + +const PLUGIN_ID = 'nsolid-plugin@nodesource' +const MARKETPLACE_NAME = 'nodesource' export class ClaudeAdapter implements HarnessAdapter { readonly name: HarnessType = 'claude' @@ -10,6 +16,10 @@ export class ClaudeAdapter implements HarnessAdapter { return resolveHome('~/.claude.json') } + getPluginsDir (): string { + return resolveHome('~/.claude/plugins') + } + getSkillsPath (): string { return resolveHome('~/.claude/skills/') } @@ -25,4 +35,67 @@ export class ClaudeAdapter implements HarnessAdapter { async writeMcpConfig (config: McpConfig): Promise { writeAdapterMcpConfig(this.name, config) } + + /** + * Claude Code records native plugins in + * `~/.claude/plugins/installed_plugins.json` and an enable map in + * `~/.claude.json` (`enabledPlugins`). The cloned marketplace lives at + * `~/.claude/plugins/marketplaces//`. The installed_plugins schema has + * varied across versions (a map or a `{plugins:[...]}` array), so each is + * tolerated; an explicit entry or the marketplace clone counts as installed, + * and `enabled` is set only when an enabled map explicitly lists the id. + */ + detectNativePlugin (): NativePluginStatus { + const status: NativePluginStatus = { installed: false, label: PLUGIN_ID } + const installedPath = path.join(this.getPluginsDir(), 'installed_plugins.json') + + try { + const data = readJsonFile(installedPath) + const ids = extractPluginIds(data) + if (ids.includes(PLUGIN_ID)) { + status.installed = true + } + } catch { + // Unreadable installed_plugins.json — fall through. + } + + try { + const settings = readJsonFile>(this.getMcpConfigPath()) + const enabledPlugins = settings?.enabledPlugins + if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) { + const map = enabledPlugins as Record + if (map[PLUGIN_ID] === true) { + status.installed = true + status.enabled = true + } + } + } catch { + // settings.json unreadable — enabled stays undefined. + } + + if (!status.installed) { + const clone = path.join(this.getPluginsDir(), 'marketplaces', MARKETPLACE_NAME) + if (existsSync(clone)) status.installed = true + } + + return status + } +} + +function extractPluginIds (data: unknown): string[] { + if (!data || typeof data !== 'object') return [] + if (Array.isArray(data)) return data.filter((v): v is string => typeof v === 'string') + + const obj = data as Record + const arr = obj.plugins + if (Array.isArray(arr)) { + return arr.flatMap((v) => { + if (typeof v === 'string') return [v] + if (v && typeof v === 'object' && typeof (v as { id?: unknown }).id === 'string') { + return [(v as { id: string }).id] + } + return [] + }) + } + return Object.keys(obj) } diff --git a/packages/core/src/harnesses/codex-adapter.ts b/packages/core/src/harnesses/codex-adapter.ts index cdfabb5..8b337e2 100644 --- a/packages/core/src/harnesses/codex-adapter.ts +++ b/packages/core/src/harnesses/codex-adapter.ts @@ -1,7 +1,12 @@ -import type { HarnessAdapter, McpConfig } from './harness-adapter.js' +import { existsSync } from 'node:fs' +import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' +import { readTomlFile } from '../utils/config.js' + +const PLUGIN_KEY = 'nsolid-plugin@nodesource' +const MARKETPLACE_NAME = 'nodesource' export class CodexAdapter implements HarnessAdapter { readonly name: HarnessType = 'codex' @@ -25,4 +30,48 @@ export class CodexAdapter implements HarnessAdapter { async writeMcpConfig (config: McpConfig): Promise { writeAdapterMcpConfig(this.name, config) } + + /** + * Codex records a native plugin install in `~/.codex/config.toml`: + * [plugins."nsolid-plugin@nodesource"] + * enabled = true + * and the marketplace under `[marketplaces.nodesource]`, with the cloned + * repo at `~/.codex/.tmp/marketplaces/nodesource/`. Any of those signals + * counts as installed; only the `enabled = true` flag sets `enabled`. + */ + detectNativePlugin (): NativePluginStatus { + const status: NativePluginStatus = { installed: false, label: PLUGIN_KEY } + try { + const data = readTomlFile>(this.getMcpConfigPath()) + if (data) { + const plugins = data.plugins + const entry = plugins && typeof plugins === 'object' && !Array.isArray(plugins) + ? (plugins as Record)[PLUGIN_KEY] + : undefined + const enabled = entry && typeof entry === 'object' + ? (entry as { enabled?: unknown }).enabled === true + : false + const marketplaces = data.marketplaces + const hasMarketplace = marketplaces && typeof marketplaces === 'object' && !Array.isArray(marketplaces) + ? Object.prototype.hasOwnProperty.call(marketplaces, MARKETPLACE_NAME) + : false + + if (enabled) { + status.installed = true + status.enabled = true + } else if (hasMarketplace) { + status.installed = true + } + } + } catch { + // Corrupt or unreadable config — fall through to the on-disk clone check. + } + + if (!status.installed) { + const clone = resolveHome(`~/.codex/.tmp/marketplaces/${MARKETPLACE_NAME}/bundle.json`) + if (existsSync(clone)) status.installed = true + } + + return status + } } diff --git a/packages/core/src/harnesses/harness-adapter.ts b/packages/core/src/harnesses/harness-adapter.ts index 1cc9514..35f8875 100644 --- a/packages/core/src/harnesses/harness-adapter.ts +++ b/packages/core/src/harnesses/harness-adapter.ts @@ -4,6 +4,24 @@ import type { McpServerConfig, NormalizedMcpConfig } from '../mcp/mcp-config-mer export type { McpServerConfig } export type McpConfig = NormalizedMcpConfig +/** + * Result of probing whether the nsolid plugin is installed as a *native* + * plugin/package of a harness (e.g. `codex plugin install`, + * `claude plugin install`, `agy plugin install`, `pi install npm:...`). + * + * Native installs are owned by the harness CLI, not the shared tracking file + * at `~/.agents/.nodesource-installed.json`, so `doctor` detects them here + * rather than via tracking. Detection is best-effort and read-only: any + * adapter that can't determine the state returns `{ installed: false }`. + */ +export interface NativePluginStatus { + installed: boolean + /** True only when the harness records the plugin as explicitly enabled. */ + enabled?: boolean + /** Human label like `nsolid-plugin@nodesource` shown on the Plugin line. */ + label?: string +} + export interface HarnessAdapter { readonly name: HarnessType getMcpConfigPath(): string | null @@ -11,4 +29,10 @@ export interface HarnessAdapter { supportsMcp(): boolean readMcpConfig(): Promise writeMcpConfig(config: McpConfig): Promise + /** + * Detect whether the nsolid plugin is installed as a native plugin/package. + * Optional: harnesses that don't have a native plugin model (e.g. opencode) + * omit it, and `doctor` then treats the plugin line as N/A for them. + */ + detectNativePlugin?(): NativePluginStatus } diff --git a/packages/core/src/harnesses/index.ts b/packages/core/src/harnesses/index.ts index 5a92501..d968e4d 100644 --- a/packages/core/src/harnesses/index.ts +++ b/packages/core/src/harnesses/index.ts @@ -21,4 +21,4 @@ export function getAdapter (harness: HarnessType): HarnessAdapter { } } -export type { HarnessAdapter, McpConfig, McpServerConfig } from './harness-adapter.js' +export type { HarnessAdapter, McpConfig, McpServerConfig, NativePluginStatus } from './harness-adapter.js' diff --git a/packages/core/src/harnesses/pi-adapter.ts b/packages/core/src/harnesses/pi-adapter.ts index 7ca7f57..a8aa9e5 100644 --- a/packages/core/src/harnesses/pi-adapter.ts +++ b/packages/core/src/harnesses/pi-adapter.ts @@ -1,7 +1,8 @@ -import type { HarnessAdapter, McpConfig } from './harness-adapter.js' +import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' +import { piPluginInstalled, PI_PLUGIN_PACKAGE_NAME } from './pi-plugin-detector.js' export class PiAdapter implements HarnessAdapter { readonly name: HarnessType = 'pi' @@ -25,4 +26,14 @@ export class PiAdapter implements HarnessAdapter { async writeMcpConfig (config: McpConfig): Promise { writeAdapterMcpConfig(this.name, config) } + + /** + * Pi is package-owned: detection follows from an installed + * `nsolid-pi-plugin` npm package (see pi-plugin-detector). There is no + * separate enable flag, so `installed` implies `enabled`. + */ + detectNativePlugin (): NativePluginStatus { + const installed = piPluginInstalled() + return { installed, enabled: installed ? true : undefined, label: PI_PLUGIN_PACKAGE_NAME } + } } diff --git a/packages/core/src/harnesses/pi-plugin-detector.ts b/packages/core/src/harnesses/pi-plugin-detector.ts new file mode 100644 index 0000000..d61c0e0 --- /dev/null +++ b/packages/core/src/harnesses/pi-plugin-detector.ts @@ -0,0 +1,115 @@ +import os from 'node:os' +import path from 'node:path' +import { existsSync } from 'node:fs' +import { readJsonFile } from '../utils/config.js' + +/** + * Native package detection for the Pi Agent harness. + * + * Pi is package-owned: `pi install npm:nsolid-pi-plugin` installs skills via a + * real npm package rather than the shared CLI tracking file. These helpers + * discover that package on disk (from `~/.pi/agent/settings.json` `packages` + * entries or the canonical npm install location) so both `doctor` and the + * Pi adapter can report it without the CLI tracking file. + * + * Extracted from `index.ts` so the Pi adapter and `doctor()` share one source + * of truth. + */ + +export const PI_PLUGIN_PACKAGE_NAME = 'nsolid-pi-plugin' + +function readPiPackageSourceEntries (settingsPath: string): string[] { + let settings: { packages?: Array } | null = null + try { + settings = readJsonFile<{ packages?: Array }>(settingsPath) + } catch { + return [] + } + if (!settings || !Array.isArray(settings.packages)) return [] + + return settings.packages + .map((entry) => typeof entry === 'string' ? entry : entry.source) + .filter((source): source is string => typeof source === 'string' && source.length > 0) +} + +function packageNameFromNpmSource (source: string): string | null { + if (!source.startsWith('npm:')) return null + const spec = source.slice('npm:'.length) + if (spec.startsWith('@')) { + const [scope, name] = spec.split('/') + if (!scope || !name) return null + return `${scope}/${name.split('@')[0]}` + } + return spec.split('@')[0] || null +} + +function resolvePiPackageRootFromSource (source: string, settingsDir: string): string | null { + const npmPackageName = packageNameFromNpmSource(source) + if (npmPackageName) { + if (npmPackageName !== PI_PLUGIN_PACKAGE_NAME) return null + return path.join(settingsDir, 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME) + } + + if (source.startsWith('git:') || source.startsWith('http://') || source.startsWith('https://') || source.startsWith('ssh://')) { + return null + } + + return path.resolve(settingsDir, source) +} + +export function findPiPluginPackageRoots (): string[] { + const settingsPaths = [ + path.join(os.homedir(), '.pi', 'agent', 'settings.json'), + path.resolve('.pi', 'settings.json'), + ] + const roots = new Set() + + for (const settingsPath of settingsPaths) { + const settingsDir = path.dirname(settingsPath) + for (const source of readPiPackageSourceEntries(settingsPath)) { + const root = resolvePiPackageRootFromSource(source, settingsDir) + if (root) roots.add(root) + } + } + + roots.add(path.join(os.homedir(), '.pi', 'agent', 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME)) + return [...roots] +} + +/** + * Skill roots declared by each installed Pi package. A package without an + * explicit `pi.skills` array defaults to `./skills` (matching the package + * generator and the install test). + */ +export function findPiPluginSkillRoots (): string[] { + const skillRoots: string[] = [] + for (const packageRoot of findPiPluginPackageRoots()) { + const pkgPath = path.join(packageRoot, 'package.json') + let pkg: { name?: string; pi?: { skills?: string[] } } | null = null + try { + pkg = readJsonFile<{ name?: string; pi?: { skills?: string[] } }>(pkgPath) + } catch { + continue + } + if (pkg?.name !== PI_PLUGIN_PACKAGE_NAME) continue + + const configuredSkillRoots = Array.isArray(pkg.pi?.skills) && pkg.pi!.skills.length > 0 + ? pkg.pi!.skills + : ['./skills'] + for (const skillRoot of configuredSkillRoots) { + skillRoots.push(path.resolve(packageRoot, skillRoot)) + } + } + return skillRoots +} + +/** True when a nsolid-pi-plugin package root exists on disk. */ +export function piPluginInstalled (): boolean { + return findPiPluginPackageRoots().some((root) => { + try { + return existsSync(path.join(root, 'package.json')) + } catch { + return false + } + }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4e7d17..d4c9ffe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,6 @@ export { getAdapter } from './harnesses/index.js' export type { HarnessAdapter, McpConfig, McpServerConfig } from './harnesses/index.js' export { loadCredentials, isExpired } from './auth/index.js' -import os from 'node:os' import path from 'node:path' import { readdir } from 'node:fs/promises' import { existsSync } from 'node:fs' @@ -37,6 +36,7 @@ import { } from './mcp/index.js' import { getAdapter } from './harnesses/index.js' import type { HarnessAdapter } from './harnesses/index.js' +import { findPiPluginSkillRoots } from './harnesses/pi-plugin-detector.js' import { readJsonFile } from './utils/config.js' import { getSkillsDir, getAuthFilePath } from './utils/path.js' import { createLogger, isVerboseEnabled } from './utils/logger.js' @@ -47,7 +47,13 @@ import { toPluginError } from './errors.js' const KNOWN_MCP_SERVERS = ['ns-benchmark', 'nsolid-console', 'ncm'] const STAGING_ACCOUNTS_URL = 'https://staging.accounts.nodesource.com' const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) -const PI_PLUGIN_PACKAGE_NAME = 'nsolid-pi-plugin' +/** + * Harnesses that install the nsolid plugin/package natively (owning skills and + * MCP config themselves) rather than via the shared CLI tracking file. The + * doctor probes each via `adapter.detectNativePlugin()`. Superset of + * {@link PLUGIN_OWNED_HARNESSES} plus the package-owned Pi harness. + */ +const NATIVE_PLUGIN_HARNESSES = new Set(['claude', 'codex', 'antigravity', 'pi']) function formatBundleSummary (bundle: BundleDescriptor, options: { packageOwnedSkills?: boolean }): string { if (options.packageOwnedSkills === true) { @@ -57,86 +63,6 @@ function formatBundleSummary (bundle: BundleDescriptor, options: { packageOwnedS return `${bundle.skills.length} skills, ${bundle.mcpServers.length} MCP servers` } -function readPiPackageSourceEntries (settingsPath: string): string[] { - let settings: { packages?: Array } | null = null - try { - settings = readJsonFile<{ packages?: Array }>(settingsPath) - } catch { - return [] - } - if (!settings || !Array.isArray(settings.packages)) return [] - - return settings.packages - .map((entry) => typeof entry === 'string' ? entry : entry.source) - .filter((source): source is string => typeof source === 'string' && source.length > 0) -} - -function packageNameFromNpmSource (source: string): string | null { - if (!source.startsWith('npm:')) return null - const spec = source.slice('npm:'.length) - if (spec.startsWith('@')) { - const [scope, name] = spec.split('/') - if (!scope || !name) return null - return `${scope}/${name.split('@')[0]}` - } - return spec.split('@')[0] || null -} - -function resolvePiPackageRootFromSource (source: string, settingsDir: string): string | null { - const npmPackageName = packageNameFromNpmSource(source) - if (npmPackageName) { - if (npmPackageName !== PI_PLUGIN_PACKAGE_NAME) return null - return path.join(settingsDir, 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME) - } - - if (source.startsWith('git:') || source.startsWith('http://') || source.startsWith('https://') || source.startsWith('ssh://')) { - return null - } - - return path.resolve(settingsDir, source) -} - -function findPiPluginPackageRoots (): string[] { - const settingsPaths = [ - path.join(os.homedir(), '.pi', 'agent', 'settings.json'), - path.resolve('.pi', 'settings.json'), - ] - const roots = new Set() - - for (const settingsPath of settingsPaths) { - const settingsDir = path.dirname(settingsPath) - for (const source of readPiPackageSourceEntries(settingsPath)) { - const root = resolvePiPackageRootFromSource(source, settingsDir) - if (root) roots.add(root) - } - } - - roots.add(path.join(os.homedir(), '.pi', 'agent', 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME)) - return [...roots] -} - -function findPiPluginSkillRoots (): string[] { - const skillRoots: string[] = [] - for (const packageRoot of findPiPluginPackageRoots()) { - const pkgPath = path.join(packageRoot, 'package.json') - let pkg: { name?: string; pi?: { skills?: string[] } } | null = null - try { - pkg = readJsonFile<{ name?: string; pi?: { skills?: string[] } }>(pkgPath) - } catch { - continue - } - if (pkg?.name !== PI_PLUGIN_PACKAGE_NAME) continue - - const configuredSkillRoots = Array.isArray(pkg.pi?.skills) && pkg.pi.skills.length > 0 - ? pkg.pi.skills - : ['./skills'] - for (const skillRoot of configuredSkillRoots) { - skillRoots.push(path.resolve(packageRoot, skillRoot)) - } - } - return skillRoots -} - function piPackageSkillExists (skillName: string): boolean { return findPiPluginSkillRoots().some((skillRoot) => existsSync(path.join(skillRoot, skillName, 'SKILL.md'))) } @@ -644,6 +570,7 @@ export async function doctor ( const report: DoctorReport = { healthy: true, credentials: { status: 'missing' }, + plugin: { status: 'n/a', installed: false }, skills: { status: 'ok', installed: [], missing: [] }, mcpServers: { status: 'ok', reachable: [], unreachable: [] }, errors: [], @@ -678,9 +605,40 @@ export async function doctor ( const adapter = getAdapter(harness) + // For plugin/package-owned harnesses the recommended install path is the + // harness's native mechanism, not the CLI tracking file. Probe it here; when + // present, skills and MCP servers are owned by the plugin and reported as ok. + const isNativeHarness = NATIVE_PLUGIN_HARNESSES.has(harness) + let nativeOwned = false + if (isNativeHarness && adapter.detectNativePlugin) { + const detected = adapter.detectNativePlugin() + if (detected.installed) { + nativeOwned = true + report.plugin = { + status: 'ok', + installed: true, + enabled: detected.enabled, + label: detected.label, + } + } else { + report.plugin = { status: 'missing', installed: false } + } + } + if (!bundle) { report.skills.status = 'unknown' report.mcpServers.status = 'unknown' + } else if (nativeOwned) { + // The native plugin owns skills and MCP config; report them as satisfied + // rather than cross-referencing the (irrelevant) CLI tracking file. + report.skills.installed = bundle.skills.map((s) => s.name) + report.skills.missing = [] + report.skills.status = 'ok' + if (adapter.supportsMcp()) { + report.mcpServers.reachable = bundle.mcpServers.map((s) => s.name) + report.mcpServers.unreachable = [] + report.mcpServers.status = 'ok' + } } else { const expectedMcps = bundle.mcpServers.map((s) => s.name) @@ -746,6 +704,12 @@ export async function doctor ( } } + // The Plugin line is informational — it reflects whether the *native* + // plugin/package is installed. Health is driven by whether skills, MCP + // servers, and credentials are actually satisfied, regardless of which path + // (native plugin or direct CLI install) provided them. So a direct (fallback) + // install on a plugin-owned harness can still be healthy without the native + // plugin present. report.healthy = report.credentials.status === 'ok' && report.skills.status === 'ok' && diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e807922..3feddf6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -120,6 +120,13 @@ export type SetupResult = InstallResult export interface DoctorReport { healthy: boolean; credentials: { status: 'ok' | 'missing' | 'expired'; message?: string }; + /** + * Native plugin/package install status. Only meaningful for plugin/package- + * owned harnesses (claude, codex, antigravity, pi); for others the status is + * `'n/a'`. When `installed`, skills and MCP servers are satisfied from the + * plugin itself rather than the CLI tracking file. + */ + plugin: { status: 'ok' | 'missing' | 'n/a'; installed: boolean; enabled?: boolean; label?: string }; /** `unknown` when the bundle could not be loaded — the listed `installed`/`missing` arrays are not meaningful. */ skills: { status: 'ok' | 'partial' | 'missing' | 'unknown'; installed: string[]; missing: string[] }; /** `unknown` when the bundle could not be loaded — the listed `reachable`/`unreachable` arrays are not meaningful. */ diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index 0eac10a..a9ea0f8 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -7,6 +7,25 @@ export const C = { dim: (s: string) => `\x1b[2m${s}\x1b[0m`, } +/** Harnesses that install the plugin/package natively and get a Plugin line. */ +const NATIVE_PLUGIN_HARNESSES = new Set(['claude', 'codex', 'antigravity', 'pi']) + +/** Native install command shown when the plugin is missing for a harness. */ +function nativeInstallHint (harness: string): string { + switch (harness) { + case 'claude': + return 'claude plugin marketplace add NodeSource/nsolid-plugin && claude plugin install nsolid-plugin@nodesource' + case 'codex': + return 'codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource' + case 'antigravity': + return 'agy plugin install https://github.com/NodeSource/nsolid-plugin' + case 'pi': + return 'pi install npm:nsolid-pi-plugin' + default: + return '' + } +} + export function supportsColor (stream: { isTTY?: boolean } = process.stdout): boolean { if (process.env.NO_COLOR !== undefined) return false if (process.env.FORCE_COLOR === '0') return false @@ -22,6 +41,16 @@ function credLine (status: DoctorReport['credentials']['status'], color: boolean return line('Credentials', '✗ missing', C.red, 'Run installation to authenticate', color) } +function pluginLine (p: DoctorReport['plugin'], harness: string, color: boolean): string | null { + // Non-native harnesses (e.g. opencode) have no plugin model — no line shown. + if (!NATIVE_PLUGIN_HARNESSES.has(harness)) return null + if (p.status === 'ok') { + const label = p.label ? ` (${p.label})` : '' + return line('Plugin', `✓ installed${label}`, C.green, '', color) + } + return line('Plugin', '✗ not installed', C.red, nativeInstallHint(harness), color) +} + function skillsLine (s: DoctorReport['skills'], color: boolean): string { if (s.status === 'ok') return line('Skills', `✓ ok (${s.installed.length} installed)`, C.green, '', color) if (s.status === 'partial') return line('Skills', `⚠ partial (${s.installed.length} installed, ${s.missing.length} missing)`, C.yellow, 'Re-run installation to restore skills', color) @@ -47,6 +76,8 @@ export function formatDoctorReport (report: DoctorReport, harness: string, color const title = color ? C.dim(`NodeSource plugin health — ${harness}`) : `NodeSource plugin health — ${harness}` out.push(title, '─'.repeat(34)) out.push(credLine(report.credentials.status, color)) + const plugin = pluginLine(report.plugin, harness, color) + if (plugin) out.push(plugin) out.push(skillsLine(report.skills, color)) out.push(mcpLine(report.mcpServers, color)) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index d31183b..057b215 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -794,6 +794,139 @@ describe('doctor()', () => { assert.deepStrictEqual(report.skills.missing, []) }) + it('detects a native Codex plugin from config.toml enabled flag', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + // Mirror what `codex plugin add nsolid-plugin@nodesource` writes. + mkdirSync(join(tmpDir, '.codex'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', 'config.toml'), [ + '[marketplaces.nodesource]', + 'source = "https://github.com/NodeSource/nsolid-plugin.git"', + '', + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.plugin.status, 'ok') + assert.strictEqual(report.plugin.installed, true) + assert.strictEqual(report.plugin.enabled, true) + assert.strictEqual(report.plugin.label, 'nsolid-plugin@nodesource') + // Plugin-owned harness satisfies skills/MCP from the plugin itself. + assert.strictEqual(report.skills.status, 'ok') + assert.ok(report.skills.installed.includes('ns-test-skill')) + assert.deepStrictEqual(report.skills.missing, []) + assert.strictEqual(report.mcpServers.status, 'ok') + assert.ok(report.mcpServers.reachable.includes('ns-test-mcp')) + assert.deepStrictEqual(report.mcpServers.unreachable, []) + }) + + it('detects a native Codex plugin from the cloned marketplace bundle', async () => { + // Even without the [plugins.*] enabled flag, the cloned marketplace dir counts. + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource', 'bundle.json'), '{}') + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.plugin.status, 'ok') + assert.strictEqual(report.plugin.installed, true) + }) + + it('detects a native Claude plugin from installed_plugins.json', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.claude', 'plugins'), { recursive: true }) + writeFileSync(join(tmpDir, '.claude', 'plugins', 'installed_plugins.json'), JSON.stringify({ + version: 2, + plugins: [{ id: 'nsolid-plugin@nodesource', enabled: true }], + })) + + const report = await doctor('claude', bundlePath) + + assert.strictEqual(report.plugin.status, 'ok') + assert.strictEqual(report.plugin.installed, true) + assert.strictEqual(report.skills.status, 'ok') + assert.strictEqual(report.mcpServers.status, 'ok') + }) + + it('detects a native Antigravity plugin from the staged plugins dir', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.gemini', 'antigravity-cli', 'plugins', 'nsolid-plugin'), { recursive: true }) + + const report = await doctor('antigravity', bundlePath) + + assert.strictEqual(report.plugin.status, 'ok') + assert.strictEqual(report.plugin.installed, true) + assert.strictEqual(report.skills.status, 'ok') + assert.strictEqual(report.mcpServers.status, 'ok') + }) + + it('reports plugin missing for a plugin-owned harness with no native install', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + + const report = await doctor('codex', bundlePath) + + // Plugin line is informational: missing here, but health is driven by the + // other checks (creds/skills/mcp), not by this line alone. + assert.strictEqual(report.plugin.status, 'missing') + assert.strictEqual(report.plugin.installed, false) + }) + + it('marks plugin status as n/a for a non-native harness', async () => { + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + + const report = await doctor('opencode', bundlePath) + + assert.strictEqual(report.plugin.status, 'n/a') + }) + + it('reports a fully healthy report when a native plugin is installed and creds are valid', async () => { + const { doctor } = await import('../../src/index.js') + const { getAuthFilePath, getAgentsDir } = await import('../../src/utils/path.js') + const { ensureDir } = await import('../../src/utils/fs.js') + ensureDir(getAgentsDir()) + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() + writeFileSync(getAuthFilePath(), JSON.stringify({ + serviceToken: 'valid-token', + organizationId: 'valid-org', + saasToken: 'valid-saas', + consoleUrl: 'https://console.nodesource.com', + mcpUrl: 'https://mcp.nodesource.com', + expiresAt: futureDate, + })) + + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.codex'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', 'config.toml'), [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = true', + '', + ].join('\n')) + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.healthy, true) + assert.strictEqual(report.credentials.status, 'ok') + assert.strictEqual(report.plugin.status, 'ok') + assert.strictEqual(report.skills.status, 'ok') + assert.strictEqual(report.mcpServers.status, 'ok') + assert.deepStrictEqual(report.errors, []) + }) + it('reports errors when bundle path is invalid', async () => { const { doctor } = await import('../../src/index.js') diff --git a/packages/core/test/unit/utils/format.test.ts b/packages/core/test/unit/utils/format.test.ts index 5879161..3ed0027 100644 --- a/packages/core/test/unit/utils/format.test.ts +++ b/packages/core/test/unit/utils/format.test.ts @@ -6,6 +6,7 @@ function makeReport (overrides?: Partial): DoctorReport { return { healthy: true, credentials: { status: 'ok' }, + plugin: { status: 'ok', installed: true, label: 'nsolid-plugin@nodesource' }, skills: { status: 'ok', installed: ['ns-skill-1', 'ns-skill-2'], missing: [] }, mcpServers: { status: 'ok', reachable: ['nsolid-console', 'ns-benchmark'], unreachable: [] }, errors: [], @@ -65,6 +66,40 @@ describe('formatDoctorReport', () => { assert.ok(out.includes('Re-run installation to re-authenticate')) }) + it('shows "✓ installed" Plugin line for an installed native plugin (no color)', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport() + const out = formatDoctorReport(report, 'codex', false) + + assert.ok(out.includes('Plugin ✓ installed (nsolid-plugin@nodesource)')) + }) + + it('shows "✗ not installed" Plugin line with install hint when plugin missing (no color)', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ plugin: { status: 'missing', installed: false }, healthy: false }) + const out = formatDoctorReport(report, 'codex', false) + + assert.ok(out.includes('Plugin ✗ not installed')) + assert.ok(out.includes('codex plugin marketplace add NodeSource/nsolid-plugin')) + }) + + it('shows the Pi install hint for a missing pi plugin', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ plugin: { status: 'missing', installed: false }, healthy: false }) + const out = formatDoctorReport(report, 'pi', false) + + assert.ok(out.includes('Plugin ✗ not installed')) + assert.ok(out.includes('pi install npm:nsolid-pi-plugin')) + }) + + it('omits the Plugin line for a non-native harness (opencode)', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ plugin: { status: 'n/a', installed: false } }) + const out = formatDoctorReport(report, 'opencode', false) + + assert.ok(!out.includes('Plugin')) + }) + it('shows "⚠ partial" on partial skills (no color)', async () => { const { formatDoctorReport } = await import('../../../src/utils/format.js') const report = makeReport({ From 7a233107fd75aa4b935068338a563c1a1b6e0405 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Tue, 30 Jun 2026 13:19:03 +0200 Subject: [PATCH 03/11] Address PR review feedback --- docs/qa-guide.md | 20 ++++++------- packages/core/src/auth/auth-manager.ts | 7 ++++- packages/core/src/harnesses/claude-adapter.ts | 27 +++++++++--------- packages/core/src/harnesses/codex-adapter.ts | 28 ++++--------------- packages/core/src/index.ts | 2 +- packages/core/src/utils/format.ts | 5 +++- .../integration/auth/auth-manager.test.ts | 25 +++++++++++++++++ .../core/test/integration/installer.test.ts | 28 ++++++++++++++++--- packages/core/test/unit/utils/format.test.ts | 9 ++++++ scripts/materialize-github-marketplace.mjs | 2 +- scripts/mcp-wrapper.js | 6 ++-- scripts/plugin-generators.mjs | 4 +-- 12 files changed, 104 insertions(+), 59 deletions(-) diff --git a/docs/qa-guide.md b/docs/qa-guide.md index 29c42c7..9d5753d 100644 --- a/docs/qa-guide.md +++ b/docs/qa-guide.md @@ -112,7 +112,7 @@ Expected: no second browser if credentials are still valid; output says credenti ## 4. CLI doctor / uninstall / logout -Install the opencode mcp and skills firts +Install the opencode mcp and skills first ```bash $CLI install --harness opencode @@ -154,12 +154,12 @@ Expected: ## 6. Claude Code native install QA Claude installs from the GitHub plugin root. The local path can be the repo checkout itself, or a clone for closer-to-release coverage. -We use the branch for now. +Set `PLUGIN_REF` to the branch or tag under test (e.g., `export PLUGIN_REF=cesar/github-install-test`). Production accounts: ```bash -claude plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +claude plugin marketplace add NodeSource/nsolid-plugin@$PLUGIN_REF claude plugin install nsolid-plugin@nodesource $CLI setup --harness claude --yes claude plugin list @@ -169,7 +169,7 @@ claude plugin details nsolid-plugin@nodesource Staging accounts (use this one for the initial QA): ```bash -claude plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +claude plugin marketplace add NodeSource/nsolid-plugin@$PLUGIN_REF claude plugin install nsolid-plugin@nodesource $CLI setup --harness claude --yes --staging claude plugin list @@ -195,12 +195,12 @@ $CLI uninstall --harness claude --keep-credentials || true ## 7. Codex native install QA Codex installs from the GitHub plugin root. The marketplace name is `nodesource`. -We use the branch for now. +Set `PLUGIN_REF` to the branch or tag under test. Production accounts: ```bash -codex plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +codex plugin marketplace add NodeSource/nsolid-plugin@$PLUGIN_REF codex plugin add nsolid-plugin@nodesource $CLI setup --harness codex --yes codex /plugins @@ -209,7 +209,7 @@ codex /plugins Staging accounts (use this one for the initial QA): ```bash -codex plugin marketplace add NodeSource/nsolid-plugin@cesar/github-install-test +codex plugin marketplace add NodeSource/nsolid-plugin@$PLUGIN_REF codex plugin add nsolid-plugin@nodesource $CLI setup --harness codex --yes --staging codex /plugins @@ -235,12 +235,12 @@ If `codex plugin remove nsolid-plugin@nodesource` is unavailable, remove it thro ## 8. Antigravity native install QA Antigravity installs directly from the GitHub plugin root (a git URL). -We use the branch for now. +Set `PLUGIN_REF` to the branch or tag under test. Production accounts: ```bash -agy plugin install https://github.com/nodesource/nsolid-plugin/tree/cesar/github-install-test +agy plugin install https://github.com/nodesource/nsolid-plugin/tree/$PLUGIN_REF agy plugin list $CLI setup --harness antigravity --yes ``` @@ -249,7 +249,7 @@ Staging accounts (use this one for the initial QA): ```bash -agy plugin install https://github.com/nodesource/nsolid-plugin/tree/cesar/github-install-test +agy plugin install https://github.com/nodesource/nsolid-plugin/tree/$PLUGIN_REF agy plugin list $CLI setup --harness antigravity --yes --staging ``` diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index 46ab93f..b5fb004 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -75,10 +75,15 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg } // API unavailable (unreachable, non-JSON response, timeout): trust the // unexpired, origin-matching stored credentials rather than forcing a - // re-login on every setup. Mirrors the optimistic-storage path after + // re-login on every setup, but only if cached permissions satisfy + // required permissions. Mirrors the optimistic-storage path after // OAuth. A 401/403 is NOT thrown — validateToken returns { valid: false } // for that — so revoked tokens still trigger re-authentication below. logger?.warn('auth.credentials.validationUnavailable', { error: (err as Error).message }) + if (required.length > 0) { + const cachedPerms = existing.permissions ?? [] + checkRequiredPermissions(required, cachedPerms) + } return existing } } diff --git a/packages/core/src/harnesses/claude-adapter.ts b/packages/core/src/harnesses/claude-adapter.ts index c2d9507..6bc74a7 100644 --- a/packages/core/src/harnesses/claude-adapter.ts +++ b/packages/core/src/harnesses/claude-adapter.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs' import path from 'node:path' import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' @@ -7,7 +6,6 @@ import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-wri import { readJsonFile } from '../utils/config.js' const PLUGIN_ID = 'nsolid-plugin@nodesource' -const MARKETPLACE_NAME = 'nodesource' export class ClaudeAdapter implements HarnessAdapter { readonly name: HarnessType = 'claude' @@ -39,11 +37,10 @@ export class ClaudeAdapter implements HarnessAdapter { /** * Claude Code records native plugins in * `~/.claude/plugins/installed_plugins.json` and an enable map in - * `~/.claude.json` (`enabledPlugins`). The cloned marketplace lives at - * `~/.claude/plugins/marketplaces//`. The installed_plugins schema has + * `~/.claude.json` (`enabledPlugins`). The installed_plugins schema has * varied across versions (a map or a `{plugins:[...]}` array), so each is - * tolerated; an explicit entry or the marketplace clone counts as installed, - * and `enabled` is set only when an enabled map explicitly lists the id. + * tolerated; an explicit entry counts as installed, and `enabled` is set + * only when an enabled map explicitly lists the id. */ detectNativePlugin (): NativePluginStatus { const status: NativePluginStatus = { installed: false, label: PLUGIN_ID } @@ -65,26 +62,30 @@ export class ClaudeAdapter implements HarnessAdapter { if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) { const map = enabledPlugins as Record if (map[PLUGIN_ID] === true) { - status.installed = true status.enabled = true + } else if (map[PLUGIN_ID] === false) { + status.enabled = false } } } catch { // settings.json unreadable — enabled stays undefined. } - if (!status.installed) { - const clone = path.join(this.getPluginsDir(), 'marketplaces', MARKETPLACE_NAME) - if (existsSync(clone)) status.installed = true - } - return status } } function extractPluginIds (data: unknown): string[] { if (!data || typeof data !== 'object') return [] - if (Array.isArray(data)) return data.filter((v): v is string => typeof v === 'string') + if (Array.isArray(data)) { + return data.flatMap((v) => { + if (typeof v === 'string') return [v] + if (v && typeof v === 'object' && typeof (v as { id?: unknown }).id === 'string') { + return [(v as { id: string }).id] + } + return [] + }) + } const obj = data as Record const arr = obj.plugins diff --git a/packages/core/src/harnesses/codex-adapter.ts b/packages/core/src/harnesses/codex-adapter.ts index 8b337e2..0b411ce 100644 --- a/packages/core/src/harnesses/codex-adapter.ts +++ b/packages/core/src/harnesses/codex-adapter.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs' import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js' import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' @@ -6,7 +5,6 @@ import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-wri import { readTomlFile } from '../utils/config.js' const PLUGIN_KEY = 'nsolid-plugin@nodesource' -const MARKETPLACE_NAME = 'nodesource' export class CodexAdapter implements HarnessAdapter { readonly name: HarnessType = 'codex' @@ -35,9 +33,8 @@ export class CodexAdapter implements HarnessAdapter { * Codex records a native plugin install in `~/.codex/config.toml`: * [plugins."nsolid-plugin@nodesource"] * enabled = true - * and the marketplace under `[marketplaces.nodesource]`, with the cloned - * repo at `~/.codex/.tmp/marketplaces/nodesource/`. Any of those signals - * counts as installed; only the `enabled = true` flag sets `enabled`. + * The entry itself drives `installed`; only the `enabled = true` flag + * sets `enabled`. */ detectNativePlugin (): NativePluginStatus { const status: NativePluginStatus = { installed: false, label: PLUGIN_KEY } @@ -48,28 +45,13 @@ export class CodexAdapter implements HarnessAdapter { const entry = plugins && typeof plugins === 'object' && !Array.isArray(plugins) ? (plugins as Record)[PLUGIN_KEY] : undefined - const enabled = entry && typeof entry === 'object' - ? (entry as { enabled?: unknown }).enabled === true - : false - const marketplaces = data.marketplaces - const hasMarketplace = marketplaces && typeof marketplaces === 'object' && !Array.isArray(marketplaces) - ? Object.prototype.hasOwnProperty.call(marketplaces, MARKETPLACE_NAME) - : false - - if (enabled) { - status.installed = true - status.enabled = true - } else if (hasMarketplace) { + if (entry && typeof entry === 'object') { status.installed = true + status.enabled = (entry as { enabled?: unknown }).enabled === true } } } catch { - // Corrupt or unreadable config — fall through to the on-disk clone check. - } - - if (!status.installed) { - const clone = resolveHome(`~/.codex/.tmp/marketplaces/${MARKETPLACE_NAME}/bundle.json`) - if (existsSync(clone)) status.installed = true + // Corrupt or unreadable config — fall through. } return status diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4c9ffe..69a100d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -613,7 +613,7 @@ export async function doctor ( if (isNativeHarness && adapter.detectNativePlugin) { const detected = adapter.detectNativePlugin() if (detected.installed) { - nativeOwned = true + nativeOwned = detected.enabled !== false report.plugin = { status: 'ok', installed: true, diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index a9ea0f8..37a9593 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -18,7 +18,7 @@ function nativeInstallHint (harness: string): string { case 'codex': return 'codex plugin marketplace add NodeSource/nsolid-plugin && codex plugin add nsolid-plugin@nodesource' case 'antigravity': - return 'agy plugin install https://github.com/NodeSource/nsolid-plugin' + return 'agy plugin install https://github.com/NodeSource/nsolid-plugin.git' case 'pi': return 'pi install npm:nsolid-pi-plugin' default: @@ -46,6 +46,9 @@ function pluginLine (p: DoctorReport['plugin'], harness: string, color: boolean) if (!NATIVE_PLUGIN_HARNESSES.has(harness)) return null if (p.status === 'ok') { const label = p.label ? ` (${p.label})` : '' + if (p.enabled === false) { + return line('Plugin', `⚠ disabled${label}`, C.yellow, 'Enable the plugin in your harness', color) + } return line('Plugin', `✓ installed${label}`, C.green, '', color) } return line('Plugin', '✗ not installed', C.red, nativeInstallHint(harness), color) diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 4f77699..560acb6 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -353,6 +353,31 @@ describe('ensureAuthenticated - requiredPermissions', () => { ) }) + it('throws when validation is unavailable and cached permissions are insufficient', async () => { + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + + const creds: Credentials = { + serviceToken: 'existing-token', + organizationId: 'org-123', + saasToken: 'test-saas-token', + consoleUrl: 'https://test.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + permissions: ['nsolid:benchmark:run'], + } + saveCredentials(creds) + + globalThis.fetch = (async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + await assert.rejects( + ensureAuthenticated(authConfigWithPerms), + /Missing required permissions: nsolid:profile:read/ + ) + }) + it('returns credentials when all required permissions are present', async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 057b215..eeb4484 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -824,18 +824,38 @@ describe('doctor()', () => { assert.deepStrictEqual(report.mcpServers.unreachable, []) }) - it('detects a native Codex plugin from the cloned marketplace bundle', async () => { - // Even without the [plugins.*] enabled flag, the cloned marketplace dir counts. + it('keeps a disabled native Codex plugin on the normal tracking path', async () => { const { doctor } = await import('../../src/index.js') const bundle = createBundle() const bundlePath = writeBundle(bundle) - mkdirSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource'), { recursive: true }) - writeFileSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource', 'bundle.json'), '{}') + mkdirSync(join(tmpDir, '.codex'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', 'config.toml'), [ + '[plugins."nsolid-plugin@nodesource"]', + 'enabled = false', + '', + ].join('\n')) const report = await doctor('codex', bundlePath) assert.strictEqual(report.plugin.status, 'ok') assert.strictEqual(report.plugin.installed, true) + assert.strictEqual(report.plugin.enabled, false) + assert.strictEqual(report.skills.status, 'missing') + assert.strictEqual(report.mcpServers.status, 'unreachable') + }) + + it('does not treat a Codex marketplace clone as a plugin install', async () => { + // A marketplace clone without [plugins.*] entry should not count as installed. + const { doctor } = await import('../../src/index.js') + const bundle = createBundle() + const bundlePath = writeBundle(bundle) + mkdirSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource'), { recursive: true }) + writeFileSync(join(tmpDir, '.codex', '.tmp', 'marketplaces', 'nodesource', 'bundle.json'), '{}') + + const report = await doctor('codex', bundlePath) + + assert.strictEqual(report.plugin.status, 'missing') + assert.strictEqual(report.plugin.installed, false) }) it('detects a native Claude plugin from installed_plugins.json', async () => { diff --git a/packages/core/test/unit/utils/format.test.ts b/packages/core/test/unit/utils/format.test.ts index 3ed0027..ba6bdfc 100644 --- a/packages/core/test/unit/utils/format.test.ts +++ b/packages/core/test/unit/utils/format.test.ts @@ -74,6 +74,15 @@ describe('formatDoctorReport', () => { assert.ok(out.includes('Plugin ✓ installed (nsolid-plugin@nodesource)')) }) + it('shows "⚠ disabled" Plugin line for a disabled native plugin (no color)', async () => { + const { formatDoctorReport } = await import('../../../src/utils/format.js') + const report = makeReport({ plugin: { status: 'ok', installed: true, enabled: false, label: 'nsolid-plugin@nodesource' }, healthy: false }) + const out = formatDoctorReport(report, 'codex', false) + + assert.ok(out.includes('Plugin ⚠ disabled (nsolid-plugin@nodesource)')) + assert.ok(out.includes('Enable the plugin in your harness')) + }) + it('shows "✗ not installed" Plugin line with install hint when plugin missing (no color)', async () => { const { formatDoctorReport } = await import('../../../src/utils/format.js') const report = makeReport({ plugin: { status: 'missing', installed: false }, healthy: false }) diff --git a/scripts/materialize-github-marketplace.mjs b/scripts/materialize-github-marketplace.mjs index 0bf67ec..875bac4 100644 --- a/scripts/materialize-github-marketplace.mjs +++ b/scripts/materialize-github-marketplace.mjs @@ -153,7 +153,7 @@ function generateSharedWrapper () { return generateClaudeWrapper() .replace( "const SETUP_COMMAND = 'npx -y @nodesource/nsolid-plugin setup --harness claude'", - "const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness '" + "const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness '" ) } diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js index fc15719..92d0269 100644 --- a/scripts/mcp-wrapper.js +++ b/scripts/mcp-wrapper.js @@ -8,7 +8,7 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' const AUTH_FILE = path.join(os.homedir(), '.agents', '.nodesource-auth.json') -const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness ' +const SETUP_COMMAND = 'npx -y nsolid-plugin setup --harness ' const SERVER_NAMES = new Set(["nsolid-console","ns-benchmark","ncm"]) const serverName = process.argv[2] @@ -50,8 +50,8 @@ function readCredentials () { function resolveServer (name, credentials) { switch (name) { case 'nsolid-console': { - const derivedUrl = deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) - const url = derivedUrl ?? credentials.mcpUrl + const derivedUrl = credentials.mcpUrl ? null : deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) + const url = credentials.mcpUrl ?? derivedUrl if (!url) { fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND}`) } diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index dff4ede..2933682 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -194,8 +194,8 @@ function readCredentials () { function resolveServer (name, credentials) { switch (name) { case 'nsolid-console': { - const derivedUrl = deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) - const url = derivedUrl ?? credentials.mcpUrl + const derivedUrl = credentials.mcpUrl ? null : deriveMcpUrlFromConsoleUrl(credentials.consoleUrl) + const url = credentials.mcpUrl ?? derivedUrl if (!url) { fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND}\`) } From b481c929f8ec966a00fdeb4d38e33c7a154962a0 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz Date: Thu, 25 Jun 2026 17:18:19 +0200 Subject: [PATCH 04/11] refactor the skills to reduce tokens and improve progresive disclousure patterns --- .claude-plugin/plugin.json | 9 +- bundle.json | 37 +- packages/core/bundle.json | 37 +- .../core/scripts/skill-assets.manifest.json | 17 +- packages/pi-plugin/test/manifest.test.ts | 2 +- skill-assets/save-report.cjs | 227 ---------- .../ns-advanced-memory-leak-hunter/SKILL.md | 67 +-- .../save-report.cjs | 227 ---------- skills/ns-analyze-asset/SKILL.md | 150 ++----- .../fetch-asset.cjs | 0 skills/ns-analyze-asset/save-report.cjs | 227 ---------- skills/ns-analyze-cpu/SKILL.md | 251 ----------- skills/ns-analyze-cpu/save-report.cjs | 227 ---------- skills/ns-analyze-event/SKILL.md | 75 +--- skills/ns-analyze-event/save-report.cjs | 227 ---------- skills/ns-analyze-memory/SKILL.md | 105 ----- skills/ns-analyze-memory/save-report.cjs | 227 ---------- skills/ns-analyze-tracing/SKILL.md | 77 ++-- skills/ns-analyze-vulnerabilities/SKILL.md | 36 +- skills/ns-audit-dependencies/SKILL.md | 90 +--- skills/ns-benchmark-run/SKILL.md | 139 +----- .../reference/benchmark-inputs.md | 91 ++++ skills/ns-benchmark-run/save-report.cjs | 227 ---------- skills/ns-benchmark-validate/save-report.cjs | 227 ---------- skills/ns-cpu-spike-analysis/SKILL.md | 146 ++++++ .../fetch-asset.cjs | 0 .../wait.cjs | 0 .../workspace-delta.cjs | 4 +- skills/ns-generate-asset/SKILL.md | 102 ++--- skills/ns-generate-asset/fetch-asset.cjs | 422 ++++++++++++++++++ .../wait.cjs | 0 skills/ns-generate-sbom/SKILL.md | 25 +- skills/ns-memory-spike-analysis/SKILL.md | 84 ++++ .../ns-memory-spike-analysis/fetch-asset.cjs | 422 ++++++++++++++++++ .../wait.cjs | 0 skills/ns-node-upgrade/SKILL.md | 74 +-- skills/ns-optimize-function/SKILL.md | 50 +++ skills/ns-replace-package/SKILL.md | 54 +-- skills/ns-upgrade-package/SKILL.md | 72 +-- .../SKILL.md | 187 ++------ skills/ns-validate-optimization/wait.cjs | 13 + 41 files changed, 1615 insertions(+), 3039 deletions(-) delete mode 100644 skill-assets/save-report.cjs delete mode 100644 skills/ns-advanced-memory-leak-hunter/save-report.cjs rename skills/{ns-analyze-cpu => ns-analyze-asset}/fetch-asset.cjs (100%) delete mode 100644 skills/ns-analyze-asset/save-report.cjs delete mode 100644 skills/ns-analyze-cpu/SKILL.md delete mode 100644 skills/ns-analyze-cpu/save-report.cjs delete mode 100644 skills/ns-analyze-event/save-report.cjs delete mode 100644 skills/ns-analyze-memory/SKILL.md delete mode 100644 skills/ns-analyze-memory/save-report.cjs create mode 100644 skills/ns-benchmark-run/reference/benchmark-inputs.md delete mode 100644 skills/ns-benchmark-run/save-report.cjs delete mode 100644 skills/ns-benchmark-validate/save-report.cjs create mode 100644 skills/ns-cpu-spike-analysis/SKILL.md rename skills/{ns-analyze-memory => ns-cpu-spike-analysis}/fetch-asset.cjs (100%) rename skills/{ns-analyze-cpu => ns-cpu-spike-analysis}/wait.cjs (100%) rename skills/{ns-analyze-cpu => ns-cpu-spike-analysis}/workspace-delta.cjs (99%) create mode 100644 skills/ns-generate-asset/fetch-asset.cjs rename skills/{ns-analyze-memory => ns-generate-asset}/wait.cjs (100%) create mode 100644 skills/ns-memory-spike-analysis/SKILL.md create mode 100644 skills/ns-memory-spike-analysis/fetch-asset.cjs rename skills/{ns-benchmark-validate => ns-memory-spike-analysis}/wait.cjs (100%) create mode 100644 skills/ns-optimize-function/SKILL.md rename skills/{ns-benchmark-validate => ns-validate-optimization}/SKILL.md (61%) create mode 100644 skills/ns-validate-optimization/wait.cjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9949cb1..d780558 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -13,19 +13,20 @@ "skills": [ "./skills/ns-advanced-memory-leak-hunter", "./skills/ns-analyze-asset", - "./skills/ns-analyze-cpu", "./skills/ns-analyze-event", - "./skills/ns-analyze-memory", "./skills/ns-analyze-tracing", "./skills/ns-analyze-vulnerabilities", "./skills/ns-audit-dependencies", "./skills/ns-benchmark-run", - "./skills/ns-benchmark-validate", + "./skills/ns-cpu-spike-analysis", "./skills/ns-generate-asset", "./skills/ns-generate-sbom", + "./skills/ns-memory-spike-analysis", "./skills/ns-node-upgrade", + "./skills/ns-optimize-function", "./skills/ns-replace-package", - "./skills/ns-upgrade-package" + "./skills/ns-upgrade-package", + "./skills/ns-validate-optimization" ], "mcpServers": "./.claude-mcp.json" } diff --git a/bundle.json b/bundle.json index b030ecc..812eff6 100644 --- a/bundle.json +++ b/bundle.json @@ -15,24 +15,12 @@ "description": "Load and analyze N|Solid saved assets (CPU profiles, heap snapshots)", "requiresMcp": ["nsolid-console"] }, - { - "name": "ns-analyze-cpu", - "path": "skills/ns-analyze-cpu", - "description": "Profile CPU usage of running production processes to identify bottlenecks", - "requiresMcp": ["nsolid-console"] - }, { "name": "ns-analyze-event", "path": "skills/ns-analyze-event", "description": "Analyze event loop delays and identify blocking operations in production", "requiresMcp": ["nsolid-console"] }, - { - "name": "ns-analyze-memory", - "path": "skills/ns-analyze-memory", - "description": "Take and analyze heap snapshots of running production memory", - "requiresMcp": ["nsolid-console"] - }, { "name": "ns-analyze-tracing", "path": "skills/ns-analyze-tracing", @@ -58,10 +46,10 @@ "requiresMcp": ["ns-benchmark"] }, { - "name": "ns-benchmark-validate", - "path": "skills/ns-benchmark-validate", - "description": "Validate benchmark results against established performance baselines", - "requiresMcp": ["ns-benchmark"] + "name": "ns-cpu-spike-analysis", + "path": "skills/ns-cpu-spike-analysis", + "description": "Profile CPU usage of running production processes to identify bottlenecks", + "requiresMcp": ["nsolid-console"] }, { "name": "ns-generate-asset", @@ -75,12 +63,23 @@ "description": "Generate Software Bill of Materials for project dependency provenance", "requiresMcp": ["ncm"] }, + { + "name": "ns-memory-spike-analysis", + "path": "skills/ns-memory-spike-analysis", + "description": "Take and analyze heap snapshots of running production memory", + "requiresMcp": ["nsolid-console"] + }, { "name": "ns-node-upgrade", "path": "skills/ns-node-upgrade", "description": "Plan and execute Node.js version upgrades safely", "requiresMcp": ["ncm"] }, + { + "name": "ns-optimize-function", + "path": "skills/ns-optimize-function", + "description": "Optimize a selected Node.js function: assess impact, benchmark baseline, propose and validate an optimization" + }, { "name": "ns-replace-package", "path": "skills/ns-replace-package", @@ -92,6 +91,12 @@ "path": "skills/ns-upgrade-package", "description": "Safely upgrade npm packages with compatibility analysis", "requiresMcp": ["ncm"] + }, + { + "name": "ns-validate-optimization", + "path": "skills/ns-validate-optimization", + "description": "Validate benchmark results against established performance baselines", + "requiresMcp": ["ns-benchmark"] } ], "mcpServers": [ diff --git a/packages/core/bundle.json b/packages/core/bundle.json index b030ecc..812eff6 100644 --- a/packages/core/bundle.json +++ b/packages/core/bundle.json @@ -15,24 +15,12 @@ "description": "Load and analyze N|Solid saved assets (CPU profiles, heap snapshots)", "requiresMcp": ["nsolid-console"] }, - { - "name": "ns-analyze-cpu", - "path": "skills/ns-analyze-cpu", - "description": "Profile CPU usage of running production processes to identify bottlenecks", - "requiresMcp": ["nsolid-console"] - }, { "name": "ns-analyze-event", "path": "skills/ns-analyze-event", "description": "Analyze event loop delays and identify blocking operations in production", "requiresMcp": ["nsolid-console"] }, - { - "name": "ns-analyze-memory", - "path": "skills/ns-analyze-memory", - "description": "Take and analyze heap snapshots of running production memory", - "requiresMcp": ["nsolid-console"] - }, { "name": "ns-analyze-tracing", "path": "skills/ns-analyze-tracing", @@ -58,10 +46,10 @@ "requiresMcp": ["ns-benchmark"] }, { - "name": "ns-benchmark-validate", - "path": "skills/ns-benchmark-validate", - "description": "Validate benchmark results against established performance baselines", - "requiresMcp": ["ns-benchmark"] + "name": "ns-cpu-spike-analysis", + "path": "skills/ns-cpu-spike-analysis", + "description": "Profile CPU usage of running production processes to identify bottlenecks", + "requiresMcp": ["nsolid-console"] }, { "name": "ns-generate-asset", @@ -75,12 +63,23 @@ "description": "Generate Software Bill of Materials for project dependency provenance", "requiresMcp": ["ncm"] }, + { + "name": "ns-memory-spike-analysis", + "path": "skills/ns-memory-spike-analysis", + "description": "Take and analyze heap snapshots of running production memory", + "requiresMcp": ["nsolid-console"] + }, { "name": "ns-node-upgrade", "path": "skills/ns-node-upgrade", "description": "Plan and execute Node.js version upgrades safely", "requiresMcp": ["ncm"] }, + { + "name": "ns-optimize-function", + "path": "skills/ns-optimize-function", + "description": "Optimize a selected Node.js function: assess impact, benchmark baseline, propose and validate an optimization" + }, { "name": "ns-replace-package", "path": "skills/ns-replace-package", @@ -92,6 +91,12 @@ "path": "skills/ns-upgrade-package", "description": "Safely upgrade npm packages with compatibility analysis", "requiresMcp": ["ncm"] + }, + { + "name": "ns-validate-optimization", + "path": "skills/ns-validate-optimization", + "description": "Validate benchmark results against established performance baselines", + "requiresMcp": ["ns-benchmark"] } ], "mcpServers": [ diff --git a/packages/core/scripts/skill-assets.manifest.json b/packages/core/scripts/skill-assets.manifest.json index ca21132..961d5b0 100644 --- a/packages/core/scripts/skill-assets.manifest.json +++ b/packages/core/scripts/skill-assets.manifest.json @@ -2,23 +2,24 @@ "wait.cjs": [ "ns-advanced-memory-leak-hunter", "ns-analyze-asset", - "ns-analyze-cpu", - "ns-analyze-memory", "ns-benchmark-run", - "ns-benchmark-validate" + "ns-cpu-spike-analysis", + "ns-generate-asset", + "ns-memory-spike-analysis", + "ns-validate-optimization" ], "save-report.cjs": [ "ns-advanced-memory-leak-hunter", "ns-analyze-asset", - "ns-analyze-cpu", "ns-analyze-event", - "ns-analyze-memory", "ns-benchmark-run", - "ns-benchmark-validate" + "ns-cpu-spike-analysis", + "ns-memory-spike-analysis", + "ns-validate-optimization" ], "fetch-asset.cjs": [ "ns-advanced-memory-leak-hunter", - "ns-analyze-cpu", - "ns-analyze-memory" + "ns-cpu-spike-analysis", + "ns-memory-spike-analysis" ] } diff --git a/packages/pi-plugin/test/manifest.test.ts b/packages/pi-plugin/test/manifest.test.ts index eaf4048..a892b6b 100644 --- a/packages/pi-plugin/test/manifest.test.ts +++ b/packages/pi-plugin/test/manifest.test.ts @@ -33,7 +33,7 @@ describe('Pi plugin', () => { it('uses canonical root skills instead of committed package skill copies', () => { assert.strictEqual(existsSync(path.resolve('packages/pi-plugin/skills')), false, 'source tree should not keep generated package skill copies') assert.strictEqual(existsSync(path.resolve('packages/core/skills')), false, 'source tree should not keep generated core package skill copies') - assert.ok(existsSync(path.resolve('skills/ns-analyze-cpu/SKILL.md')), 'canonical root skill must exist') + assert.ok(existsSync(path.resolve('skills/ns-analyze-asset/SKILL.md')), 'canonical root skill must exist') }) it('index.js exists and has no install/setup side effects', () => { diff --git a/skill-assets/save-report.cjs b/skill-assets/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skill-assets/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-advanced-memory-leak-hunter/SKILL.md b/skills/ns-advanced-memory-leak-hunter/SKILL.md index ecb208a..216da38 100644 --- a/skills/ns-advanced-memory-leak-hunter/SKILL.md +++ b/skills/ns-advanced-memory-leak-hunter/SKILL.md @@ -1,60 +1,42 @@ --- name: ns-advanced-memory-leak-hunter description: >- - Advanced multi-phase memory leak hunting for elusive Node.js leaks using - N|Solid MCP. Use when the user reports a recurring memory leak, staircase - heap pattern, retained objects, or when a standard memory analysis was - inconclusive. Performs baseline-vs-peak delta analysis across multiple - heap samples to isolate the exact leaking constructor or closure. + Performs an advanced N|Solid memory leak hunt comparing baseline vs peak heap evidence to isolate retained constructors, closures, and retainer paths. Use when the user reports recurring/staircase memory growth, elusive leaks, retained objects, closures holding references, or a standard heap sample/snapshot analysis was inconclusive. Prefer supplied baseline/peak assets; capture new samples only when needed. --- -# NodeSource Advanced Memory Leak Hunter - -You are a Node.js Memory Whisperer. You don't just take single snapshots — -you think in terms of deltas, allocations over time, and retained heap curves. - -## Instructions - -### Phase 0: Reuse Supplied Evidence First -1. Parse the prompt for provided baseline and peak asset IDs, local heap files, - heap summaries, app name, agent ID, or time windows. -2. If the user already supplied both baseline and peak evidence, skip straight - to Phase 4. -3. If the user supplied only one side of the comparison, reuse that side and - capture only the missing phase. -4. If the user explicitly says read-only or "analyze these assets", do not take - new samples unless they later approve it. +### Phase 0: Reuse Evidence and Resolve Target +1. If the user already supplied both baseline and peak evidence, skip straight to Phase 4. +2. If no agent `id` is supplied, call `information-dashboard` (`q: "app=<appName>"`, `start: "5m"` when app is known) to resolve the required agent `id`; preserve the exact `appName` for downloads. Stop if no matching agent is connected. ### Phase 1: Establish the Baseline -1. Identify the target application suspected of leaking memory. -2. Take an initial low-overhead heap sample using `heap-sampling` (duration: `30` seconds). -3. Run the wait script using the absolute path of the directory where you read this SKILL.md: +1. Take an initial low-overhead heap sample using `heap-sampling` (`id: <agentId>`, `duration: 30`). +2. Run the wait script using the absolute path of the directory where you read this SKILL.md: ``` node "<skill-dir>/wait.cjs" 30 ``` -4. Call `assets-in-progress` to ensure generation is done. If still in progress, run `wait.cjs 10` and check again. -5. Pull the baseline summary using `asset-summary`. Note the top allocating constructors (e.g., `Object`, `Array`, `system / Map`). -6. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same baseline asset ID. If it is already present locally, reuse it and skip the download. -7. If the baseline asset is not present, save it locally: +3. Call `asset-summary` on the returned baseline asset ID. If not ready, wait 10s and retry; use `assets-in-progress` only as a secondary queue clue. +4. From the baseline summary, note the top allocating constructors (e.g., `Object`, `Array`, `system / Map`). +5. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same baseline asset ID. If it is already present locally, skip the download. +6. If the baseline asset is not present, save it locally: ``` node "<skill-dir>/fetch-asset.cjs" <baselineAssetId> heapprofile <appName> ``` ### Phase 2: Monitor RSS and Heap Growth -1. Using `metrics-historic` (fields: `rss`, `heapUsed`, `heapTotal`, `loopEstimatedLag`), monitor the target process over a rolling 5-minute to 1-hour window (`start: 1h`). +1. Call `metrics-historic` with `field: ["rss", "heapUsed", "heapTotal", "loopEstimatedLag"]`, `q: "app=<appName>"` or `q: "id=<id>"`, and `start: "1h"`. 2. Search for the "Sawtooth Pattern" (normal garbage collection) vs "Staircase Pattern" (unreleased memory). 3. Identify the moment where `heapUsed` stops dropping to its original baseline. ### Phase 3: Capture the Peak / Leak State 1. Once you confirm memory has substantially grown from the baseline, trigger a second analysis. -2. Use `track-heap-objects` if you suspect closures/retainers, otherwise standard `heap-sampling` for 60 seconds. Only use a full `snapshot` if absolutely necessary and the app is <256MB. +2. Use advanced `track-heap-objects` only for closure/retainer suspicion; otherwise use `heap-sampling` for 60 seconds. Both require the resolved agent `id`. 3. Wait for the operation to complete: ``` node "<skill-dir>/wait.cjs" 60 ``` - Then call `assets-in-progress`. If still generating, run `wait.cjs 10` and check again. -4. Pull the `asset-summary`. -5. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same peak asset ID. If it is already present locally, reuse it and skip the download. + Then call `asset-summary` on the peak asset ID. If not ready, wait 10s and retry; use `assets-in-progress` only as a secondary queue clue. +4. Analyze the `asset-summary`. +5. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same peak asset ID. If it is already present locally, skip the download. 6. If the peak asset is not present, save it locally: ``` node "<skill-dir>/fetch-asset.cjs" <peakAssetId> heapprofile <appName> @@ -71,10 +53,10 @@ you think in terms of deltas, allocations over time, and retained heap curves. - Extraction will fail if `scriptId` is `0`. - If the process runs in Docker, try tweaking the path up to two times. If it still fails, ask the user to provide the source code. 3. **Human in the loop**: Present the problematic code and root cause to the user. Ask: *"I've isolated the cause of the memory leak in this function. Would you like me to propose a fix?"* -4. Only after user approval, propose an optimized rewrite and use the `ns-benchmark-validate` skill to verify the fix. +4. Only after user approval, propose an optimized rewrite and use the `ns-validate-optimization` skill to verify the fix. ### Phase 6: Write a Report -1. Write the full report as markdown to a temporary file (e.g. `/tmp/nsolid-report-leak.md`) using this structure: +1. Compose the full report as markdown using this structure: ```markdown # Memory Leak Hunt Report — <appName> **Date**: <ISO date> @@ -110,15 +92,12 @@ you think in terms of deltas, allocations over time, and retained heap curves. - Baseline heap profile: `.nsolid/assets/heapprofile-<appName>-<baselineAssetIdPrefix>.heapprofile` - Peak heap profile: `.nsolid/assets/heapprofile-<appName>-<peakAssetIdPrefix>.heapprofile` ``` -2. Run the save-report script to persist the report and register it in the metadata index: - ``` - node "<skill-dir>/save-report.cjs" memory-analysis "Memory Leak Hunt Report — <appName>" /tmp/nsolid-report-leak.md <appName> - ``` -3. The script prints the saved path. Tell the user: *"Report saved to `.nsolid/assets/`. Both baseline and peak heap profiles are available in `.nsolid/assets/`."* + +### 7. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/memory-analysis-<appName>.md`. ## Guardrails - **No early assumptions**: Never declare a memory leak from a single snapshot. Always compare baseline to peak. -- **Reuse what exists**: Do not capture a new baseline or peak sample if the - user already supplied the needed assets. -- **Wait times**: Memory tools block the thread. Do not spam endpoints while an asset is in progress. -- **Snapshot size limit**: `asset-summary` requires two API calls for snapshots, and will fail on dumps >256MB. +- **Reuse what exists**: Do not capture a new baseline or peak sample if the user already supplied the needed assets. +- **Wait times**: Memory tools block the thread. Do not spam endpoints while an asset is in progress. \ No newline at end of file diff --git a/skills/ns-advanced-memory-leak-hunter/save-report.cjs b/skills/ns-advanced-memory-leak-hunter/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-advanced-memory-leak-hunter/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-analyze-asset/SKILL.md b/skills/ns-analyze-asset/SKILL.md index 992a744..6fd8fc1 100644 --- a/skills/ns-analyze-asset/SKILL.md +++ b/skills/ns-analyze-asset/SKILL.md @@ -1,93 +1,66 @@ --- name: ns-analyze-asset description: >- - Analyze an already-existing N|Solid asset from either an asset ID or a local - downloaded file path. Prefer MCP asset-summary for token-efficient analysis, - but fall back to the local file when MCP is unavailable. Supports CPU - profiles, heap profiles, heap samples, and heap snapshots. + Analyzes an existing N|Solid diagnostic asset by asset ID or local asset path using asset-summary. Use when the user asks to inspect, review, summarize, or interpret an already-captured CPU profile, heap profile, heap sample, or heap snapshot, including assets just produced by ns-generate-asset. Do not use to capture new profiles/snapshots. --- -# NodeSource Asset Analysis - -You are a NodeSource diagnostics engineer analyzing an asset the user already -has. Do not capture a new profile unless the user explicitly asks for that. - ## Instructions ### 1. Identify the Asset -- The user should provide an asset ID, a local file path, or both. -- If only a local file path is provided (no asset ID), proceed directly to - step 2 using the local file. -- If the user only gives an app name or asset type, call `assets` with filters - and ask the user which asset to inspect. -- Record the asset ID (or `unavailable`), local file path (or `unavailable`), - app name, and likely asset type. +- The user should provide an asset ID or an app name with asset type. +- Preserve the app name from the user, `assets`, or `information-dashboard`; `asset-summary` does not include it. +- If the user only gives an app name and asset type, call `assets` with filters and ask the user which asset to inspect. ### 2. Get the Best Available Summary #### CPU profiles and heap sampling assets -- Prefer `asset-summary` first when you have an asset ID. These asset types - return immediately. -- If MCP is unavailable or `asset-summary` fails, read the local file and use - that content as analysis input. +- Prefer `asset-summary` first when you have an asset ID. These asset types return immediately. #### Heap snapshots (async summarization) -Heap snapshot summarization is asynchronous and may not be ready on the first -call. Use this retry loop: +Heap snapshot summarization is asynchronous and may not be ready on the first call. Use this retry loop: 1. Call `asset-summary` with the asset ID. -2. If the response body contains `"processing"`, `"summarization started"`, - or a reference to `"assets-in-progress"`, the snapshot is still being - summarized — do NOT analyze it yet. +2. If the response body contains `"processing"`, `"summarization started"`, or a reference to `"assets-in-progress"`, the snapshot is still being summarized — do NOT analyze it yet. 3. Call `assets-in-progress` to check the queue position. -4. If `nsolid_wait` is available, call it with `{ "seconds": 5 }`. Otherwise - run: +4. Run the wait script (use the absolute path of the directory where you read this SKILL.md): ``` node "<skill-dir>/wait.cjs" 5 ``` before retrying. 5. Call `asset-summary` again on the same asset ID. 6. Repeat steps 3–5 up to **12 times** before giving up. -7. If still not ready after 12 retries, report that clearly instead of - analyzing a stale or empty summary. +7. If still not ready after 12 retries, report that clearly instead of analyzing a stale or empty summary. **Never analyze a heap snapshot summary that is still marked as processing.** -#### Local file only (no asset ID) -- Read the local file directly and use it as the grounded input. -- Accept `.cpuprofile`, `.heapprofile`, `.heapsampling`, `.heapsnapshot`, - and raw `.json` files. -- If the file is unreadable and MCP is unavailable, state that clearly and stop. +#### No asset ID +- If the user gives a local `.cpuprofile`, `.heapprofile`, or `.heapsnapshot` path instead of an asset ID, resolve the full asset ID from `.nsolid/assets/index.json` or from the filename pattern `.nsolid/assets/<assetType>-<appName>-<assetIdPrefix>.<ext>`, then analyze it with `asset-summary`. +- **Never read the raw asset file into context** — it is large and token-wasteful. Always go through the token-optimized `asset-summary`. +- If the asset ID cannot be resolved and MCP is unavailable, state that clearly and stop. ### 3. Analyze by Asset Type #### CPU Profile - Find the functions with the highest `totalTime` and `selfTime`. - Explain the hot path and the most expensive bottleneck. -- Focus on user-owned code. If the top cost is in Node internals or - `node_modules`, explain the nearest relevant user-owned caller instead. +- Focus on user-owned code. If the top cost is in Node internals or `node_modules`, explain the nearest relevant user-owned caller instead. #### Heap Profile or Heap Sample - Identify top allocating constructors by self size and retained size. -- Call out suspicious allocation patterns (e.g. unusually large arrays, - many short-lived objects of the same type). +- Call out suspicious allocation patterns (e.g. unusually large arrays, many short-lived objects of the same type). #### Heap Snapshot Inspect the summary for these signals: -**Top retained-size objects** — list the largest objects by retained size. -Explain what type they are and why they are still reachable. +**Top retained-size objects** — list the largest objects by retained size. Explain what type they are and why they are still reachable. -**Dominator chains** — identify the shortest path from GC roots to the -largest retained objects. Explain what is holding references. +**Dominator chains** — identify the shortest path from GC roots to the largest retained objects. Explain what is holding references. -**Retainer paths** — for the top retained objects, trace back to the closest -named user-owned code (module, function, or variable name). +**Retainer paths** — for the top retained objects, trace back to the closest named user-owned code (module, function, or variable name). **Common leak patterns to flag:** - Closures holding references to large outer scopes that outlive their use. -- `EventEmitter` listeners added but never removed (`removeListener` / - `off` missing). +- `EventEmitter` listeners added but never removed (`removeListener` / `off` missing). - Unbounded `Map`, `Set`, or plain object caches that grow without eviction. - Promise chains retaining intermediate values after resolution. - `setInterval` or `setTimeout` callbacks capturing large context. @@ -96,86 +69,61 @@ named user-owned code (module, function, or variable name). - Interned string tables or template literal accumulation. **Confirm leak vs one-off spike:** -- Call `metrics-historic` for `heapUsed`, `heapTotal`, and `rss` around - the snapshot time to see whether heap was trending upward before the - snapshot was taken. -- Check `events-historic` for near-OOM alerts or process-blocked events - that coincide with the snapshot time. +- Call `metrics-historic` with `field: ["heapUsed", "heapTotal", "rss"]`, `q: "app=<appName>"` or `q: "id=<id>"`, and `start`/`end` around the snapshot time. +- Check `events-historic` with the same `app` or agent `id`, plus `start`/`end`, for near-OOM or process-blocked events at the snapshot time. **Follow-up recommendation:** -- If the snapshot provides insufficient allocation stack traces, recommend - `track-heap-objects` to capture allocation origins with low overhead. -- For deeper leak hunting workflows, reference the - `ns-advanced-memory-leak-hunter` skill. +- If allocation stack traces are insufficient, recommend advanced `track-heap-objects` via `ns-advanced-memory-leak-hunter`. +- For deeper leak hunting workflows, reference the `ns-advanced-memory-leak-hunter` skill. ### 4. Correlate with Runtime Context -- If MCP is available, call `information-dashboard` to confirm the app, agent, - hostname, and whether the originating process still exists. -- Call `metrics-historic` around the asset time to correlate CPU, heap, or - event-loop behavior with the findings. +- If MCP is available, call `information-dashboard` to confirm the app, agent, hostname, and whether the originating process still exists. +- Call `metrics-historic` around the asset time with explicit `field` values and `q: "app=<appName>"` or `q: "id=<id>"`. ### 5. Extract Runtime Code for CPU Bottlenecks - Only do this for CPU profiles and only if MCP is available. -- After identifying the hottest user-owned frame (function name, `scriptId`, - `url`), offer to call `runtime-code`. +- After identifying the hottest user-owned frame, offer `runtime-code` only when you have agent `id`, `threadId`, `scriptId`, and `url`/`path`. - Do not call `runtime-code` when `scriptId` is `0`. -- Retry up to 2 times with path adjustments if the first call fails - (strip leading `/app` or Docker prefix, or remove a path segment). +- Retry up to 2 times with path adjustments if the first call fails (strip leading `/app` or Docker prefix, or remove a path segment). ### 6. Keep the User in the Loop - Present the key findings first. -- Ask whether the user wants an optimized solution before proposing code - changes (CPU profiles only). +- Ask whether the user wants an optimized solution before proposing code changes (CPU profiles only). -### 7. Write a Report -Emit the analysis directly in chat using this exact structure so the host -extension can locate and persist it: +### 7. Present a Report +Emit the analysis directly in chat using this exact structure and formatting: ```markdown # Asset Analysis — <asset label> **Date**: <ISO date> -**Asset ID**: <asset id or unavailable> -**Local File**: <path or unavailable> -**Source**: <MCP asset-summary | local file> +**Asset ID**: <asset id> +**Source**: asset-summary ## Analysis <findings, observations, and next steps — start here directly without - repeating the title, asset ID, local file path, or source label> + repeating the title, asset ID, or source label> ``` Rules: - Start the `## Analysis` section directly with findings — no preamble. -- Do not repeat the title, asset ID, local file, or source label inside the body. +- Do not repeat the title, asset ID, or source label inside the body. - Do not wrap the final answer in triple backticks or any fenced code block. -- Do not invent sample counts, timings, function names, object names, or - conclusions that are not present in the grounded input. +- Do not invent sample counts, timings, function names, object names, or conclusions that are not present in the grounded input. - If the grounded input is insufficient for a claim, say that plainly. -- In participant or host-managed flows, render this markdown inline and let the - host persist it automatically. -- In generic-agent flows, write the final markdown report to a temporary file - and run: - ``` - node "<skill-dir>/save-report.cjs" asset-analysis "Asset Analysis — <asset label>" /tmp/nsolid-asset-analysis.md - ``` - -### 8. Validate When Optimization Is Proposed -- If you end up optimizing CPU-bound code, use the `ns-benchmark-validate` skill to - prove the change. - -## Tools -- `assets` -- `asset-summary` -- `assets-in-progress` -- `information-dashboard` -- `metrics-historic` -- `events-historic` -- `runtime-code` -- `track-heap-objects` + +### 8. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/asset-analysis-<asset id>.md`. + +### 9. Offer Next Steps +- For CPU profiles, offer to propose code changes for the hottest user-owned function. +- Ask the users if they want to download the asset, if they confirm download, save it locally: + ``` + node "<skill-dir>/fetch-asset.cjs" <assetId> <assetType> <appName> + ``` + AssetType is one of: `cpuprofile`, `heapprofile`, or `heapsnapshot`; heap sampling assets use `heapprofile`. ## Guardrails -- Do not download raw assets when `asset-summary` already gives enough signal. -- Never analyze a heap snapshot that is still marked as processing or pending. - Poll and wait until `asset-summary` returns the actual summarized content. -- Do not assume the local file is CPU-only; all heap asset types are supported. -- Do not invent findings. If the asset summary lacks detail, say so explicitly. +- Never analyze a heap snapshot that is still marked as processing or pending. Poll and wait until `asset-summary` returns the actual summarized content. +- Do not lie about findings. If the asset summary lacks detail, say so explicitly. diff --git a/skills/ns-analyze-cpu/fetch-asset.cjs b/skills/ns-analyze-asset/fetch-asset.cjs similarity index 100% rename from skills/ns-analyze-cpu/fetch-asset.cjs rename to skills/ns-analyze-asset/fetch-asset.cjs diff --git a/skills/ns-analyze-asset/save-report.cjs b/skills/ns-analyze-asset/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-analyze-asset/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-analyze-cpu/SKILL.md b/skills/ns-analyze-cpu/SKILL.md deleted file mode 100644 index 5ebdc7b..0000000 --- a/skills/ns-analyze-cpu/SKILL.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: ns-analyze-cpu -description: >- - Diagnose high CPU usage in Node.js applications using user-provided evidence - or live V8 CPU profiles from N|Solid MCP. Use when the user mentions: high - CPU, CPU spike, CPU usage, slow endpoint, slow function, flamegraph, - profiling, optimize function, slow loop, or "why is my app slow". Prefer - existing assets, summaries, or trace data before capturing a new profile. ---- - -# NodeSource CPU Analysis - -You are a NodeSource Performance Engineer. You demand cold, hard data — no -guessing. Prefer the user's supplied evidence first. Only capture fresh data -when the current evidence is insufficient and the user wants a live -investigation. Telemetry alerts that name an affected app count as enough -authorization to run the standard live CPU workflow unless the user explicitly -says read-only, offline, or no-capture. - -## Instructions - -### 1. Use Provided Evidence First -- Treat the prompt content as primary evidence. Parse any provided app name, - agent ID, hostname, time window, asset ID, local file path, CPU summary, - flamegraph excerpt, hot function list, or stack trace before calling tools. -- If the prompt already includes an asset ID, local `.cpuprofile` path, or - structured CPU summary, analyze that evidence first instead of starting with - `information-dashboard` or `metrics-historic`. -- If the prompt is a telemetry alert such as "CPU spike 121.1% in app X", use - that app name as authoritative scope and immediately run the live workflow: - identify the hottest connected agent inside that app, capture a 30-second CPU - profile, summarize it, download it, and fetch runtime code when possible. -- In telemetry-alert mode, do not stop after rediscovering the same spike and - do not ask for capture approval unless the prompt explicitly says read-only, - offline, or no-capture. -- If the user explicitly says read-only, offline, or "analyze this data", never - capture a new profile unless they later approve it. - -### 2. Discover Connected Agents Only When Needed -- Call `information-dashboard` (no parameters) to list all connected agents. -- Note each agent's `id`, `app` name, and `hostname`. -- Do NOT call `global-filter` — it returns ~18,000 tokens and will fill the context window. -- Skip this step when the provided evidence already names the exact agent or - already includes a CPU profile asset or local file. -- If the user already named a specific app, treat that app name as authoritative. -- When an alert, warning, or telemetry card names an app, do NOT switch to a different app just because it has higher CPU elsewhere. -- If the prompt names a worker, hostname, or process hint, prefer the matching agent inside that same app. - -### 3. Find the Bottleneck Only If You Still Need a Target -- If the user named a specific app, call `metrics-historic` for that app and identify the hottest connected agent inside that app only. -- If the user did not name an app, call `metrics-historic` to query `cpuUserPercent` and `cpuSystemPercent` fields (`start: "5m"`) and identify the agent `id` with the highest overall CPU usage. -- If a telemetry warning included a recent spike value or timeframe, bias the query window around that warning instead of doing a generic search. -- If no agent for the named app is connected, stop and say that clearly instead of profiling a different app. -- If the user already supplied a usable profile, asset summary, or local file, - skip this step. -- When several agents are connected for the same app, choose the agent with the - highest recent CPU inside that app. Record its `id`, `hostname`, and the CPU - evidence that justified the choice. - -### 4. Reuse Existing Assets Before Capturing -- If the prompt includes an asset ID, call `asset-summary` first. -- If the prompt includes a local `.cpuprofile` path, analyze that local file. -- If the prompt includes both, prefer `asset-summary` and use the local file as - fallback. -- If you can identify the bottleneck from the supplied evidence, stop there and - explain it. Do not capture a second profile unless the user asks. - -### 5. Capture a 30-Second Profile -- Call `profile` on that `id` with `duration: 30` and `threadId: 0`. -- Note the returned `id` (Asset ID). -- Only skip this capture when the prompt already supplied a reusable CPU - profile, asset summary, or local `.cpuprofile`, or when the user explicitly - requested read-only/offline analysis. -- For telemetry-alert mode, this 30-second profile capture is the default path. - -### 6. Wait (Critical) -- After starting the standard 30-second CPU profile, call the `nsolid_wait` - tool with `{ "seconds": 35 }` so the capture has time to finish and upload. -- If you used a different profile duration, wait that duration plus 5 seconds. -- Do NOT use shell commands, `node wait.cjs`, or `setTimeout`. The only way to - wait inside this skill is the `nsolid_wait` tool. - -### 7. Check Readiness Using the Exact Asset ID -- Call `asset-summary` using the exact Asset ID returned by `profile`. -- If `asset-summary` says the asset is not ready yet, call `nsolid_wait` with - `{ "seconds": 5 }` and retry `asset-summary` on that same Asset ID. -- Retry at most 2 short waits after the initial 35-second wait. If the asset is - still not ready, explain that clearly rather than looping indefinitely. -- Do NOT use `assets-in-progress` as the normal readiness check for CPU - profiles. It is a global queue and may report unrelated assets. - -### 8. Summarize the Profile -- Once `asset-summary` succeeds, use its token-optimized JSON view as the - grounded source for the rest of the workflow. -- Do not stop after `asset-summary`; continue the workflow with full profile - download and runtime code extraction. - -### 9. Save the Full Profile -- Call the `nsolid_downloadAsset` tool with `{ "assetId": "<id>", "kind": - "cpuprofile", "appName": "<app>" }`. The tool is idempotent: if the asset is - already present in `.nsolid/assets/index.json`, it returns the existing path - without re-downloading. -- Do NOT use shell commands, `node fetch-asset.cjs`, or direct HTTP calls. - `nsolid_downloadAsset` is the only supported download path inside this skill. -- The tool writes to `.nsolid/assets/cpuprofile-<appName>-<assetIdPrefix>.cpuprofile` - and updates the index automatically. - -### 10. Identify the Culprit -- Analyze the summary JSON or local profile data. Identify the function - (`functionName`), `scriptId`, and file path (`url`) consuming the highest - `totalTime` or `selfTime`. Explain this to the user. -- Focus the diagnosis on the hottest relevant **user-owned** frame. -- If the top cost is in Node internals or a dependency, report that clearly as - evidence, but walk down or up the hot path to the nearest meaningful - user-owned caller so the user gets an actionable explanation. -- If a dependency is causing the cost, explain how the user's code is invoking - it, feeding it, or calling it too often. Do not treat dependency source as - the optimization target. -- If the current evidence is insufficient to isolate a function, say exactly - what is missing instead of pretending you have a bottleneck. - -### 11. Extract Runtime Code -- After identifying the hottest relevant **user-owned** frame, call - `runtime-code` using the agent `id`, `threadId`, `scriptId`, and `url` (as - the path) to extract the exact JavaScript source code from the V8 runtime. -- Prefer the hottest non-internal application frame. If the top frame is V8 or - Node internals, walk down to the hottest frame that points to the user app. -- NEVER fetch or present runtime code for Node internals or dependency code. - If the hottest frame is under `node_modules`, `node:`, or internal runtime - paths, explain the dependency/internal cost and move to the nearest relevant - user-owned caller instead of extracting dependency source. -- The `runtime-code` response is raw source material. Before presenting it in - the report, keep only the most relevant parts needed to explain the CPU - problem in the app and to ground the optimization proposal. -- Include the hot function and any nearby helpers, branches, loops, constants, - or call sites that materially affect the bottleneck. -- Exclude unrelated module setup, imports, exports, sibling functions, or large - sections of the file that do not help explain the issue. -- Do not force an artificially tiny excerpt. Keep enough surrounding context to - make the diagnosis and recommendation understandable. -- Retry up to 2 times with path tweaks if the first call fails: - - Try stripping a leading `/app` or `/usr/src/app` Docker prefix. - - Try removing a leading path segment one level at a time. - - If still failing after 2 tweaks, skip and proceed to step 12 with a note - that runtime code was unavailable. -- **Edge cases**: - - If `scriptId` is `0`, skip this step entirely — extraction will fail. - - If the process is Dockerized, the `path` may be misaligned; apply the path - tweaks above before giving up. - -### 12. Compare Runtime Code to Workspace Source -- Prefer the `workspace_delta` tool when it is available. Pass the profiled app - name, runtime path, runtime code, and the best line range or line hint you - have for the hot function. -- If `workspace_delta` reports that the workspace does not match the profiled - app, skip comparison and state that clearly in the report. -- If `workspace_delta` is unavailable but shell execution is allowed, use the - bundled same-directory helper: - ``` - node "<skill-dir>/workspace-delta.cjs" <json-file-or-stdin> - ``` - It accepts JSON via stdin or a file path and prints the comparison result as - JSON. -- If neither the tool nor the bundled helper is available, perform a - conservative manual fallback: verify that the current workspace corresponds - to the target app before comparing any local code, and skip comparison if - identity is uncertain. -- Only attempt this step for user-owned application code. If the hot frame is a - dependency or Node internal path, skip comparison and say that the - performance issue originates outside user-owned source. -- If the file maps cleanly to the workspace, compare the local copy to the - runtime version. Note real differences such as added logic, removed guards, - changed thresholds, or refactors. -- Include a "Workspace Delta" section in the final report with the actual diff - content when a comparison was possible. -- If the file does not map to a local workspace file, or app identity could not - be proven safely, say so explicitly instead of guessing. - -### 13. Present the Full Report -- Structure the final report with these sections: - 1. **Executive Summary** — one paragraph stating the CPU problem and root cause. - 2. **Top CPU Consumers** — table with: `functionName`, `file:line`, `selfTime`, `totalTime`. - 3. **Hot Call Path** — the function call chain leading to the bottleneck. - 4. **Runtime Code** — the most relevant extracted source from step 11 (or a - note if unavailable), not the whole fetched file or module. - This section must contain only user-owned application code. If the hottest - cost is in a dependency or Node internals, replace this section with a - short note that user-owned runtime code could not be extracted for that - hotspot and explain the nearest relevant user-owned caller instead. - Wrap the code block with these exact HTML comment markers so the host - extension can locate it: - ``` - <!-- nsolid-ide-runtime-code-start --> - ```language - <relevant source here> - ``` - <!-- nsolid-ide-runtime-code-end --> - ``` - 5. **Workspace Delta** (if applicable from step 12) — local vs. runtime diff and analysis. - 6. **Root Cause** — the specific reason this code is expensive (algorithmic, I/O, serialization, etc.). - 7. **Recommendation** — concrete fix advice with code-level specifics when possible. - When a dependency is the main cost source, recommend changes in the user's - call pattern, batching, caching, input size, or library choice. Do not - recommend rewriting dependency source. - 8. **Profile Reference** — the saved `.cpuprofile` path from step 9. -- End the report with a structured metadata comment on its own line so the host - extension can drive the followup flow. Use workspace-relative forward-slash - paths and 1-indexed inclusive line numbers: - ``` - <!-- nsolid-ide-hotfn: {"file":"src/foo.ts","startLine":42,"endLine":80,"name":"parseToken"} --> - ``` - If the hot function could not be mapped to a workspace file (Workspace Delta - reported "file exists in runtime but not in workspace"), omit the marker - entirely rather than emit a placeholder. -- In participant or host-managed flows, present this report inline and let the - host persist it automatically. -- In generic-agent flows, write the final markdown report to a temporary file - and run: - ``` - node "<skill-dir>/save-report.cjs" cpu-analysis "CPU Analysis — <appName> — <YYYY-MM-DD>" /tmp/nsolid-cpu-analysis.md <appName> - ``` - -### 14. Validate the Fix -- The host extension presents followup buttons after the report. When the user - clicks "Continue with optimization & benchmark", the extension invokes the - `ns-benchmark-validate` skill with this report in scope. Those followups are - for actionable user-owned code only. Do not ask a human-in-the-loop question - in this response — the buttons replace it. - -## Guardrails -- NEVER call `global-filter` as a discovery step. -- NEVER drift to a different app when the user or alert already identified the affected app. -- NEVER ask for capture approval on a telemetry alert unless the user explicitly - requested read-only/offline behavior. -- NEVER call discovery tools only to restate the same app and spike value the - user already provided; continue to the target-agent selection and profile. -- NEVER shell out for waiting, asset download, or ad hoc data collection - (`node wait.cjs`, `node fetch-asset.cjs`, `setTimeout`, `sleep`, curl, etc.). - Use `nsolid_wait` and `nsolid_downloadAsset` when those tools exist. The only - shell fallback allowed by this skill is the bundled same-directory - `workspace-delta.cjs` helper, and only when the `workspace_delta` tool is - unavailable. -- NEVER paste the entire `runtime-code` response when only part of it is - relevant. Keep the report focused on the code that explains the problem and - proposed fix. -- NEVER fetch or present dependency or Node-internal source as the code to - optimize. Treat those frames as evidence, then explain the nearest relevant - user-owned caller instead. -- If you do not wait long enough with `nsolid_wait`, `asset-summary` may still - report that the profile asset is not ready. -- A fix is not a fix until it is proven by benchmarking. diff --git a/skills/ns-analyze-cpu/save-report.cjs b/skills/ns-analyze-cpu/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-analyze-cpu/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-analyze-event/SKILL.md b/skills/ns-analyze-event/SKILL.md index 56ffd61..1dff259 100644 --- a/skills/ns-analyze-event/SKILL.md +++ b/skills/ns-analyze-event/SKILL.md @@ -1,87 +1,46 @@ --- name: ns-analyze-event description: >- - Investigate an existing N|Solid event with event-type-aware MCP tool usage. - Tailors the workflow for performance, security, lifecycle, and error events, - and correlates related assets before suggesting deeper follow-up analysis. + Investigates an existing N|Solid event payload and correlates relevant MCP evidence. Use when the user provides or references an event such as process-blocked, new-vulnerability-found, agent-exit, uncaught exception, stack trace, span data. Routes follow-up to CPU, memory, vulnerability, or tracing workflows when needed. --- -# NodeSource Event Analysis - -You are a NodeSource diagnostics engineer investigating a single N|Solid event. -Use the event type to drive tool selection. Be specific about what evidence you -found and what is still missing. - -## Instructions - ### 1. Parse the Event -- Extract these fields first: `event`, `type`, `severity`, `app`, `agent`, - `time`, and `args`. +- Extract these fields first: `event`, `type`, `severity`, `app`, `agent`, `time`, and `args`. - If `args` is a JSON string, parse it before doing anything else. -- If `args` already include a stack trace, span data, trace identifiers, or - other direct evidence, analyze that payload before reaching for more MCP - tools. -- Identify whether the event is best treated as performance, security, - lifecycle, or error oriented. +- If `args` already include a stack trace, span data, trace identifiers, or other direct evidence, analyze that payload before reaching for more MCP tools. +- Identify whether the event is best treated as performance, security, lifecycle, or error oriented. ### 2. Branch by Event Type - Performance events such as `process-blocked`, event loop lag, and high CPU: - - Call `metrics-historic` around the event time for `cpuUserPercent`, - `heapUsed`, and `loopEstimatedLag`. + - Call `metrics-historic` with `field: ["cpuUserPercent", "heapUsed", "loopEstimatedLag"]`, `q: "app=<app>"` or `q: "id=<agent>"`, and `start`/`end` around the event time. - Call `assets` to look for nearby profiles or snapshots. - If relevant assets exist, prefer `asset-summary`. - Security events such as `new-vulnerability-found`: - Call `vulnerabilities` for the affected application. - Call `application-packages` to identify the loaded package versions. - Lifecycle events such as `agent-exit` or repeated restarts: - - Call `events-historic` filtered to the same app or agent around the event - time. - - Call `metrics-historic` before the event to spot resource spikes. + - Call `events-historic` with the same `app` or agent `id`, plus `start`/`end` around the event time. + - Call `metrics-historic` with explicit `field` and `q` before the event to spot resource spikes. - Error events such as uncaught exceptions: - Parse `args.stack`. - - If the agent is still connected and the top frame is usable, call - `runtime-code` for the relevant function. - -### 3. Check Related Assets -- Call `assets` using the same app and a nearby time window. -- If you find a useful asset, inspect it with `asset-summary`. -- Prefer explaining the existing evidence over telling the user to capture a new - profile immediately. + - If the agent is still connected and you have `id`, `threadId`, `scriptId`, and `path`, call `runtime-code`; a plain stack trace alone is not enough. -### 4. Recommend the Right Next Step -- If the issue needs deeper work, point to the most relevant follow-up skill: - `ns-analyze-cpu`, `ns-analyze-memory`, `ns-analyze-vulnerabilities`, or - `ns-analyze-tracing`. +### 3. Recommend the Right Next Step +- If the issue needs deeper work, point to the most relevant follow-up skill: `ns-cpu-spike-analysis`, `ns-memory-spike-analysis`, `ns-analyze-vulnerabilities`, or `ns-analyze-tracing`. -### 5. Present the Result +### 4. Present the Result - Structure the final answer around: 1. Summary of what happened. 2. Evidence inspected from the event payload and any MCP calls. 3. Root cause hypothesis. 4. Most pragmatic next step. -- In participant or host-managed flows, render the final markdown inline and - let the host persist it automatically. -- In generic-agent flows, write the final markdown report to a temporary file - and run: - ``` - node "<skill-dir>/save-report.cjs" event-analysis "Event Analysis — <appName> — <classification>" /tmp/nsolid-event-analysis.md <appName> - ``` -## Tools -- `events-historic` -- `metrics-historic` -- `assets` -- `asset-summary` -- `vulnerabilities` -- `application-packages` -- `runtime-code` -- `information-dashboard` +### 5. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/event-analysis-<appName>-<classification>.md`. ## Guardrails -- Do not give a generic answer before checking the event type and the relevant - MCP evidence. -- Do not ask for a new capture until you have checked whether assets already - exist. -- Do not ignore stack, trace, or span data that is already present in the - event payload. +- Do not give a generic answer before checking the event type and the relevant MCP evidence. +- Do not ask for a new capture until you have checked whether assets already exist. +- Do not ignore stack, trace, or span data that is already present in the event payload. - If the event lacks enough data, say exactly what is missing. diff --git a/skills/ns-analyze-event/save-report.cjs b/skills/ns-analyze-event/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-analyze-event/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-analyze-memory/SKILL.md b/skills/ns-analyze-memory/SKILL.md deleted file mode 100644 index 872c226..0000000 --- a/skills/ns-analyze-memory/SKILL.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: ns-analyze-memory -description: >- - Diagnose memory leaks and high heap usage in Node.js applications using - real-time heap sampling or user-provided heap evidence from N|Solid MCP. Use - when the user mentions: memory leak, memory growth, heap growing, OOM, out of - memory, high RSS, or heap analysis. Prefer existing assets, summaries, or - local files before capturing new heap data. ---- - -# NodeSource Memory Analysis - -You are a NodeSource Performance Engineer specializing in memory diagnostics. -You capture and analyze heap data to pinpoint exactly where memory is being -consumed. Prefer the user's supplied evidence first. Only capture fresh data -when the current evidence is insufficient and the user wants live analysis. - -## Instructions - -### 1. Use Provided Evidence First -- Parse the prompt for app name, agent ID, asset ID, local heap file path, - heap summary, constructor table, retained-size output, or OOM context before - calling tools. -- If the user already provided an asset ID, local file path, or structured heap - summary, analyze that evidence first instead of starting with - `metrics-historic`. -- If the prompt is a telemetry alert for a specific app, use that as the - starting signal for live analysis of that same app. -- If the user explicitly says read-only, offline, or "analyze this file", do - not capture a new heap asset unless they later approve it. - -### 2. Find the Bottleneck Only If You Still Need a Target -- Call `metrics-historic` (`start: "5m"`) focusing on `heapUsed` and `heapTotal`. -- Identify the agent `id` consistently growing in memory. -- Skip this step when the provided evidence already names the exact asset, - process, or local file to inspect. - -### 3. Reuse Existing Assets Before Capturing -- If the prompt includes an asset ID, call `asset-summary` first. -- If the prompt includes a local `.heapprofile` or `.heapsnapshot` path, - analyze that local file. -- If the current evidence is already enough to identify the culprit, explain - it and stop there. Do not capture a second asset unless the user asks. - -### 4. Capture Memory Data Only With Missing Evidence or User Approval -- **Preferred (Low Overhead)**: Call `heap-sampling` on the `id` (e.g., `duration: 30`). -- **Alternative (Full Freeze)**: Call `snapshot` on the `id`. Only use if explicitly requested. -- Only capture when no reusable evidence was provided and the user wants a live - investigation. - -### 5. Wait (Critical) -- Use the `nsolid_wait` tool. -- For `heap-sampling`, wait the exact `duration` you passed. -- For `snapshot`, wait at least 40 seconds before checking summarization. - -### 6. Monitor Asset Generation -- For `heap-sampling`, call `asset-summary` on the exact Asset ID after waiting. -- For `snapshot`, call `asset-summary` on the exact Asset ID first. -- If a snapshot summary is still being generated, use `assets-in-progress` and - `nsolid_wait` in short intervals before retrying `asset-summary`. -- Do not use `assets-in-progress` as the first check for heap-sampling assets. - -### 7. Summarize the Profile -- Call `asset-summary` with your Asset ID. -- **Critical for full snapshots**: For `heap-sampling`, the summary usually returns immediately after the wait. For `snapshot` assets, the first `asset-summary` call may only trigger asynchronous summarization (HTTP 202). In that case, monitor readiness briefly and retry `asset-summary`. -- Once `asset-summary` succeeds, analyze that summary directly. Do not answer that telemetry alone is insufficient after a successful heap summary. - -### 8. Save the Full Asset -- Call `nsolid_downloadAsset` with the captured `assetId`. -- Use `kind: "heapprofile"` for `heap-sampling` assets. -- Use `kind: "heapsnapshot"` for `snapshot` assets. -- The host tool is idempotent and will reuse an existing local download when possible. - -### 9. Identify the Culprit -- Look for the constructor or function allocating the largest chunks of memory - in the summary JSON or local file analysis. Explain your findings to the - user. -- If the current evidence is insufficient to isolate the allocator, say exactly - what specific constructor, retaining path, or retained-size detail is still missing from the summary. - -### 10. Present the Result -- Structure the final answer around: - 1. Summary of the memory issue. - 2. Top allocators / constructors from the summary. - 3. Root cause hypothesis. - 4. Recommendation. - 5. Full asset reference if downloaded. -- In participant or host-managed flows, render the final markdown inline and - let the host persist it automatically. -- In generic-agent flows, write the final markdown report to a temporary file - and run: - ``` - node "<skill-dir>/save-report.cjs" memory-analysis "Memory Analysis — <appName or date>" /tmp/nsolid-memory-analysis.md <appName> - ``` - -### 11. For Elusive or Recurring Leaks -- If the leak shows a staircase pattern, retainers, or closures, consider using the `ns-advanced-memory-leak-hunter` skill for multi-phase baseline-vs-peak delta analysis. - -## Guardrails -- Respect strict wait times. Memory operations are blocking and slow. -- Do not turn a user-supplied asset review into a fresh capture workflow unless - the user asked for that or approved it. -- Prioritize `heap-sampling` over `snapshot` to minimize production impact. -- After a successful `asset-summary`, analyze that summary instead of falling - back to a generic telemetry-only conclusion. diff --git a/skills/ns-analyze-memory/save-report.cjs b/skills/ns-analyze-memory/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-analyze-memory/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-analyze-tracing/SKILL.md b/skills/ns-analyze-tracing/SKILL.md index 5ca3417..1fcddc7 100644 --- a/skills/ns-analyze-tracing/SKILL.md +++ b/skills/ns-analyze-tracing/SKILL.md @@ -1,67 +1,58 @@ --- name: ns-analyze-tracing description: >- - Analyze distributed tracing for HTTP latency, microservice topology, and - error origins using OpenTelemetry spans from N|Solid MCP. Use when the user - mentions: API timeout, microservice latency, slow endpoint, slow database - query, N+1 query, event loop lag, cascading failure, distributed trace, - OpenTelemetry, span, tracing, request waterfall, slow dashboard, async - bottleneck, await chain, service dependency, or "why is the API slow". - Prefer user-provided traces or span exports before querying for fresh data. + Analyzes N|Solid/OpenTelemetry tracing evidence for request latency, HTTP errors, and service dependencies. Use when the user asks about slow endpoints, API timeouts, distributed traces/spans, N+1 queries, await chains, microservice latency, cascading failures, or trace IDs. Query N|Solid only when no trace data was provided. Do not assume full waterfall details unless supplied. --- -# NodeSource Tracing Analysis - -You are a Staff-level Distributed Systems Architect. While others look at -single lines of code, you map cascading network topographies, database I/O, -asynchronous Promise chains, and systemic distributed bottlenecks using -OpenTelemetry data. - -## Instructions - ### 1. Use Provided Trace Data First -- Treat the prompt as primary evidence. Parse any provided trace ID, span list, - JSON export, waterfall table, endpoint name, status code, duration, or error - stack before calling tools. -- If the user already supplied a trace export or a specific trace ID with child - spans, analyze that material first instead of starting with - `information-dashboard` or a broad `tracing` query. -- If the prompt only contains a generic latency complaint with no trace data, - explain what is missing and then use MCP tools to locate the relevant trace. -- If the user explicitly says read-only, offline, or "analyze this trace", do - not go hunting for other services unless a required identifier is missing. +- Treat the prompt as primary evidence. Parse any provided trace ID, span list, app name, endpoint name, status code, duration, or error stack before calling tools. +- If the user supplied a trace tree/export, map that hierarchy directly. +- If the user supplied only a trace ID, call `tracing` with `span_traceId` for list-level rows only; the MCP tool does not expose full waterfall details. ### 2. Discover Connected Services Only When Needed - Call `information-dashboard` (no parameters) to list all connected agents and their `app` names and `id` values. - If the user mentions a specific service or app name, use that directly and skip this step. -- Use `serverless-functions` instead if the user is asking about AWS Lambda. -- Do NOT call `global-filter` — it returns ~18,000 tokens and will fill the context window. -- Skip this step when the supplied trace data already identifies the relevant - service. +- Use `serverless-functions` instead if the user is asking about a serverless function. +- Skip this step when the supplied trace data already identifies the relevant service. ### 3. Find Slow Requests Only If No Trace Was Supplied -- Call `tracing`. Use the `durations` parameter (e.g., `durations="1000-"` for spans >1 second). +- Call `tracing`. Use pipe duration syntax (e.g., `durations="1000|5000"` for 1–5s spans); do not use dash-range syntax. +- Use `functionName` only when the exact server-side function name is known. ### 4. Find Failing Endpoints Only If No Trace Was Supplied - Call `tracing` with `span_attributes_http_status_code` (e.g., `500`) to filter for HTTP errors. -### 5. Map the Trace -- Copy the `span_traceId` of the slow/failing request. Call `tracing` again with this specific ID. -- Analyze the `span_parentId` vs child hierarchy. Pinpoint which exact downstream span was the longest or threw the exception, and explain the topology to the user. -- If the user already supplied the trace tree, do this analysis directly without - an extra discovery query. +### 5. Triage the Trace +- Use `tracing` results as collapsed trace-list evidence: slow/failing service, endpoint, status, duration, and `span_traceId`. +- Do not claim parent/child waterfall analysis from `tracing` alone; `tracing-detail` is not exposed as MCP. +- If the user supplied the full trace tree/export, analyze `span_parentId` vs child hierarchy directly. ### 6. Propose Architectural Fixes -- Once you identify a bottleneck trace, synthesize the parent-child span relationships. -- Explain if the issue is a slow database query, a synchronous request layer, or network latency. +- Once you identify a bottleneck trace row or supplied trace tree, explain the strongest supported cause. +- Only discuss parent-child span relationships when the trace tree was supplied. - Propose topological changes like adding Redis caching, parallelizing independent `Promise.all` requests, or using message queues. -### 7. Validate -- Once the user implements the architectural shift, re-run the tracing analysis post-deployment to confirm spans have reduced in duration. +### 7. Present a Report +- Emit the analysis directly in chat as markdown: + - `# Tracing Analysis — <service/app/endpoint>` + - `## Summary` + - `## Evidence` + - `## Findings` + - `## Recommendations` + - `## Validation Plan` when a fix is proposed +- Ground every claim in supplied trace data or MCP `tracing` output. State when only collapsed trace-list evidence is available. + +### 8. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/tracing-analysis-<appName-or-endpoint>.md`. + +### 9. Validate (only if the user deployed a fix) +- If the user deployed one of the proposed fixes, re-run `tracing` with the same `durations` filter used in step 3 on the affected endpoint. +- Compare the post-deployment span duration against the pre-fix baseline you recorded. State the delta explicitly (e.g. "p95 dropped from 1200ms to 80ms"). +- Do not run this step unless the user reports a deployment — it is not a background check. ## Guardrails -- NEVER call `global-filter` for service discovery. Use `information-dashboard` only. -- Do not ignore a user-supplied trace export just to fetch a fresh one. +- NEVER call `global-filter` for service discovery — it returns ~18,000 tokens and fills the context window. Use `information-dashboard` only. - Do not search randomly; always filter using `durations` or status codes first to narrow down the dataset. -- A slow top-level span is usually caused by a slow child span — respect the topological hierarchy. +- A slow top-level span may be caused by a slow child span, but only assert that when full trace hierarchy is available. - Filter out expected long-polling or WebSocket connections when hunting for latency regressions. diff --git a/skills/ns-analyze-vulnerabilities/SKILL.md b/skills/ns-analyze-vulnerabilities/SKILL.md index 1857cc3..246b213 100644 --- a/skills/ns-analyze-vulnerabilities/SKILL.md +++ b/skills/ns-analyze-vulnerabilities/SKILL.md @@ -1,26 +1,11 @@ --- name: ns-analyze-vulnerabilities description: >- - Analyze live runtime vulnerabilities across all connected Node.js processes - using N|Solid MCP tools. Use when the user mentions: CVE, vulnerability, - npm audit, security risk, zero-day, supply chain attack, malicious package, - dependency risk, outdated package, "is this package safe", or - "do we have any vulnerabilities". This skill scans running production memory - for actively-exploitable CVEs — data that a static npm audit cannot provide. + Analyzes live runtime vulnerabilities loaded by connected N|Solid Node.js processes. Use when the user wants to know which CVEs are actually present in running apps, active exploitable/live vulnerabilities, zero-day exposure, supply-chain risk in production, or production package risk. For static npm-audit-style project dependency scans, use ns-audit-dependencies instead. --- -# NodeSource Vulnerability Analysis - -You are a NodeSource DevSecOps Engineer. You use N|Solid MCP tools to peer -directly into running memory to see exactly what vulnerable code is physically -executing in production right now. - -## Instructions - -Follow these precise steps: - ### 1. High-Level Overview -- Call the `vulnerabilities` tool. +- Call the `vulnerabilities` tool with `showLimit`/`page` when scanning large fleets. - Summarize the output to identify which application (`app`) has the most critical issues. ### 2. Detail Per App @@ -28,7 +13,7 @@ Follow these precise steps: - **Timeout note**: The MCP server allows 180s for this streaming endpoint, but the MCP client may drop after 60s. If it times out, proceed using data already collected from the `vulnerabilities` tool. ### 3. Discover First Detection Time -- Call `events-historic` (parameters: `type='new-vulnerability-found'`, `summarize='true'`). +- Call `events-historic` (parameters: `type='new-vulnerability-found'`, plus `app`, `start`, and `end` when known). - **CRITICAL**: The MCP tool description may incorrectly say `vulnerability-detected`. Do not use that. Valid Security Events: `new-vulnerability-found`, `package-vulnerabilities-updated`, `vulnerabilities-database-updated`, `active-vulns-updated`. - If this endpoint returns `null`, it means there are no matched historical events. Proceed without failing the audit. @@ -39,6 +24,21 @@ Follow these precise steps: ### 5. Verify - Re-run the `vulnerabilities` tool to confirm the vulnerability is resolved. +- If results do not change, note that verification may require app restart/redeploy or package refresh before live data updates. + +### 6. Present a Report +- Emit the analysis directly in chat as markdown: + - `# Runtime Vulnerability Analysis — <appName-or-fleet>` + - `## Summary` + - `## Evidence` + - `## Findings` + - `## Remediation Plan` + - `## Verification` +- Ground every CVE, package, version, and severity in `vulnerabilities`, `application-packages`, or provided evidence. + +### 7. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/runtime-vulnerability-analysis-<appName>.md`. ## Guardrails - NEVER suggest bumping a major version without explicitly warning the user about potential breaking changes. diff --git a/skills/ns-audit-dependencies/SKILL.md b/skills/ns-audit-dependencies/SKILL.md index 165defb..1969da9 100644 --- a/skills/ns-audit-dependencies/SKILL.md +++ b/skills/ns-audit-dependencies/SKILL.md @@ -1,35 +1,19 @@ --- name: ns-audit-dependencies description: >- - Run a security audit across all npm dependencies in a Node.js project. Use - when the user mentions: audit, security scan, vulnerabilities, CVE, check - dependencies, npm audit, security report, dependency risks, or asks to review - all packages. Produces a prioritized remediation plan grounded in NCM data - covering both direct and transitive dependencies. + Audits a local Node.js project dependency tree with NCM vulnerability and quality data. Use when the user asks for an npm audit-style security review, package CVEs, direct/transitive vulnerability report, dependency risk assessment, or remediation plan before upgrading. For vulnerabilities actually loaded in running N|Solid processes, use ns-analyze-vulnerabilities instead. --- -# NodeSource Dependency Security Auditor - -You are a NodeSource security engineer. Your job is to audit all npm -dependencies in the user's project and produce a prioritized, actionable -remediation plan grounded in NCM data. Do not hallucinate CVE identifiers, -vulnerability titles, or severity levels — use only what NCM returns. - -## Instructions - ### 1. Collect Dependencies -**If grounded audit data is already provided in the prompt** (a `## Audit Results` -block injected by the host), skip to step 3 and use that data exclusively. -Do not re-fetch packages that are already covered. +**If grounded audit data is already provided in the prompt** (a `## Audit Results` block injected by the host), skip to step 3 and use that data exclusively. Do not re-fetch packages that are already covered. **Otherwise (MCP-only / agent mode):** -- Run the bundled helper to extract all packages: +- Run the bundled helper to extract all packages (use the absolute path of the directory where you read this SKILL.md): ``` node "<skill-dir>/collect-dependencies.cjs" ``` - The script walks `package.json` and the lockfile (`package-lock.json`, - `yarn.lock`, or `pnpm-lock.yaml`) and prints a JSON object: + The script walks `package.json` and the lockfile (`package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`) and prints a JSON object: ```json { "packageManager": "npm|yarn|pnpm", @@ -38,67 +22,41 @@ Do not re-fetch packages that are already covered. "batches": [[{"name":"express","version":"4.18.2","isDirect":true}, ...], ...] } ``` -- Detect the package manager from the `packageManager` field (used later for - exact remediation commands). +- Detect the package manager from the `packageManager` field (used later for exact remediation commands). ### 2. Query NCM for Vulnerabilities - For each batch in `batches`, call `getPackageVersions` with that batch array. - Collect all packages that have at least one vulnerability reported. -- For critical or high severity findings where you need more detail, call - `getPackageQuality` on that specific package. +- For critical or high severity findings where you need more detail, call `getPackageQuality` on that specific package. - Cap batch size at ≤ 100 packages per `getPackageVersions` call. ### 3. Optional: Enrich with N|Solid Live Data -If an N|Solid agent is connected (check with `information-dashboard`), you can -supplement NCM data with live runtime intelligence: -- `vulnerabilities` — high-level security overview across all connected processes. -- `application-packages` — packages and vulnerabilities for a specific app. -- `sbom` — Software Bill of Materials for compliance use cases. +This is enrichment only; the NCM audit path above remains the source of truth. Check `information-dashboard` first. +- No connected agents → skip live enrichment. +- `vulnerabilities` — high-level security overview across connected processes. +- `application-packages` — use the exact `app` name from `information-dashboard`. +- `sbom` — use the exact `app` name; do not guess. -### 4. Produce the Remediation Plan -Return a response with these sections: +### 4. Present an Audit Report +Emit the audit directly in chat as markdown with these sections: -**Summary** — total packages checked, total vulnerabilities found (by severity), -and count of packages that could not be checked. +**Summary** — total packages checked, total vulnerabilities found (by severity), and count of packages that could not be checked. -**Prioritized Findings** — sorted critical → high → medium → low: -For each vulnerable package: -- Package name and version -- Vulnerability severity and title (from NCM) -- Latest safe version (if NCM reports one) -- Whether it is a direct or transitive dependency +**Prioritized Findings** — sorted critical → high → medium → low. For each vulnerable package include package name/version, severity/title from NCM, latest safe version if known, and whether it is direct or transitive. -**Remediation Plan** — step-by-step with exact commands for the detected package -manager (npm / yarn / pnpm): -1. Start with critical and high severity issues. -2. Provide the exact upgrade command for each fix. -3. Note any potential breaking changes based on SemVer classification. -4. For transitive deps the user can't directly control, explain how to use - dependency overrides / resolutions. +**Remediation Plan** — exact commands for the detected package manager (npm / yarn / pnpm), starting with critical/high issues. Note SemVer breaking changes and use overrides/resolutions for transitive deps the user cannot directly control. -**Breaking Change Notes** — flag major-version bumps required to fix -vulnerabilities; remind the user to review official changelogs. +**Breaking Change Notes** — flag major-version bumps required to fix vulnerabilities; remind the user to review official changelogs. -**Rollback Guidance** — N|Solid backs up `package.json` and the lockfile to -`.nsolid/backup/` before each upgrade. If an upgrade causes issues, the user -can click "Rollback" in the post-upgrade notification or restore manually from -that directory. +**Rollback Guidance** — N|Solid backs up `package.json` and the lockfile to `.nsolid/backup/` before each upgrade. If an upgrade causes issues, the user can click "Rollback" in the post-upgrade notification or restore manually from that directory. -## Tools -- `getPackageVersions` — batch-query NCM for vulnerability and quality data (≤ 100 packages per call). -- `getPackageQuality` — single-package deep dive for critical findings. -- `information-dashboard` — discover connected N|Solid agents (optional enrichment). -- `vulnerabilities` — live runtime vulnerability overview (optional, requires connected agent). -- `application-packages` — live per-app package data (optional, requires connected agent). -- `sbom` — Software Bill of Materials (optional, requires connected agent). +### 5. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final audit report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/dependency-audit-<projectName>.md`. ## Guardrails -- Never hallucinate CVE IDs, vulnerability titles, or severity levels. - Use only what NCM returns. If a package cannot be checked, report it as - unchecked rather than safe. -- When host-provided audit data is in the prompt, analyze only that data — - do not re-fetch. +- Never hallucinate CVE IDs, vulnerability titles, or severity levels. Use only what NCM returns. If a package cannot be checked, report it as unchecked rather than safe. +- When host-provided audit data is in the prompt, analyze only that data — do not re-fetch. - Cap each `getPackageVersions` call at ≤ 100 packages to avoid context overflow. -- If the total vulnerability count exceeds 50 packages, truncate the detailed - findings list and note the total count so the user knows the scope. +- If the total vulnerability count exceeds 50 packages, truncate the detailed findings list and note the total count so the user knows the scope. - Rollback reminder is mandatory in every audit response. diff --git a/skills/ns-benchmark-run/SKILL.md b/skills/ns-benchmark-run/SKILL.md index d4c0f7c..4426000 100644 --- a/skills/ns-benchmark-run/SKILL.md +++ b/skills/ns-benchmark-run/SKILL.md @@ -1,21 +1,9 @@ --- name: ns-benchmark-run description: >- - Benchmark a single Node.js function to measure its performance in ops/sec - using the ns-benchmark MCP server. Supports both user-provided code and live - V8 source extraction from a running N|Solid process. The final benchmark - report is markdown-first and can be persisted to `.nsolid/assets/` in - generic-agent flows. Use when the user wants to profile or measure a - function's throughput without comparing two versions. + Benchmarks one Node.js function and reports throughput/statistics. Use when the user asks to benchmark or time a single function, measure ops/sec, compare input sizes, or answer "how fast is this code?" Use ns-validate-optimization instead when original and optimized versions need an A/B proof. --- -# NodeSource Benchmark Runner - -You are a NodeSource Performance Engineer. You measure function performance -with scientific rigor using live benchmark execution — not estimates. - -## Instructions - ### 1. Acquire the Function Code and Project Context This skill is triggered from a code-lens interaction inside the user's workspace, so the codebase is always available. @@ -104,19 +92,12 @@ Do NOT call `run_benchmark`, `get_benchmark_result`, or any result-saving step u --- -### Argument Examples +### Argument Examples (key pattern) -**Simple primitives — no argSetupCode needed:** -``` -args: [5, "test", true] -args: [{ "name": "John", "age": 30 }, { "sortBy": "date" }] -args: [[1, 2, 3], ["a", "b", "c"]] -args: [] // function with no parameters -``` +For complex external dependencies, add the dependency as an **explicit parameter** to the function signature, mock it in `argSetupCode`, and pass the **parameter name** (not the value) in `args`. Example (HTTP req/res): -**HTTP Request/Response (external dependency):** ``` -// Original: function exampleFn(req, res) { arrExample.push(JSON.parse(resp)); res.end(); } +// Original: function exampleFn(req, res) { arrExample.push(JSON.parse(resp)); res.end(); } // Transformed: function exampleFn(req, res, arrExample) { arrExample.push(JSON.parse(resp)); res.end(); } args: ["req", "res", "arrExample"] argSetupCode: @@ -125,61 +106,7 @@ argSetupCode: const arrExample = []; ``` -**Database connection:** -``` -// Original: function queryDatabase(userId) { return db.collection('users').findOne({ _id: userId }); } -// Transformed: function queryDatabase(userId, db) { return db.collection('users').findOne({ _id: userId }); } -args: ["user123", "db"] -argSetupCode: - const db = { - collection: function(name) { - return { findOne: function(query) { return { name: 'Test User' }; } }; - } - }; -``` - -**File system:** -``` -// Original: function readConfigFile() { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } -// Transformed: function readConfigFile(fs, configPath) { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } -args: ["fs", "configPath"] -argSetupCode: - const fs = { readFileSync: function(path, enc) { return '{"apiKey":"test"}'; } }; - const configPath = '/etc/config.json'; -``` - -**Event emitters:** -``` -// Original: function processEvents(data) { eventEmitter.on('data', callback); } -// Transformed: function processEvents(data, eventEmitter, callback) { eventEmitter.on('data', callback); } -args: ["[1,2,3]", "eventEmitter", "callback"] -argSetupCode: - const data = [1,2,3]; - const callback = function(d) {}; - const eventEmitter = { - _events: {}, - on: function(event, handler) { this._events[event] = handler; }, - emit: function(event, data) { if (this._events[event]) this._events[event](data); } - }; -``` - -**Async/Await:** -``` -// Original: async function asyncExample() { return await fetchData(); } -// Transformed: async function asyncExample(fetchData) { return await fetchData(); } -args: ["fetchData"] -argSetupCode: - const fetchData = async function() { return { data: 'example data' }; }; -``` - -**Class instantiation:** -``` -// Original: function useClassExample() { const instance = new MyClass(); return instance.doSomething(); } -// Transformed: function useClassExample(MyClass) { const instance = new MyClass(); return instance.doSomething(); } -args: ["MyClass"] -argSetupCode: - class MyClass { constructor() {} doSomething() { return 'result'; } } -``` +For more patterns (DB, FS, event emitters, async/await, class instantiation), see `reference/benchmark-inputs.md` in this skill's directory. --- @@ -188,7 +115,7 @@ argSetupCode: Call `run_benchmark` with: - `functionData`: the object built in step 2 (`type`, `code`, `explanation`, `entryPoint`, `args`, and `argSetupCode` if needed) - `isOptimized: false` -- If the MCP tool accepts it, also pass the user-confirmed `benchmarkConfig` as part of `functionData` (e.g. `functionData.benchmarkConfig`) or as a separate parameter. Include `repeatSuite`, `minSamples`, `minTime`, and `maxTime`. +- Pass the user-confirmed `benchmarkConfig` only if the `run_benchmark` schema exposes that parameter (or `functionData.benchmarkConfig`). If not, record the desired config in the report and state that tool defaults were used. Note the returned `jobId`. @@ -202,7 +129,7 @@ node "<skill-dir>/wait.cjs" 20 ### 6. Get the Result -Call `get_benchmark_result` with the `jobId`. If `status` is not yet `"completed"`, run `wait.cjs 5` and poll again. Repeat until complete. +Call `get_benchmark_result` with the `jobId`. If `status` is not yet `"completed"`, run `node "<skill-dir>/wait.cjs" 5` and poll again. Poll up to **12 times**; if still not completed, report the `jobId` and incomplete status rather than looping indefinitely. Extract the full `result` object from the response. It contains: - `result.name` — the benchmark name @@ -215,7 +142,7 @@ Extract the full `result` object from the response. It contains: Keep the full `result` JSON available to present to the user. -### 7. Save a Markdown Benchmark Report +### 7. Build a Markdown Benchmark Report Build a markdown report with these sections: - title/date/type/function/source/benchmark ID (when available) @@ -224,25 +151,11 @@ Build a markdown report with these sections: - `## Results` - `## Tool Execution Log` -For `## Tool Execution Log`, include the raw input/output pairs for the -`run_benchmark` call and every `get_benchmark_result` poll for the same `jobId`, -in chronological order. - -Persistence path: -- In participant or host-managed flows, present this markdown report inline and - let the host persist it automatically. -- In generic-agent flows, write the report to a temporary file and run: - ``` - node "<skill-dir>/save-report.cjs" benchmark "Benchmark Report — <entryPoint>" /tmp/nsolid-benchmark-run.md - ``` - -If you used the local helper, report the saved markdown path to the user. +For `## Tool Execution Log`, include the raw input/output pairs for the `run_benchmark` call and every `get_benchmark_result` poll for the same `jobId`, in chronological order. ### 8. Present Results -Use the markdown report from step 7 as the final answer body. Present the -benchmark results in a markdown table. Include all relevant metrics from the -`result` object so the user can see the full performance picture at a glance. +Use the markdown report from step 7 as the final answer body. Present the benchmark results in a markdown table. Include all relevant metrics from the `result` object so the user can see the full performance picture at a glance. The table must include: - **Function**: the entry point name @@ -256,34 +169,26 @@ The table must include: - **Variance assessment**: whether the histogram shows high variance (high min/max spread suggests inconsistent performance) - **Benchmark ID**: the benchmark job/reference identifier when available -When variance is **high**, append a diagnostic paragraph below the table. Explain possible causes: -- **V8 JIT compilation stages**: functions may run in interpreter, baseline, or optimized tiers during the same benchmark, causing sporadic slowdowns or speedups -- **Garbage collection pauses**: if the function allocates memory, GC runs can introduce latency spikes in certain iterations -- **External factors**: system load, CPU throttling, or background processes can influence individual samples -- **Input sensitivity**: the function's performance may vary significantly with different argument values — consider testing a broader set of inputs - -Also recommend the user: -- increase `repeatSuite` or `minSamples` to gather more data if the variance is due to measurement noise -- inspect per-run ops/sec values from the `get_benchmark_result` output to see if variance is driven by individual outlier runs - Example table format: | Metric | Value | |--------|-------| -| Function | `generatePattern` | -| ops/sec | 266.36 | -| Iterations | 2074 | +| Function | `<entryPoint>` | +| ops/sec | <n> | +| Iterations | <n> | | Runs | 15 | -| Histogram min | 1.65 ms | -| Histogram max | 7.40 ms | -| Histogram samples | 128 | +| Histogram min/max | <min> ms / <max> ms | | Config | repeatSuite=15, minSamples=10, minTime=0.05s, maxTime=0.5s | -| Plugins | V8NeverOptimizePlugin | -| Variance | High (min/max spread is 4.5x) | -| Benchmark ID | `benchmark-abc123` | +| Variance | High/Low (min/max spread is <n>x) | +| Benchmark ID | `<id>` | -## Guardrails +When variance is **high** (wide min/max spread), append a diagnostic line: note likely causes (V8 JIT tier transitions, GC pauses on allocating functions, system load/CPU throttling, or input sensitivity) and recommend increasing `repeatSuite`/`minSamples` or inspecting per-run ops/sec for outliers. + +### 9. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/benchmark-<entryPoint>.md`. Report the saved path to the user. +## Guardrails - NEVER skip searching for real call sites and tests before proposing arguments — the workspace is always available in this flow. - If tests exist for the target function or its immediate caller, inspect them before proposing benchmark inputs. - NEVER run benchmark tools before the user confirms both the proposed arguments AND the benchmark configuration. diff --git a/skills/ns-benchmark-run/reference/benchmark-inputs.md b/skills/ns-benchmark-run/reference/benchmark-inputs.md new file mode 100644 index 0000000..e702841 --- /dev/null +++ b/skills/ns-benchmark-run/reference/benchmark-inputs.md @@ -0,0 +1,91 @@ +# Benchmark Input Examples + +Reference for building `args` and `argSetupCode` when benchmarking a function. +Referenced by `ns-benchmark-run` and `ns-validate-optimization`. + +The key rule for every example: complex external dependencies are added as an +**explicit parameter** to the function signature, defined as a mock in +`argSetupCode`, and referenced in `args` by **parameter name** (not by value). +`args` and `argSetupCode` must be identical between original and optimized runs. + +## Simple primitives — no `argSetupCode` needed + +``` +args: [5, "test", true] +args: [{ "name": "John", "age": 30 }, { "sortBy": "date" }] +args: [[1, 2, 3], ["a", "b", "c"]] +args: [] // function with no parameters +``` + +## HTTP Request/Response (external dependency) + +``` +// Original: function exampleFn(req, res) { arrExample.push(JSON.parse(resp)); res.end(); } +// Transformed: function exampleFn(req, res, arrExample) { arrExample.push(JSON.parse(resp)); res.end(); } +args: ["req", "res", "arrExample"] +argSetupCode: + const req = { url: '/test' }; + const res = { writeHead: function() {}, write: function() {}, end: function() {} }; + const arrExample = []; +``` + +## Database connection + +``` +// Original: function queryDatabase(userId) { return db.collection('users').findOne({ _id: userId }); } +// Transformed: function queryDatabase(userId, db) { return db.collection('users').findOne({ _id: userId }); } +args: ["user123", "db"] +argSetupCode: + const db = { + collection: function(name) { + return { findOne: function(query) { return { name: 'Test User' }; } }; + } + }; +``` + +## File system + +``` +// Original: function readConfigFile() { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } +// Transformed: function readConfigFile(fs, configPath) { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } +args: ["fs", "configPath"] +argSetupCode: + const fs = { readFileSync: function(path, enc) { return '{"apiKey":"test"}'; } }; + const configPath = '/etc/config.json'; +``` + +## Event emitters + +``` +// Original: function processEvents(data) { eventEmitter.on('data', callback); } +// Transformed: function processEvents(data, eventEmitter, callback) { eventEmitter.on('data', callback); } +args: ["[1,2,3]", "eventEmitter", "callback"] +argSetupCode: + const data = [1,2,3]; + const callback = function(d) {}; + const eventEmitter = { + _events: {}, + on: function(event, handler) { this._events[event] = handler; }, + emit: function(event, data) { if (this._events[event]) this._events[event](data); } + }; +``` + +## Async/Await + +``` +// Original: async function asyncExample() { return await fetchData(); } +// Transformed: async function asyncExample(fetchData) { return await fetchData(); } +args: ["fetchData"] +argSetupCode: + const fetchData = async function() { return { data: 'example data' }; }; +``` + +## Class instantiation + +``` +// Original: function useClassExample() { const instance = new MyClass(); return instance.doSomething(); } +// Transformed: function useClassExample(MyClass) { const instance = new MyClass(); return instance.doSomething(); } +args: ["MyClass"] +argSetupCode: + class MyClass { constructor() {} doSomething() { return 'result'; } } +``` diff --git a/skills/ns-benchmark-run/save-report.cjs b/skills/ns-benchmark-run/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-benchmark-run/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-benchmark-validate/save-report.cjs b/skills/ns-benchmark-validate/save-report.cjs deleted file mode 100644 index f392909..0000000 --- a/skills/ns-benchmark-validate/save-report.cjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// save-report.cjs — persists a markdown report to .nsolid/assets/ and updates reports-index.json. -// Usage: node save-report.cjs <type> <title> <markdown-file> [appName] -// Note: This script is designed for single-process/single-agent use. -// Concurrent executions may race on reports-index.json updates. - -'use strict' - -const fs = require('fs') -const path = require('path') - -const VALID_TYPES = new Set([ - 'security-audit', - 'lockfile-analysis', - 'package-check', - 'profile-analysis', - 'asset-analysis', - 'event-analysis', - 'cpu-analysis', - 'memory-analysis', - 'benchmark', - 'general-analysis' -]) - -function writeStdout (message) { - fs.writeSync(process.stdout.fd, message) -} - -function writeStderr (message) { - fs.writeSync(process.stderr.fd, message) -} - -let saveSeq = 0 - -function isWorkspaceRoot (dir) { - return fs.existsSync(path.join(dir, 'package.json')) || - fs.existsSync(path.join(dir, '.git')) || - fs.existsSync(path.join(dir, '.vscode', 'settings.json')) -} - -function findWorkspaceRoot () { - const seen = new Set() - const candidates = [ - process.env.INIT_CWD, - process.cwd(), - path.resolve(__dirname) - ].filter(Boolean) - - for (const candidate of candidates) { - let dir = path.resolve(candidate) - - while (!seen.has(dir)) { - seen.add(dir) - - if (isWorkspaceRoot(dir)) { - return dir - } - - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - } - - return path.resolve(process.cwd()) -} - -function ensureReportsDir (workspaceRoot) { - const nsolidDir = path.join(workspaceRoot, '.nsolid') - const reportsDir = path.join(nsolidDir, 'assets') - - fs.mkdirSync(reportsDir, { recursive: true }) - - const gitignorePath = path.join(nsolidDir, '.gitignore') - if (!fs.existsSync(gitignorePath)) { - fs.writeFileSync(gitignorePath, '*\n', 'utf-8') - } - - return reportsDir -} - -function extractSummary (content) { - const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n---|\n##|$)/) - if (summaryMatch?.[1]) { - return summaryMatch[1].trim().slice(0, 200) - } - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if ( - trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('---') && - !trimmed.startsWith('**Date') - ) { - return trimmed.slice(0, 200) - } - } - - return '' -} - -function cleanAppName (value) { - if (typeof value !== 'string') { - return undefined - } - - const cleaned = value - .replace(/[`*]/g, '') - .trim() - - if (cleaned.length === 0 || cleaned.toLowerCase() === 'unknown') { - return undefined - } - - return cleaned -} - -function inferAppName (title, content) { - const titlePatterns = [ - /^Event Analysis\s+[—-]\s+(.+?)\s+[—-]\s+(?:performance|security|lifecycle|error)$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+[—-]\s+\d{4}-\d{2}-\d{2}(?:\s+\+\s+Optimization)?$/i, - /^CPU Analysis\s+[—-]\s+(.+?)\s+\+\s+Optimization$/i, - /^Memory Analysis\s+[—-]\s+(.+?)\s+[—-]\s+.+$/i - ] - - for (const pattern of titlePatterns) { - const value = cleanAppName(title.match(pattern)?.[1]) - if (value) { - return value - } - } - - const contentPatterns = [ - /^\*\*Application\*\*:\s*(.+)$/mi, - /^Application:\s*(.+)$/mi - ] - - for (const pattern of contentPatterns) { - const value = cleanAppName(content.match(pattern)?.[1]) - if (value) { - return value - } - } - - return undefined -} - -function readReportsIndex (reportsDir) { - const indexPath = path.join(reportsDir, 'reports-index.json') - - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch (error) { - writeStderr(`Warning: Could not read reports index (${error.message}). Starting fresh.\n`) - return [] - } - - return [] -} - -function writeReportsIndex (reportsDir, entries) { - const indexPath = path.join(reportsDir, 'reports-index.json') - try { - fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), 'utf-8') - } catch (error) { - writeStderr(`Error writing reports index: ${error.message}\n`) - throw error - } -} - -function saveMetadata (reportsDir, metadata) { - const entries = readReportsIndex(reportsDir) - entries.push(metadata) - writeReportsIndex(reportsDir, entries) -} - -function main () { - const [,, type, title, markdownPath, explicitAppName] = process.argv - - if (!type || !title || !markdownPath) { - writeStderr('Usage: node save-report.cjs <type> <title> <markdown-file> [appName]\n') - process.exit(1) - } - - if (!VALID_TYPES.has(type)) { - writeStderr(`Unsupported report type: ${type}. Use one of: ${Array.from(VALID_TYPES).join(', ')}\n`) - process.exit(1) - } - - const resolvedMarkdownPath = path.resolve(markdownPath) - if (!fs.existsSync(resolvedMarkdownPath)) { - writeStderr(`Markdown file not found: ${resolvedMarkdownPath}\n`) - process.exit(1) - } - - const content = fs.readFileSync(resolvedMarkdownPath, 'utf-8') - const workspaceRoot = findWorkspaceRoot() - const reportsDir = ensureReportsDir(workspaceRoot) - const now = new Date() - const timestamp = now.toISOString() - const dateStr = timestamp.replace(/[:.]/g, '-').slice(0, 23) - const seq = String(++saveSeq).padStart(3, '0') - const fileName = `${type}-${dateStr}-${seq}.md` - const outputPath = path.join(reportsDir, fileName) - - fs.writeFileSync(outputPath, content, 'utf-8') - - const appName = cleanAppName(explicitAppName) || inferAppName(title, content) - saveMetadata(reportsDir, { - id: `${type}-${dateStr}-${seq}`, - title, - type, - timestamp, - fileName, - summary: extractSummary(content), - ...(appName ? { appName } : {}) - }) - - writeStdout(`${outputPath}\n`) -} - -main() diff --git a/skills/ns-cpu-spike-analysis/SKILL.md b/skills/ns-cpu-spike-analysis/SKILL.md new file mode 100644 index 0000000..bd217fe --- /dev/null +++ b/skills/ns-cpu-spike-analysis/SKILL.md @@ -0,0 +1,146 @@ +--- +name: ns-cpu-spike-analysis +description: >- + Diagnoses high CPU or slow execution in a Node.js app using supplied evidence or N|Solid CPU metrics/profiles. Use when the user reports CPU spike, high CPU percent, slow endpoint/function, event loop blocking, flamegraph/profile analysis, hot loop, or asks why an app is slow. Prefer existing asset IDs, summaries, trace data, or local profiles before capturing a new profile. +--- + +### 1. Use Provided Evidence First +- Treat the prompt content as primary evidence. Parse any provided app name, agent ID, hostname, time window, asset ID, local file path, CPU summary, flamegraph excerpt, hot function list, or stack trace before calling tools. +- If the prompt already includes an asset ID, local `.cpuprofile` path, or structured CPU summary, analyze that evidence first instead of starting with `information-dashboard` or `metrics-historic`. +- If the prompt is a telemetry alert such as "CPU spike 121.1% in app X", use that app name as authoritative scope and immediately run the live workflow: identify the hottest connected agent inside that app, capture a 30-second CPU profile, summarize it, download it, and fetch runtime code when possible. +- In telemetry-alert mode, do not stop after rediscovering the same spike and do not ask for capture approval unless the prompt explicitly says read-only, offline, or no-capture. +- If the user explicitly says read-only, offline, or "analyze this data", never capture a new profile unless they later approve it. + +### 2. Discover Connected Agents Only When Needed +- Call `information-dashboard` (no parameters) to list all connected agents. +- Note each agent's `id`, `app` name, and `hostname`. +- Skip this step when the provided evidence already names the exact agent or already includes a CPU profile asset or local file. +- If the user already named a specific app, treat that app name as authoritative. +- When an alert, warning, or telemetry card names an app, do NOT switch to a different app just because it has higher CPU elsewhere. +- If the prompt names a worker, hostname, or process hint, prefer the matching agent inside that same app. + +### 3. Find the Bottleneck Only If You Still Need a Target +- If the user named a specific app, call `metrics-historic` with `field: ["cpuUserPercent", "cpuSystemPercent"]`, `q: "app=<appName>"`, and `start: "5m"`; choose the hottest connected agent inside that app only. +- If no app was named, call `metrics-historic` with those fields and `start: "5m"` to identify the highest-CPU agent `id`. +- If a telemetry warning included a recent spike value or timeframe, bias the query window around that warning instead of doing a generic search. +- If no agent for the named app is connected, stop and say that clearly instead of profiling a different app. +- If the user already supplied a usable profile, asset summary, or local file, skip this step. +- When several agents are connected for the same app, choose the agent with the highest recent CPU inside that app. Record its `id`, `hostname`, and the CPU evidence that justified the choice. + +### 4. Reuse Existing Assets Before Capturing +- If the prompt includes an asset ID, call `asset-summary` first. +- If the prompt includes a local `.cpuprofile` path, resolve it to its asset ID (via `assets` or the download index) and analyze via `asset-summary`. **Never read the raw `.cpuprofile` into context** — it is large and token-wasteful. +- If you can identify the bottleneck from the supplied evidence, stop there and explain it. Do not capture a second profile unless the user asks. + +### 5. Capture a 30-Second Profile +- Call `profile` on that `id` with `duration: 30` and `threadId: 0`. +- Note the returned `id` (Asset ID). +- Only skip this capture when the prompt already supplied a reusable CPU profile, asset summary, or local `.cpuprofile`, or when the user explicitly requested read-only/offline analysis. +- For telemetry-alert mode, this 30-second profile capture is the default path. + +### 6. Wait (Critical) +- After starting the standard 30-second CPU profile, run the bundled wait script so the capture has time to finish and upload: + ``` + node "<skill-dir>/wait.cjs" 35 + ``` + (use the absolute path of the directory where you read this SKILL.md). +- If you used a different profile duration, wait that duration plus 5 seconds. +- Do NOT use `setTimeout`, `sleep`, or estimate the wait yourself — always run `wait.cjs`. + +### 7. Check Readiness Using the Exact Asset ID +- Call `asset-summary` using the exact Asset ID returned by `profile`. +- If `asset-summary` says the asset is not ready yet, run `node "<skill-dir>/wait.cjs" 5` and retry `asset-summary` on that same Asset ID. +- Retry at most 2 short waits after the initial 35-second wait. If the asset is still not ready, explain that clearly rather than looping indefinitely. +- Do NOT use `assets-in-progress` as the normal readiness check for CPU profiles. It is a global queue and may report unrelated assets. + +### 8. Summarize the Profile +- Once `asset-summary` succeeds, use its token-optimized JSON view as the grounded source for the rest of the workflow. +- Do not stop after `asset-summary`; continue the workflow with full profile download and runtime code extraction. + +### 9. Save the Full Profile +- Use the app name recorded from the prompt or discovery; `asset-summary` does not include app metadata. +- Download the profile with the bundled script (use the absolute path of the directory where you read this SKILL.md): + ``` + node "<skill-dir>/fetch-asset.cjs" <assetId> cpuprofile <appName> + ``` +- The script is idempotent: if the asset is already present in `.nsolid/assets/index.json`, it reuses the existing file without re-downloading. +- Do NOT use direct HTTP calls or `curl`. The bundled `fetch-asset.cjs` is the only supported download path inside this skill. +- The script writes to `.nsolid/assets/cpuprofile-<appName>-<assetIdPrefix>.cpuprofile` and updates the index automatically. + +### 10. Identify the Culprit +- Analyze the summary JSON or local profile data. Identify the function (`functionName`), `scriptId`, and file path (`url`) consuming the highest `totalTime` or `selfTime`. Explain this to the user. +- Focus the diagnosis on the hottest relevant **user-owned** frame. +- If the top cost is in Node internals or a dependency, report that clearly as evidence, but walk down or up the hot path to the nearest meaningful user-owned caller so the user gets an actionable explanation. +- If a dependency is causing the cost, explain how the user's code is invoking it, feeding it, or calling it too often. Do not treat dependency source as the optimization target. +- If the current evidence is insufficient to isolate a function, say exactly what is missing instead of pretending you have a bottleneck. + +### 11. Extract Runtime Code +- After identifying the hottest relevant **user-owned** frame, call `runtime-code` only when you have agent `id`, `threadId`, `scriptId`, and `url`/`path`. +- Prefer the hottest non-internal application frame. If the top frame is V8 or Node internals, walk down to the hottest frame that points to the user app. +- NEVER fetch or present runtime code for Node internals or dependency code. If the hottest frame is under `node_modules`, `node:`, or internal runtime paths, explain the dependency/internal cost and move to the nearest relevant user-owned caller instead of extracting dependency source. +- The `runtime-code` response is raw source material. Before presenting it in the report, keep only the most relevant parts needed to explain the CPU problem in the app and to ground the optimization proposal. +- Include the hot function and any nearby helpers, branches, loops, constants, or call sites that materially affect the bottleneck. +- Exclude unrelated module setup, imports, exports, sibling functions, or large sections of the file that do not help explain the issue. +- Do not force an artificially tiny excerpt. Keep enough surrounding context to make the diagnosis and recommendation understandable. +- Retry up to 2 times with path tweaks if the first call fails: + - Try stripping a leading `/app` or `/usr/src/app` Docker prefix. + - Try removing a leading path segment one level at a time. + - If still failing after 2 tweaks, skip and proceed to step 12 with a note that runtime code was unavailable. +- **Edge cases**: + - If `scriptId` is `0`, skip this step entirely — extraction will fail. + - If the process is Dockerized, the `path` may be misaligned; apply the path tweaks above before giving up. + +### 12. Compare Runtime Code to Workspace Source +- Prefer the `workspace_delta` tool when it is available. Pass the profiled app name, runtime path, runtime code, and the best line range or line hint you have for the hot function. +- If `workspace_delta` reports that the workspace does not match the profiled app, skip comparison and state that clearly in the report. +- If `workspace_delta` is unavailable but shell execution is allowed, use the bundled same-directory helper: + ``` + node "<skill-dir>/workspace-delta.cjs" <json-file-or-stdin> + ``` + It accepts JSON via stdin or a file path and prints the comparison result as JSON. +- If neither the tool nor the bundled helper is available, perform a conservative manual fallback: verify that the current workspace corresponds to the target app before comparing any local code, and skip comparison if identity is uncertain. +- Only attempt this step for user-owned application code. If the hot frame is a dependency or Node internal path, skip comparison and say that the performance issue originates outside user-owned source. +- If the file maps cleanly to the workspace, compare the local copy to the runtime version. Note real differences such as added logic, removed guards, changed thresholds, or refactors. +- Include a "Workspace Delta" section in the final report with the actual diff content when a comparison was possible. +- If the file does not map to a local workspace file, or app identity could not be proven safely, say so explicitly instead of guessing. + +### 13. Present the Full Report +- Structure the final report with these sections: + 1. **Executive Summary** — one paragraph stating the CPU problem and root cause. + 2. **Top CPU Consumers** — table with: `functionName`, `file:line`, `selfTime`, `totalTime`. + 3. **Hot Call Path** — the function call chain leading to the bottleneck. + 4. **Runtime Code** — the most relevant extracted source from step 11 (or a note if unavailable), not the whole fetched file or module. This section must contain only user-owned application code. If the hottest cost is in a dependency or Node internals, replace this section with a short note that user-owned runtime code could not be extracted for that hotspot and explain the nearest relevant user-owned caller instead. Wrap the code block with these exact HTML comment markers so the host extension can locate it: + ``` + <!-- nsolid-ide-runtime-code-start --> + ```language + <relevant source here> + ``` + <!-- nsolid-ide-runtime-code-end --> + ``` + 5. **Workspace Delta** (if applicable from step 12) — local vs. runtime diff and analysis. + 6. **Root Cause** — the specific reason this code is expensive (algorithmic, I/O, serialization, etc.). + 7. **Recommendation** — concrete fix advice with code-level specifics when possible. When a dependency is the main cost source, recommend changes in the user's call pattern, batching, caching, input size, or library choice. Do not recommend rewriting dependency source. + 8. **Profile Reference** — the saved `.cpuprofile` path from step 9. +- End the report with a structured metadata comment on its own line so the host extension can drive the followup flow. Use workspace-relative forward-slash paths and 1-indexed inclusive line numbers: + ``` + <!-- nsolid-ide-hotfn: {"file":"src/foo.ts","startLine":42,"endLine":80,"name":"parseToken"} --> + ``` + If the hot function could not be mapped to a workspace file (Workspace Delta reported "file exists in runtime but not in workspace"), omit the marker entirely rather than emit a placeholder. + +### 14. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/cpu-analysis-<appName>-<YYYY-MM-DD>.md`. + +### 15. Validate the Fix +- The host extension presents followup buttons after the report. When the user clicks "Continue with optimization & benchmark", the extension invokes the `ns-validate-optimization` skill with this report in scope. Those followups are for actionable user-owned code only. Do not ask a human-in-the-loop question in this response — the buttons replace it. + +## Guardrails +- NEVER call `global-filter` as a discovery step — it returns ~18,000 tokens and fills the context window. +- NEVER drift to a different app when the user or alert already identified the affected app. +- NEVER ask for capture approval on a telemetry alert unless the user explicitly requested read-only/offline behavior. +- NEVER call discovery tools only to restate the same app and spike value the user already provided; continue to the target-agent selection and profile. +- ALWAYS wait via the bundled `wait.cjs` script, never `setTimeout`/`sleep` or an estimate. Download assets via the bundled `fetch-asset.cjs` (do not use direct HTTP calls or curl). The only other shell helper allowed by this skill is the bundled same-directory `workspace-delta.cjs`. +- NEVER paste the entire `runtime-code` response when only part of it is relevant. Keep the report focused on the code that explains the problem and proposed fix. +- NEVER fetch or present dependency or Node-internal source as the code to optimize. Treat those frames as evidence, then explain the nearest relevant user-owned caller instead. +- If you do not wait long enough with `wait.cjs`, `asset-summary` may still report that the profile asset is not ready. +- A fix is not a fix until it is proven by benchmarking. diff --git a/skills/ns-analyze-memory/fetch-asset.cjs b/skills/ns-cpu-spike-analysis/fetch-asset.cjs similarity index 100% rename from skills/ns-analyze-memory/fetch-asset.cjs rename to skills/ns-cpu-spike-analysis/fetch-asset.cjs diff --git a/skills/ns-analyze-cpu/wait.cjs b/skills/ns-cpu-spike-analysis/wait.cjs similarity index 100% rename from skills/ns-analyze-cpu/wait.cjs rename to skills/ns-cpu-spike-analysis/wait.cjs diff --git a/skills/ns-analyze-cpu/workspace-delta.cjs b/skills/ns-cpu-spike-analysis/workspace-delta.cjs similarity index 99% rename from skills/ns-analyze-cpu/workspace-delta.cjs rename to skills/ns-cpu-spike-analysis/workspace-delta.cjs index ed84bf1..1c53ed3 100644 --- a/skills/ns-analyze-cpu/workspace-delta.cjs +++ b/skills/ns-cpu-spike-analysis/workspace-delta.cjs @@ -32,8 +32,8 @@ function main () { function printHelp () { process.stdout.write( 'Usage:\n' + - ' node .agents/skills/ns-analyze-cpu/workspace-delta.cjs <input.json>\n' + - ' printf \'%s\' \'<json>\' | node .agents/skills/ns-analyze-cpu/workspace-delta.cjs\n\n' + + ' node .agents/skills/ns-cpu-spike-analysis/workspace-delta.cjs <input.json>\n' + + ' printf \'%s\' \'<json>\' | node .agents/skills/ns-cpu-spike-analysis/workspace-delta.cjs\n\n' + 'Input JSON fields:\n' + ' workspaceRoot?: string\n' + ' targetAppName?: string\n' + diff --git a/skills/ns-generate-asset/SKILL.md b/skills/ns-generate-asset/SKILL.md index f1f377a..0092157 100644 --- a/skills/ns-generate-asset/SKILL.md +++ b/skills/ns-generate-asset/SKILL.md @@ -1,87 +1,65 @@ --- name: ns-generate-asset description: >- - Generate a new N|Solid diagnostic asset for a connected Node.js application. - Supports CPU profiles, heap samples, and heap snapshots. + Captures a new N|Solid diagnostic asset for a connected Node.js app. Use when the user explicitly asks to collect, capture, or generate a CPU profile, flamegraph, heap sample, heap snapshot, or heap tracking asset and wants it saved or handed off for analysis. For existing assets, use ns-analyze-asset instead. --- -# NodeSource Asset Generation - -You are a NodeSource diagnostics engineer. This is an ASSET GENERATION -workflow — not an analysis workflow. Create the requested asset, wait for -completion, download it locally, and report only grounded summary data. -Follow ONLY these instructions. Never load or reference other skill files. - -## Instructions - ### 1. Resolve Scope -- The target app is already resolved (workspace mapping, explicit - `Application: <name>`, or user confirmation). Do not switch apps. -- Call `mcp__nsolid-console__information-dashboard` with `q: "app=<appName>"` and `start: "5m"`. +- The target app is already resolved (workspace mapping, explicit `Application: <name>`, or user confirmation). Do not switch apps. +- Call `information-dashboard` with `q: "app=<appName>"` and `start: "5m"`. - No agents → stop and report no connected agents. Do not profile a different app. - One agent → use its `id`. -- Multiple agents → list each (`id`, hostname, key metrics) and ask the user - to choose. Only proceed after selection. +- Multiple agents → list each (`id`, hostname, key metrics) and ask the user to choose. Only proceed after selection. ### 2. Choose Asset Type -- Supported types: CPU profile, heap sample, heap snapshot. Heap tracking - profiles are not supported and must not be offered. +- Supported types: CPU profile, heap sample, heap snapshot, heap tracking. - Match the user's request: - - CPU profile / cpuprofile / flamegraph → `mcp__nsolid-console__profile`. - - Heap sample / heap sampling / memory sample → `mcp__nsolid-console__heap-sampling`. - - Heap snapshot / full heap snapshot → `mcp__nsolid-console__snapshot`. -- Default to `mcp__nsolid-console__heap-sampling` for memory concerns and `mcp__nsolid-console__profile` for CPU concerns. -- If the user asks for heap tracking / allocation tracking, reply that this - asset type is not supported and ask them to pick CPU profile, heap sample, - or heap snapshot instead. -- If ambiguous, ask the user to choose. Do not guess. Only proceed after the - user specifies or confirms. + - CPU profile / cpuprofile / flamegraph → `profile`. + - Heap sample / heap sampling / memory sample → `heap-sampling`. + - Heap snapshot / full heap snapshot → `snapshot`. + - Heap tracking / allocation tracking / object relocation tracking → `track-heap-objects`. +- Default to `heap-sampling` for memory concerns and `profile` for CPU concerns. +- If ambiguous, ask the user to choose. Do not guess. Only proceed after the user specifies or confirms. ### 3. Create the Asset - Use the agent `id` from Step 1 as the `id` parameter. -- CPU profile: `mcp__nsolid-console__profile` with `duration: 30`, `threadId: 0`. -- Heap sample: `mcp__nsolid-console__heap-sampling` with `duration: 30`. -- Heap snapshot: `mcp__nsolid-console__snapshot`. -- Record the exact returned asset ID and app name. +- CPU profile: `profile` with `duration: 30`, `threadId: 0`. +- Heap sample: `heap-sampling` with `duration: 30`; add `threadId: 0` when the main thread is intended. +- Heap snapshot: `snapshot`; add `threadId: 0` when the main thread is intended. +- Heap tracking: `track-heap-objects` with `duration: 30`; add `threadId: 0` when the main thread is intended. Include `trackAllocations: true` when the user asks for allocation stacks. +- Record the exact returned asset ID and app name for downloading and reporting. ### 4. Wait -- Use only `nsolid_wait`. -- CPU profile: `35` seconds. Heap sample: `30` seconds. -- Heap snapshot: at least `40` seconds before checking summary readiness. +Run the bundled wait script (use the absolute path of the directory where you read this SKILL.md): +- CPU profile: `node "<skill-dir>/wait.cjs" 35`. +- Heap sample: `node "<skill-dir>/wait.cjs" 30`. +- Heap snapshot: at least `40` seconds before checking summary readiness: `node "<skill-dir>/wait.cjs" 40`. +- Heap tracking: `node "<skill-dir>/wait.cjs" 35`. -### 5. Summarize Readiness -- CPU profile and heap sample: call `mcp__nsolid-console__asset-summary` on the returned asset ID. - If not ready, `nsolid_wait` 5 seconds and retry on the same ID. -- Heap snapshot: call `mcp__nsolid-console__asset-summary` first. If the response says async, - processing, pending, or summarization started, call `mcp__nsolid-console__assets-in-progress`, - then `nsolid_wait` 5 seconds, then retry `mcp__nsolid-console__asset-summary`. Cap retries at - **12**. If still not ready, report the asset ID and the pending state — - do not invent analysis. -- If `mcp__nsolid-console__asset-summary` returns a tool error (auth, network, MCP failure), - report the error and stop. Do not retry as if pending. +### 5. Check Readiness +- CPU profile and heap sample: call `asset-summary` on the returned asset ID. If not ready, run `node "<skill-dir>/wait.cjs" 5` and retry on the same ID. +- Heap snapshot: call `asset-summary` first. If the response says async, processing, pending, or summarization started, call `assets-in-progress`, then run `node "<skill-dir>/wait.cjs" 5`, then retry `asset-summary`. Cap retries at **12**. If still not ready, report the asset ID and the pending state — do not invent analysis. +- Heap tracking: call `assets-in-progress`. If the returned asset ID is still in progress, run `node "<skill-dir>/wait.cjs" 5` and retry. Cap retries at **12**. Once it is no longer in progress, continue to download. +- If `asset-summary` returns a tool error (auth, network, MCP failure), report the error and stop. Do not retry as if pending. ### 6. Download -- Call `nsolid_downloadAsset` after the asset is ready. Use these `kind` - values: +- After the asset is ready, download it with the bundled script (use the absolute path of the directory where you read this SKILL.md): + ``` + node "<skill-dir>/fetch-asset.cjs" <assetId> <assetType> <appName> + ``` +- Use these `<assetType>` values: - CPU profile: `cpuprofile`. - - Heap sample: `heapsample`. + - Heap sample: `heapprofile`. - Heap snapshot: `heapsnapshot`. + - Heap tracking: `heapprofile`. -### 7. Report -- CPU profile: top consumers, hot call path, likely root cause, - recommendation, asset ID, local path. -- Heap sample / snapshot: top allocators or retained objects, likely root - cause, recommendation, asset ID, local path. -- Do not invent function names, constructors, timings, sizes, or paths that - are not present in tool output. +### 7. Report or Hand Off for Analysis +- For capture-only requests, report asset type, asset ID, app name, agent ID, duration/thread ID, and local path. +- If the user asked to analyze, summarize, interpret, or explain the captured asset, read and follow `../ns-analyze-asset/SKILL.md` after the asset is ready. Do not duplicate its analysis rules here. +- Pass this handoff payload: `assetId`, `assetType` (`cpuprofile`, `heapprofile`, or `heapsnapshot`), `appName`, `agentId`, `threadId`, `duration`, and local path if downloaded. +- For heap tracking, report capture metadata and local path; only route to `ns-analyze-asset` if `asset-summary` supports the returned asset. Otherwise recommend `ns-advanced-memory-leak-hunter` for interpretation. ## Guardrails -- Follow ONLY this skill. Do not read or reference other skill files - (ns-analyze-cpu, ns-analyze-memory, etc.). -- Use ONLY N|Solid MCP tools. No NCM, benchmark, or other non-NSolid tools. -- Do not use `mcp__nsolid-console__track-heap-objects`. Heap tracking - profiles are removed from this workflow. - Do not use `runtime-code` or `workspace_delta`. -- Do not use shell commands or bundled helper scripts for waits or downloads. -- Do not use `mcp__nsolid-console__assets-in-progress` as the first readiness check for CPU - profiles or heap samples. +- Waits and downloads use the bundled `wait.cjs` and `fetch-asset.cjs` scripts in this skill's directory. Do not use direct HTTP calls, `curl`, or ad hoc shell commands. +- Do not use `assets-in-progress` as the first readiness check for CPU profiles or heap samples. diff --git a/skills/ns-generate-asset/fetch-asset.cjs b/skills/ns-generate-asset/fetch-asset.cjs new file mode 100644 index 0000000..2415414 --- /dev/null +++ b/skills/ns-generate-asset/fetch-asset.cjs @@ -0,0 +1,422 @@ +#!/usr/bin/env node + +// fetch-asset.cjs — Downloads a full N|Solid asset (CPU profile, heap snapshot, +// heap sampling) from the console API and saves it to .nsolid/assets/. +// +// Usage: +// node fetch-asset.cjs <assetId> <assetType> [appName] +// +// Arguments: +// assetId — The asset ID returned by the profile/snapshot/heap-sampling MCP tool +// assetType — One of: cpuprofile, heapprofile, heapsnapshot +// appName — (Optional) Application name for the filename, defaults to "unknown" +// +// The script reads the console URL and service token from ~/.agents/.nodesource-auth.json. +// Assets are saved to <cwd>/.nsolid/assets/. +// +// Output files: +// .nsolid/assets/<assetType>-<appName>-<assetIdPrefix>.<ext> +// +// Note: This script is designed for single-process/single-user workflows. +// Concurrent executions may race on file operations and index updates. + +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const dns = require('dns').promises +const net = require('net') + +const EXTENSIONS = { + cpuprofile: '.cpuprofile', + heapprofile: '.heapprofile', + heapsnapshot: '.heapsnapshot' +} + +// Maps fetch-asset type args to the AssetType values used by the extension's AssetService +const ASSET_TYPES = { + cpuprofile: 'cpu-profile', + heapprofile: 'heap-profile', + heapsnapshot: 'heap-snapshot' +} + +function getAssetsDir (workspaceRoot) { + return path.join(workspaceRoot, '.nsolid', 'assets') +} + +function sanitizeAppName (appName) { + return appName.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function buildAssetFilename (assetType, appName, assetId) { + return `${assetType}-${sanitizeAppName(appName)}-${assetId.slice(0, 8)}${EXTENSIONS[assetType]}` +} + +function readAssetIndex (workspaceRoot) { + const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') + + try { + if (fs.existsSync(indexPath)) { + return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) + } + } catch { + return [] + } + + return [] +} + +function isPathWithin (parent, candidate) { + const rel = path.relative(parent, candidate) + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) +} + +function writeAssetIndex (workspaceRoot, records) { + const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') + fs.writeFileSync(indexPath, JSON.stringify(records, null, 2), 'utf-8') +} + +function saveToAssetIndex (workspaceRoot, record) { + const records = readAssetIndex(workspaceRoot) + + // Upsert by assetId (mirrors AssetService.saveToIndex) + const idx = records.findIndex(r => r.assetId === record.assetId) + if (idx >= 0) { + records[idx] = record + } else { + records.push(record) + } + + writeAssetIndex(workspaceRoot, records) +} + +function removeDirectoryIfEmpty (dirPath) { + if (!fs.existsSync(dirPath)) { + return + } + + if (fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath) + } +} + +function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { + const assetsDir = getAssetsDir(workspaceRoot) + const expectedFilename = buildAssetFilename(assetType, appName, assetId) + const expectedPath = path.join(assetsDir, expectedFilename) + + if (fs.existsSync(expectedPath)) { + return { + filePath: expectedPath, + localPath: expectedFilename, + source: 'flat' + } + } + + const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + if (indexRecord?.localPath) { + const indexedPath = path.resolve(assetsDir, indexRecord.localPath) + if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { + return { + filePath: indexedPath, + localPath: indexRecord.localPath, + source: 'index' + } + } + } + + const legacyPath = path.join(assetsDir, sanitizeAppName(appName), `${assetId}${EXTENSIONS[assetType]}`) + if (fs.existsSync(legacyPath)) { + return { + filePath: legacyPath, + localPath: path.join(sanitizeAppName(appName), `${assetId}${EXTENSIONS[assetType]}`), + source: 'legacy' + } + } + + return null +} + +function ensureFlatAsset (workspaceRoot, assetId, assetType, appName) { + const assetsDir = getAssetsDir(workspaceRoot) + const filename = buildAssetFilename(assetType, appName, assetId) + const filePath = path.join(assetsDir, filename) + const existing = resolveExistingAsset(workspaceRoot, assetId, assetType, appName) + + if (!existing) { + return { + exists: false, + filePath, + localPath: filename + } + } + + if (existing.filePath !== filePath) { + fs.renameSync(existing.filePath, filePath) + if (existing.source === 'legacy') { + removeDirectoryIfEmpty(path.dirname(existing.filePath)) + } + + return { + exists: true, + migrated: true, + filePath, + localPath: filename + } + } + + return { + exists: true, + migrated: false, + filePath, + localPath: filename + } +} + +function expandIPv6 (ip) { + // IPv4-mapped/compatible addresses embed a dotted IPv4 at the end and must + // not be expanded with the generic :: handler below. + const embeddedIpv4 = extractIpv4FromIpv6(ip) + if (embeddedIpv4) { + return null + } + + let expanded = ip.toLowerCase() + if (expanded.includes('::')) { + const [left, right] = expanded.split('::') + const leftParts = left ? left.split(':') : [] + const rightParts = right ? right.split(':') : [] + const missing = 8 - leftParts.length - rightParts.length + const middle = Array(Math.max(missing, 0)).fill('0000') + expanded = [...leftParts, ...middle, ...rightParts].join(':') + } + return expanded.split(':').map(p => p.padStart(4, '0')).join(':') +} + +function extractIpv4FromIpv6 (ip) { + // IPv4-mapped: ::ffff:a.b.c.d or 0:0:0:0:0:ffff:a.b.c.d + const mapped = ip.match(/^(?:::|(?:0:){5})ffff:(\d+\.\d+\.\d+\.\d+)$/i) + if (mapped) return mapped[1] + // IPv4-compatible: ::a.b.c.d or 0:0:0:0:0:0:a.b.c.d + const compatible = ip.match(/^(?:::|(?:0:){6})(\d+\.\d+\.\d+\.\d+)$/i) + if (compatible) return compatible[1] + return null +} + +function isPrivateOrLocalIp (ip) { + if (net.isIPv4(ip)) { + const [a, b] = ip.split('.').map(Number) + if (a === 127) return true // loopback 127.0.0.0/8 + if (a === 10) return true // private 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12 + if (a === 192 && b === 168) return true // private 192.168.0.0/16 + if (a === 169 && b === 254) return true // link-local 169.254.0.0/16 + if (a === 0) return true // current network 0.0.0.0/8 + return false + } + + if (net.isIPv6(ip)) { + const embeddedIpv4 = extractIpv4FromIpv6(ip) + if (embeddedIpv4) { + return isPrivateOrLocalIp(embeddedIpv4) + } + + const normalized = expandIPv6(ip) + if (normalized === null) { + // Defensive: extractIpv4FromIpv6 should have matched any mapped/compatible + // address that net.isIPv6 accepted, but treat unexpected forms as unsafe. + return true + } + + // URL parsers normalize IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible + // (::a.b.c.d) addresses to pure hex. Detect those forms by prefix. + if (normalized.startsWith('0000:0000:0000:0000:0000:ffff:') || + normalized.startsWith('0000:0000:0000:0000:0000:0000:')) { + const high = parseInt(normalized.slice(30, 34), 16) + const low = parseInt(normalized.slice(35, 39), 16) + const ipv4 = `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}` + return isPrivateOrLocalIp(ipv4) + } + + const first16 = parseInt(normalized.slice(0, 4), 16) + if (normalized === '0000:0000:0000:0000:0000:0000:0000:0001') return true // ::1 + if ((first16 & 0xffc0) === 0xfe80) return true // link-local fe80::/10 + if ((first16 & 0xfe00) === 0xfc00) return true // unique local fc00::/7 + return false + } + + return false +} + +async function resolveHostnameIps (hostname) { + const raw = hostname.replace(/^\[/, '').replace(/\]$/, '') + const ipVersion = net.isIP(raw) + + if (ipVersion === 4) { + return [raw] + } + if (ipVersion === 6) { + return [raw] + } + + const ips = [] + // Use dns.lookup (libuv/getaddrinfo), which honors /etc/hosts and the + // system resolver — not dns.resolve (c-ares), which bypasses /etc/hosts and + // therefore fails to resolve hostnames like `localhost` on platforms where + // they only exist in the hosts file (e.g. macOS). This also matches the real + // resolution an outbound fetch would use, which is what SSRF validation needs. + try { + const records = await dns.lookup(raw, { all: true, verbatim: true }) + ips.push(...records.map((r) => r.address)) + } catch { + // hostname could not be resolved; caller treats empty as an error + } + return ips +} + +async function validateConsoleUrl (consoleUrl) { + let url + try { + url = new URL(consoleUrl) + } catch { + throw new Error(`Invalid consoleUrl: ${consoleUrl}`) + } + + if (process.env.NSOLID_ALLOW_INSECURE_CONSOLE) { + return + } + + if (url.protocol !== 'https:') { + throw new Error(`consoleUrl must use HTTPS: ${consoleUrl}`) + } + + const hostname = url.hostname.toLowerCase().replace(/\.$/, '') + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1') { + throw new Error(`consoleUrl cannot be localhost: ${consoleUrl}`) + } + + const ips = await resolveHostnameIps(url.hostname) + if (ips.length === 0) { + throw new Error(`consoleUrl hostname could not be resolved: ${consoleUrl}`) + } + + for (const ip of ips) { + if (isPrivateOrLocalIp(ip)) { + throw new Error(`consoleUrl resolves to a private or local address: ${consoleUrl} (${ip})`) + } + } +} + +async function readCredentials () { + const authPath = path.join(os.homedir(), '.agents', '.nodesource-auth.json') + + if (!fs.existsSync(authPath)) { + throw new Error( + 'Credentials not found. Run "npx @nodesource/plugin-<harness> login" to authenticate.' + ) + } + + let auth + try { + auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) + } catch (e) { + throw new Error(`Failed to parse ${authPath}: ${e.message}`) + } + const consoleUrl = auth.consoleUrl + const token = auth.serviceToken + + if (!consoleUrl) { + throw new Error('Missing "consoleUrl" in ~/.agents/.nodesource-auth.json') + } + if (!token) { + throw new Error('Missing "serviceToken" in ~/.agents/.nodesource-auth.json') + } + + await validateConsoleUrl(consoleUrl) + + return { consoleUrl: consoleUrl.replace(/\/$/, ''), token } +} + +async function fetchAsset (consoleUrl, token, assetId) { + const url = `${consoleUrl}/api/v3/asset/${encodeURIComponent(assetId)}` + console.log(`Fetching asset from: ${url}`) + + const res = await fetch(url, { + headers: { + 'x-nsolid-service-token': token, + Accept: 'application/json' + }, + signal: AbortSignal.timeout(120_000) + }) + + if (!res.ok) { + throw new Error(`Console returned ${res.status} ${res.statusText} for asset ${assetId}`) + } + + return await res.text() +} + +async function main () { + const [,, assetId, assetType, appName = 'unknown'] = process.argv + + if (!assetId || !assetType) { + console.error('Usage: node fetch-asset.cjs <assetId> <assetType> [appName]') + console.error(' assetType: cpuprofile | heapprofile | heapsnapshot') + process.exit(1) + } + + const ext = EXTENSIONS[assetType] + if (!ext) { + console.error(`Unknown asset type: ${assetType}. Use one of: ${Object.keys(EXTENSIONS).join(', ')}`) + process.exit(1) + } + + const workspaceRoot = process.cwd() + const { consoleUrl, token } = await readCredentials() + + const assetsDir = getAssetsDir(workspaceRoot) + fs.mkdirSync(assetsDir, { recursive: true }) + + const existingAsset = ensureFlatAsset(workspaceRoot, assetId, assetType, appName) + + let fileSize + if (existingAsset.exists) { + fileSize = fs.statSync(existingAsset.filePath).size + } else { + const data = await fetchAsset(consoleUrl, token, assetId) + fs.writeFileSync(existingAsset.filePath, data, 'utf-8') + fileSize = Buffer.byteLength(data) + } + + // Register in .nsolid/assets/index.json so the extension's AssetService can discover it + saveToAssetIndex(workspaceRoot, { + assetId, + name: `${assetType}-${sanitizeAppName(appName)}-${assetId.slice(0, 8)}`, + type: ASSET_TYPES[assetType], + app: appName, + localPath: existingAsset.localPath, + downloadedAt: new Date().toISOString(), + fileSize + }) + + if (existingAsset.exists) { + if (existingAsset.migrated) { + console.log(`Asset already existed and was moved to: ${existingAsset.filePath}`) + } else { + console.log(`Asset already downloaded at: ${existingAsset.filePath}`) + } + } else { + console.log(`Asset saved to: ${existingAsset.filePath}`) + } + console.log(`File size: ${(fileSize / 1024).toFixed(1)} KB`) +} + +if (require.main === module) { + main().catch((err) => { + console.error(`Error: ${err.message}`) + process.exit(1) + }) +} + +module.exports = { isPrivateOrLocalIp, resolveHostnameIps, validateConsoleUrl, readCredentials, fetchAsset } diff --git a/skills/ns-analyze-memory/wait.cjs b/skills/ns-generate-asset/wait.cjs similarity index 100% rename from skills/ns-analyze-memory/wait.cjs rename to skills/ns-generate-asset/wait.cjs diff --git a/skills/ns-generate-sbom/SKILL.md b/skills/ns-generate-sbom/SKILL.md index 5f4c8d5..7704ab1 100644 --- a/skills/ns-generate-sbom/SKILL.md +++ b/skills/ns-generate-sbom/SKILL.md @@ -1,18 +1,9 @@ --- name: ns-generate-sbom description: >- - Generate a Software Bill of Materials (SBOM) for a registered Node.js - application using N|Solid MCP. Use when the user mentions: SBOM, - software bill of materials, compliance, SOC2, license audit, - transitive dependency, or needs a compliance report. + Generates a Software Bill of Materials (SBOM) for a connected or registered N|Solid Node.js app. Use when the user asks for SBOM, software bill of materials, compliance evidence, SOC2/vendor review, license inventory, dependency inventory, or transitive package report for a running application. --- -# NodeSource SBOM Generation - -You are a NodeSource DevSecOps Engineer specializing in supply chain compliance. -You generate live SBOMs from running production processes — not from static -lockfiles. - ## Instructions ### 1. Identify Target Application @@ -21,19 +12,25 @@ lockfiles. - Do NOT use `global-filter` — it returns ~18,000 tokens and is wasteful for a simple app name lookup. ### 2. Determine Format Requirement -- Ask if the user needs the SBOM in **SPDX XML** (industry compliance standard) or **JSON** (for programmatic analysis). +- The `sbom` tool's `format` parameter accepts only `"json"` or `"html"`. It does **not** support XML. +- If the user does not specify a format, default to `"html"` (human-readable compliance report). +- Use `"json"` only when the user explicitly wants programmatic/machine analysis (e.g. feeding it to another tool). ### 3. Generate SBOM -- Call the `sbom` tool with the `app` parameter and `format` as either `"xml"` (default) or `"json"`. +- Call the `sbom` tool with the `app` parameter and `format` set to `"html"` (default) or `"json"`. ### 4. Handle Execution Edge Cases - **Timeout warning**: Generating an SBOM traverses the entire transitive dependency tree of a live process. The N|Solid server extends the timeout to 180 seconds. - If your MCP client drops the connection at 60s, inform the user the server is still processing, or suggest adjusting their MCP client timeout. Do not retry immediately. ### 5. Provide Output -- Save the raw output to a file if it exceeds reasonable context sizes (e.g., `application_sbom.xml` or `application_sbom.json`). -- Give the user a high-level summary and the path to the saved SBOM. +- If the user asked to download the SBOM, write it directly to the `.nsolid/sbom/` folder (create the folder if it does not exist): + - HTML: `.nsolid/sbom/<appName>_sbom.html` + - JSON: `.nsolid/sbom/<appName>_sbom.json` +- Do not stage the output in a temporary file or `/tmp` — write straight to `.nsolid/sbom/`. +- Give the user a high-level summary (total packages, top licenses, notable dependencies) and the saved path. ## Guardrails +- DO NOT pass `format: "xml"` — XML is not supported. Only `"json"` or `"html"`; default to `"html"`. - DO NOT poll or aggressively retry the SBOM endpoint if it times out — it is computationally expensive. - DO NOT hallucinate dependencies. Only report what is strictly inside the returned SBOM. diff --git a/skills/ns-memory-spike-analysis/SKILL.md b/skills/ns-memory-spike-analysis/SKILL.md new file mode 100644 index 0000000..2a5d397 --- /dev/null +++ b/skills/ns-memory-spike-analysis/SKILL.md @@ -0,0 +1,84 @@ +--- +name: ns-memory-spike-analysis +description: >- + Diagnoses memory growth, heap spikes, RSS increases, OOMs, and suspected leaks in Node.js apps using supplied heap evidence or N|Solid heap sampling/snapshots. Use when the user reports memory leak, heap growing, high RSS, out-of-memory, heap snapshot/profile analysis, or wants memory root-cause triage. +--- + +### 1. Use Provided Evidence First +- Parse the prompt for app name, agent ID, asset ID, local heap file path, heap summary, constructor table, retained-size output, or OOM context before calling tools. +- If the user already provided an asset ID, local file path, or structured heap summary, analyze that evidence first instead of starting with `metrics-historic`. +- If the prompt is a telemetry alert for a specific app, use that as the starting signal for live analysis of that same app. +- If the user explicitly says read-only, offline, or "analyze this file", do not capture a new heap asset unless they later approve it. + +### 2. Find the Bottleneck Only If You Still Need a Target +- If no agent `id` is known, call `information-dashboard` (`q: "app=<appName>"`, `start: "5m"` when app is known) and preserve the exact app name. +- Call `metrics-historic` with `field: ["heapUsed", "heapTotal", "rss"]`, `q: "app=<appName>"` or `q: "id=<id>"`, and `start: "5m"`. +- Identify the agent `id` consistently growing in memory. +- Skip this step when the provided evidence already names the exact asset, process, or local file to inspect. + +### 3. Reuse Existing Assets Before Capturing +- If the prompt includes an asset ID, call `asset-summary` first. +- If the prompt includes a local `.heapprofile` or `.heapsnapshot` path, resolve it to its asset ID (via `assets` or the download index) and analyze via `asset-summary`. **Never read the raw heap file into context** — it is large and token-wasteful. +- If the current evidence is already enough to identify the culprit, explain it and stop there. Do not capture a second asset unless the user asks. + +### 4. Capture Memory Data Only With Missing Evidence or User Approval +- **Preferred (Low Overhead)**: Call `heap-sampling` on the `id` (`duration: 30`; optional `sampleInterval`/`stackDepth`; `threadId: 0` for main thread). +- **Alternative (Full Freeze)**: Call `snapshot` on the `id` (`threadId: 0` for main thread). Only use if explicitly requested. +- Capture only when no reusable evidence was provided and the user approves, unless the prompt is an explicit live alert/investigation. + +### 5. Wait (Critical) +Run the bundled wait script (use the absolute path of the directory where you read this SKILL.md). Memory operations are blocking — wait the exact duration you passed, never estimate. +- For `heap-sampling`: + ``` + node "<skill-dir>/wait.cjs" 30 + ``` +- For `snapshot`, wait at least 40 seconds before checking summarization: + ``` + node "<skill-dir>/wait.cjs" 40 + ``` + +### 6. Monitor Asset Generation +- For `heap-sampling`, call `asset-summary` on the exact Asset ID after waiting. +- For `snapshot`, call `asset-summary` on the exact Asset ID first. +- If a snapshot summary is still being generated, use `assets-in-progress`, run `node "<skill-dir>/wait.cjs" 5` in short intervals, then retry `asset-summary`. +- Do not use `assets-in-progress` as the first check for heap-sampling assets. + +### 7. Summarize the Profile +- Call `asset-summary` with your Asset ID. +- **Critical for full snapshots**: For `heap-sampling`, the summary usually returns immediately after the wait. For `snapshot` assets, the first `asset-summary` call may only trigger asynchronous summarization (HTTP 202). In that case, monitor readiness briefly and retry `asset-summary`. +- Once `asset-summary` succeeds, analyze that summary directly. Do not answer that telemetry alone is insufficient after a successful heap summary. + +### 8. Save the Full Asset +- Use the app name recorded from the prompt or discovery; `asset-summary` does not include app metadata. +- Download the asset with the bundled script (use the absolute path of the directory where you read this SKILL.md): + ``` + node "<skill-dir>/fetch-asset.cjs" <assetId> <assetType> <appName> + ``` +- Use `<assetType>` `heapprofile` for `heap-sampling` assets. +- Use `<assetType>` `heapsnapshot` for `snapshot` assets. +- The script is idempotent and will reuse an existing local download when possible. + +### 9. Identify the Culprit +- Look for the constructor or function allocating the largest chunks of memory in the `asset-summary` output. Explain your findings to the user. +- If the current evidence is insufficient to isolate the allocator, say exactly what specific constructor, retaining path, or retained-size detail is still missing from the summary. + +### 10. Present the Result +- Structure the final answer around: + 1. Summary of the memory issue. + 2. Top allocators / constructors from the summary. + 3. Root cause hypothesis. + 4. Recommendation. + 5. Full asset reference if downloaded. + +### 11. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/memory-analysis-<appName or date>.md`. + +### 12. For Elusive or Recurring Leaks +- If the leak shows a staircase pattern, retainers, or closures, consider using the `ns-advanced-memory-leak-hunter` skill for multi-phase baseline-vs-peak delta analysis. + +## Guardrails +- Respect strict wait times. Memory operations are blocking and slow. +- Do not turn a user-supplied asset review into a fresh capture workflow unless the user asked for that or approved it. +- Prioritize `heap-sampling` over `snapshot` to minimize production impact. +- After a successful `asset-summary`, analyze that summary instead of falling back to a generic telemetry-only conclusion. diff --git a/skills/ns-memory-spike-analysis/fetch-asset.cjs b/skills/ns-memory-spike-analysis/fetch-asset.cjs new file mode 100644 index 0000000..2415414 --- /dev/null +++ b/skills/ns-memory-spike-analysis/fetch-asset.cjs @@ -0,0 +1,422 @@ +#!/usr/bin/env node + +// fetch-asset.cjs — Downloads a full N|Solid asset (CPU profile, heap snapshot, +// heap sampling) from the console API and saves it to .nsolid/assets/. +// +// Usage: +// node fetch-asset.cjs <assetId> <assetType> [appName] +// +// Arguments: +// assetId — The asset ID returned by the profile/snapshot/heap-sampling MCP tool +// assetType — One of: cpuprofile, heapprofile, heapsnapshot +// appName — (Optional) Application name for the filename, defaults to "unknown" +// +// The script reads the console URL and service token from ~/.agents/.nodesource-auth.json. +// Assets are saved to <cwd>/.nsolid/assets/. +// +// Output files: +// .nsolid/assets/<assetType>-<appName>-<assetIdPrefix>.<ext> +// +// Note: This script is designed for single-process/single-user workflows. +// Concurrent executions may race on file operations and index updates. + +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const dns = require('dns').promises +const net = require('net') + +const EXTENSIONS = { + cpuprofile: '.cpuprofile', + heapprofile: '.heapprofile', + heapsnapshot: '.heapsnapshot' +} + +// Maps fetch-asset type args to the AssetType values used by the extension's AssetService +const ASSET_TYPES = { + cpuprofile: 'cpu-profile', + heapprofile: 'heap-profile', + heapsnapshot: 'heap-snapshot' +} + +function getAssetsDir (workspaceRoot) { + return path.join(workspaceRoot, '.nsolid', 'assets') +} + +function sanitizeAppName (appName) { + return appName.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function buildAssetFilename (assetType, appName, assetId) { + return `${assetType}-${sanitizeAppName(appName)}-${assetId.slice(0, 8)}${EXTENSIONS[assetType]}` +} + +function readAssetIndex (workspaceRoot) { + const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') + + try { + if (fs.existsSync(indexPath)) { + return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) + } + } catch { + return [] + } + + return [] +} + +function isPathWithin (parent, candidate) { + const rel = path.relative(parent, candidate) + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) +} + +function writeAssetIndex (workspaceRoot, records) { + const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') + fs.writeFileSync(indexPath, JSON.stringify(records, null, 2), 'utf-8') +} + +function saveToAssetIndex (workspaceRoot, record) { + const records = readAssetIndex(workspaceRoot) + + // Upsert by assetId (mirrors AssetService.saveToIndex) + const idx = records.findIndex(r => r.assetId === record.assetId) + if (idx >= 0) { + records[idx] = record + } else { + records.push(record) + } + + writeAssetIndex(workspaceRoot, records) +} + +function removeDirectoryIfEmpty (dirPath) { + if (!fs.existsSync(dirPath)) { + return + } + + if (fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath) + } +} + +function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { + const assetsDir = getAssetsDir(workspaceRoot) + const expectedFilename = buildAssetFilename(assetType, appName, assetId) + const expectedPath = path.join(assetsDir, expectedFilename) + + if (fs.existsSync(expectedPath)) { + return { + filePath: expectedPath, + localPath: expectedFilename, + source: 'flat' + } + } + + const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + if (indexRecord?.localPath) { + const indexedPath = path.resolve(assetsDir, indexRecord.localPath) + if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { + return { + filePath: indexedPath, + localPath: indexRecord.localPath, + source: 'index' + } + } + } + + const legacyPath = path.join(assetsDir, sanitizeAppName(appName), `${assetId}${EXTENSIONS[assetType]}`) + if (fs.existsSync(legacyPath)) { + return { + filePath: legacyPath, + localPath: path.join(sanitizeAppName(appName), `${assetId}${EXTENSIONS[assetType]}`), + source: 'legacy' + } + } + + return null +} + +function ensureFlatAsset (workspaceRoot, assetId, assetType, appName) { + const assetsDir = getAssetsDir(workspaceRoot) + const filename = buildAssetFilename(assetType, appName, assetId) + const filePath = path.join(assetsDir, filename) + const existing = resolveExistingAsset(workspaceRoot, assetId, assetType, appName) + + if (!existing) { + return { + exists: false, + filePath, + localPath: filename + } + } + + if (existing.filePath !== filePath) { + fs.renameSync(existing.filePath, filePath) + if (existing.source === 'legacy') { + removeDirectoryIfEmpty(path.dirname(existing.filePath)) + } + + return { + exists: true, + migrated: true, + filePath, + localPath: filename + } + } + + return { + exists: true, + migrated: false, + filePath, + localPath: filename + } +} + +function expandIPv6 (ip) { + // IPv4-mapped/compatible addresses embed a dotted IPv4 at the end and must + // not be expanded with the generic :: handler below. + const embeddedIpv4 = extractIpv4FromIpv6(ip) + if (embeddedIpv4) { + return null + } + + let expanded = ip.toLowerCase() + if (expanded.includes('::')) { + const [left, right] = expanded.split('::') + const leftParts = left ? left.split(':') : [] + const rightParts = right ? right.split(':') : [] + const missing = 8 - leftParts.length - rightParts.length + const middle = Array(Math.max(missing, 0)).fill('0000') + expanded = [...leftParts, ...middle, ...rightParts].join(':') + } + return expanded.split(':').map(p => p.padStart(4, '0')).join(':') +} + +function extractIpv4FromIpv6 (ip) { + // IPv4-mapped: ::ffff:a.b.c.d or 0:0:0:0:0:ffff:a.b.c.d + const mapped = ip.match(/^(?:::|(?:0:){5})ffff:(\d+\.\d+\.\d+\.\d+)$/i) + if (mapped) return mapped[1] + // IPv4-compatible: ::a.b.c.d or 0:0:0:0:0:0:a.b.c.d + const compatible = ip.match(/^(?:::|(?:0:){6})(\d+\.\d+\.\d+\.\d+)$/i) + if (compatible) return compatible[1] + return null +} + +function isPrivateOrLocalIp (ip) { + if (net.isIPv4(ip)) { + const [a, b] = ip.split('.').map(Number) + if (a === 127) return true // loopback 127.0.0.0/8 + if (a === 10) return true // private 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12 + if (a === 192 && b === 168) return true // private 192.168.0.0/16 + if (a === 169 && b === 254) return true // link-local 169.254.0.0/16 + if (a === 0) return true // current network 0.0.0.0/8 + return false + } + + if (net.isIPv6(ip)) { + const embeddedIpv4 = extractIpv4FromIpv6(ip) + if (embeddedIpv4) { + return isPrivateOrLocalIp(embeddedIpv4) + } + + const normalized = expandIPv6(ip) + if (normalized === null) { + // Defensive: extractIpv4FromIpv6 should have matched any mapped/compatible + // address that net.isIPv6 accepted, but treat unexpected forms as unsafe. + return true + } + + // URL parsers normalize IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible + // (::a.b.c.d) addresses to pure hex. Detect those forms by prefix. + if (normalized.startsWith('0000:0000:0000:0000:0000:ffff:') || + normalized.startsWith('0000:0000:0000:0000:0000:0000:')) { + const high = parseInt(normalized.slice(30, 34), 16) + const low = parseInt(normalized.slice(35, 39), 16) + const ipv4 = `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}` + return isPrivateOrLocalIp(ipv4) + } + + const first16 = parseInt(normalized.slice(0, 4), 16) + if (normalized === '0000:0000:0000:0000:0000:0000:0000:0001') return true // ::1 + if ((first16 & 0xffc0) === 0xfe80) return true // link-local fe80::/10 + if ((first16 & 0xfe00) === 0xfc00) return true // unique local fc00::/7 + return false + } + + return false +} + +async function resolveHostnameIps (hostname) { + const raw = hostname.replace(/^\[/, '').replace(/\]$/, '') + const ipVersion = net.isIP(raw) + + if (ipVersion === 4) { + return [raw] + } + if (ipVersion === 6) { + return [raw] + } + + const ips = [] + // Use dns.lookup (libuv/getaddrinfo), which honors /etc/hosts and the + // system resolver — not dns.resolve (c-ares), which bypasses /etc/hosts and + // therefore fails to resolve hostnames like `localhost` on platforms where + // they only exist in the hosts file (e.g. macOS). This also matches the real + // resolution an outbound fetch would use, which is what SSRF validation needs. + try { + const records = await dns.lookup(raw, { all: true, verbatim: true }) + ips.push(...records.map((r) => r.address)) + } catch { + // hostname could not be resolved; caller treats empty as an error + } + return ips +} + +async function validateConsoleUrl (consoleUrl) { + let url + try { + url = new URL(consoleUrl) + } catch { + throw new Error(`Invalid consoleUrl: ${consoleUrl}`) + } + + if (process.env.NSOLID_ALLOW_INSECURE_CONSOLE) { + return + } + + if (url.protocol !== 'https:') { + throw new Error(`consoleUrl must use HTTPS: ${consoleUrl}`) + } + + const hostname = url.hostname.toLowerCase().replace(/\.$/, '') + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1') { + throw new Error(`consoleUrl cannot be localhost: ${consoleUrl}`) + } + + const ips = await resolveHostnameIps(url.hostname) + if (ips.length === 0) { + throw new Error(`consoleUrl hostname could not be resolved: ${consoleUrl}`) + } + + for (const ip of ips) { + if (isPrivateOrLocalIp(ip)) { + throw new Error(`consoleUrl resolves to a private or local address: ${consoleUrl} (${ip})`) + } + } +} + +async function readCredentials () { + const authPath = path.join(os.homedir(), '.agents', '.nodesource-auth.json') + + if (!fs.existsSync(authPath)) { + throw new Error( + 'Credentials not found. Run "npx @nodesource/plugin-<harness> login" to authenticate.' + ) + } + + let auth + try { + auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) + } catch (e) { + throw new Error(`Failed to parse ${authPath}: ${e.message}`) + } + const consoleUrl = auth.consoleUrl + const token = auth.serviceToken + + if (!consoleUrl) { + throw new Error('Missing "consoleUrl" in ~/.agents/.nodesource-auth.json') + } + if (!token) { + throw new Error('Missing "serviceToken" in ~/.agents/.nodesource-auth.json') + } + + await validateConsoleUrl(consoleUrl) + + return { consoleUrl: consoleUrl.replace(/\/$/, ''), token } +} + +async function fetchAsset (consoleUrl, token, assetId) { + const url = `${consoleUrl}/api/v3/asset/${encodeURIComponent(assetId)}` + console.log(`Fetching asset from: ${url}`) + + const res = await fetch(url, { + headers: { + 'x-nsolid-service-token': token, + Accept: 'application/json' + }, + signal: AbortSignal.timeout(120_000) + }) + + if (!res.ok) { + throw new Error(`Console returned ${res.status} ${res.statusText} for asset ${assetId}`) + } + + return await res.text() +} + +async function main () { + const [,, assetId, assetType, appName = 'unknown'] = process.argv + + if (!assetId || !assetType) { + console.error('Usage: node fetch-asset.cjs <assetId> <assetType> [appName]') + console.error(' assetType: cpuprofile | heapprofile | heapsnapshot') + process.exit(1) + } + + const ext = EXTENSIONS[assetType] + if (!ext) { + console.error(`Unknown asset type: ${assetType}. Use one of: ${Object.keys(EXTENSIONS).join(', ')}`) + process.exit(1) + } + + const workspaceRoot = process.cwd() + const { consoleUrl, token } = await readCredentials() + + const assetsDir = getAssetsDir(workspaceRoot) + fs.mkdirSync(assetsDir, { recursive: true }) + + const existingAsset = ensureFlatAsset(workspaceRoot, assetId, assetType, appName) + + let fileSize + if (existingAsset.exists) { + fileSize = fs.statSync(existingAsset.filePath).size + } else { + const data = await fetchAsset(consoleUrl, token, assetId) + fs.writeFileSync(existingAsset.filePath, data, 'utf-8') + fileSize = Buffer.byteLength(data) + } + + // Register in .nsolid/assets/index.json so the extension's AssetService can discover it + saveToAssetIndex(workspaceRoot, { + assetId, + name: `${assetType}-${sanitizeAppName(appName)}-${assetId.slice(0, 8)}`, + type: ASSET_TYPES[assetType], + app: appName, + localPath: existingAsset.localPath, + downloadedAt: new Date().toISOString(), + fileSize + }) + + if (existingAsset.exists) { + if (existingAsset.migrated) { + console.log(`Asset already existed and was moved to: ${existingAsset.filePath}`) + } else { + console.log(`Asset already downloaded at: ${existingAsset.filePath}`) + } + } else { + console.log(`Asset saved to: ${existingAsset.filePath}`) + } + console.log(`File size: ${(fileSize / 1024).toFixed(1)} KB`) +} + +if (require.main === module) { + main().catch((err) => { + console.error(`Error: ${err.message}`) + process.exit(1) + }) +} + +module.exports = { isPrivateOrLocalIp, resolveHostnameIps, validateConsoleUrl, readCredentials, fetchAsset } diff --git a/skills/ns-benchmark-validate/wait.cjs b/skills/ns-memory-spike-analysis/wait.cjs similarity index 100% rename from skills/ns-benchmark-validate/wait.cjs rename to skills/ns-memory-spike-analysis/wait.cjs diff --git a/skills/ns-node-upgrade/SKILL.md b/skills/ns-node-upgrade/SKILL.md index ef267c7..214b3ee 100644 --- a/skills/ns-node-upgrade/SKILL.md +++ b/skills/ns-node-upgrade/SKILL.md @@ -1,29 +1,15 @@ --- name: ns-node-upgrade description: >- - Advise on upgrading the Node.js runtime version for a project. Use when the - user mentions: upgrade Node, update Node.js, Node version, LTS, end of life, - EOL, nvmrc, engines field, migrate Node, Node 18/20/22/24, or asks which - Node.js version to use. Detects the project-pinned version and provides - authoritative LTS/EOL data, a target recommendation, and step-by-step - upgrade instructions. + Plans a Node.js runtime upgrade for a project. Use when the user asks which Node version to use, upgrade/migrate Node.js, address LTS/EOL, update .nvmrc or engines, or move between Node versions. Produces version detection, LTS/EOL rationale, dependency compatibility checks, upgrade steps, and rollback guidance. --- -# NodeSource Node.js Runtime Upgrade Advisor - -You are a NodeSource Node.js runtime expert. Your job is to help the user -understand their current Node.js version and guide them to the right upgrade -target based on authoritative, live release schedule data. - -## Instructions - ### 1. Get the Authoritative Release Schedule -**Before reasoning about LTS or EOL dates**, fetch live data: +**Before reasoning about LTS or EOL dates**, fetch live data (use the absolute path of the directory where you read this SKILL.md): ``` node "<skill-dir>/fetch-node-releases.cjs" ``` -The script prints a markdown table fetched from `endoflife.date` (with an -offline fallback): +The script prints a markdown table fetched from `endoflife.date` (with an offline fallback): ``` | Major | Status | Latest | Active Support End | EOL | @@ -33,17 +19,12 @@ offline fallback): ... ``` -**If a release schedule table is already injected into the prompt by the host** -(labelled `AUTHORITATIVE Node.js release schedule`), use it verbatim — do not -run the script, do not substitute values from your training data. +**If a release schedule table is already injected into the prompt by the host** (labelled `AUTHORITATIVE Node.js release schedule`), use it verbatim — do not run the script, do not substitute values from your training data. -In all cases: treat the table as the source of truth for LTS names, EOL dates, -and latest patch versions. Do not override it with training-data values. +In all cases: treat the table as the source of truth for LTS names, EOL dates, and latest patch versions. Do not override it with training-data values. ### 2. Detect the Current Node.js Version -If detection results are already provided in the prompt (labelled -`Detected Node.js version information`), use those results and skip manual -detection. +If detection results are already provided in the prompt (labelled `Detected Node.js version information`), use those results and skip manual detection. Otherwise, check in order: 1. `package.json` → `engines.node` field (project pin — authoritative for the project). @@ -51,20 +32,14 @@ Otherwise, check in order: 3. `.node-version` file in the workspace root. 4. Run `node --version` as a fallback for the runtime on PATH. -**At the start of your response**, briefly tell the user which source was used to -determine the current version (one sentence). If the project-pinned version and -the runtime on PATH disagree, treat the project pin as authoritative and note -the mismatch. +**At the start of your response**, briefly tell the user which source was used to determine the current version (one sentence). If the project-pinned version and the runtime on PATH disagree, treat the project pin as authoritative and note the mismatch. ### 3. Recommend a Target Version Using the release schedule table from step 1: - Identify the current version's release line. -- If it is already EOL or approaching EOL (within 6 months), recommend upgrading - to the newest Active LTS line. -- If it is on an Active LTS line, upgrading to the next LTS is optional but - note the timeline. -- If it is on a Current (odd/even non-LTS) line, recommend moving to the - nearest stable LTS. +- If it is already EOL or approaching EOL (within 6 months), recommend upgrading to the newest Active LTS line. +- If it is on an Active LTS line, upgrading to the next LTS is optional but note the timeline. +- If it is on a Current (odd/even non-LTS) line, recommend moving to the nearest stable LTS. - Never recommend a line that is already EOL. ### 4. Provide the Upgrade Guide @@ -73,39 +48,26 @@ Return a response with these sections: 1. **Current Version** — detected version and how it was determined. 2. **Active Node.js Release Lines** — summary table from step 1. 3. **Recommended Target** — the specific version to upgrade to and why. -4. **Key Changes** — breaking changes and notable new features between the - current and target versions. Use well-known facts; do not hallucinate - specific API changes. Point the user to the official Node.js changelog for - the authoritative list. +4. **Key Changes** — breaking changes and notable new features between the current and target versions. Use well-known facts; do not hallucinate specific API changes. Point the user to the official Node.js changelog for the authoritative list. 5. **Step-by-Step Upgrade Guide**: - Update `.nvmrc` / `.node-version` to the new version. - Update `engines.node` in `package.json` if present. - Run `nvm install <version> && nvm use <version>` (or equivalent for fnm/n). - - Update CI/CD configuration (GitHub Actions `node-version`, Dockerfile - `FROM node:X`, etc.). + - Update CI/CD configuration (GitHub Actions `node-version`, Dockerfile `FROM node:X`, etc.). - Run tests with the new version and watch for deprecation warnings. - Check for incompatible native modules (`node-gyp` rebuild if needed). -6. **Dependency Compatibility** — call `getPackageVersions` with the direct - dependencies to check for packages known to be incompatible with the target - Node.js version, if NCM reports that information. -7. **Verification** — how to confirm the upgrade was successful - (`node --version`, test suite, check for `DeprecationWarning`s in output). +6. **Dependency Compatibility** — read direct deps from `package.json` (`dependencies`, `devDependencies`, `optionalDependencies`) and call `getPackageVersions` with `{name, version}` entries to check target-Node incompatibilities if NCM reports them. +7. **Verification** — how to confirm the upgrade was successful (`node --version`, test suite, check for `DeprecationWarning`s in output). ### 5. Re-detect if Needed -If the user asks about a specific version mid-conversation, call the -`nsolid_detectNodeVersion` tool to refresh the detection results. +If the user asks about a specific version mid-conversation, re-run the detection in step 2 (`package.json` engines, `.nvmrc`, `.node-version`, or `node --version`) to refresh the current version. ## Tools -- `nsolid_detectNodeVersion` — re-detect the Node.js version in the workspace (VS Code only). - `getPackageVersions` — check direct dependencies for known incompatibilities with target Node version. ## Guardrails -- The release schedule table from `fetch-node-releases.cjs` or the injected - host table is authoritative. Do not override LTS names, EOL dates, or latest - patch versions with training-data values. -- If the script fails and no table is injected, use training data as a fallback - but clearly label it as potentially stale. -- Project-pinned version (engines / .nvmrc / .node-version) is authoritative - over the runtime on PATH. +- The release schedule table from `fetch-node-releases.cjs` or the injected host table is authoritative. Do not override LTS names, EOL dates, or latest patch versions with training-data values. +- If the script fails and no table is injected, say live release data could not be verified; any fallback must be clearly labeled potentially stale. +- Project-pinned version (engines / .nvmrc / .node-version) is authoritative over the runtime on PATH. - Do not ask the user to run `node --version` if detection already ran. - Never recommend a Node.js release line that is already EOL. diff --git a/skills/ns-optimize-function/SKILL.md b/skills/ns-optimize-function/SKILL.md new file mode 100644 index 0000000..786f049 --- /dev/null +++ b/skills/ns-optimize-function/SKILL.md @@ -0,0 +1,50 @@ +--- +name: ns-optimize-function +description: >- + Optimizes a specific local Node.js function selected by the user. Use when the user points to code and asks to make it faster, improve throughput, reduce CPU/memory cost, or fix a slow function. Requires workspace source; analyzes blast radius, proposes a behavior-preserving rewrite, and hands off to benchmark validation. Not for live N|Solid diagnostics alone. +--- + +### 1. Acquire the Target Function +- The user selects a function via code-lens, names it, or pastes its code. Read it from the workspace. +- If they name it but don't point at a location, search the workspace for the definition and confirm the match before proceeding. Do not guess which definition they meant when multiple exist — ask. +- Record the exact file path, start/end lines, and the function signature. + +### 2. Analyze Context & Impact +This is the grounding step. Do not write any optimization yet. +- Read the function body and everything it references (helpers, constants, external imports, closures). +- Search the workspace for **call sites** and **tests** to understand real usage and the actual input shapes callers pass in. +- Assess and state explicitly: + - **What it does** and its current complexity — the algorithmic shape (loops, nested iterations, recursion, allocations, serialization, I/O). + - **Blast radius** — how many call sites, whether it sits on a hot path (request handler, render, hot loop) or a cold path (startup, config load), sync vs async, and any concurrency/fan-out. + - **Optimization worthiness** — is it actually worth optimizing? A cold startup path is rarely worth it; a per-request function is. If it is NOT worth optimizing, say so plainly and stop here rather than optimizing for the sake of it. + +### 3. Baseline Benchmark +Measure before changing anything — optimization without a baseline is guessing. +- Delegate to the `ns-benchmark-run` skill to benchmark the **current** implementation. It builds the benchmark inputs and runs the measurement. +- Use the real inputs derived from step 2 (call sites / tests), not invented arguments. +- Record the baseline ops/sec. This is the number the optimization must beat. + +### 4. Propose the Optimization +- Based on step 2's analysis, design ONE optimized rewrite targeting the dominant cost (algorithmic, allocation, I/O, caching, etc.). +- Present the change as a before/after diff with a short rationale tied to the specific cost you identified. +- **Human in the loop**: ask the user to approve the rewrite before benchmarking it. Do not run the optimized benchmark until they approve. + +### 5. Validate (delegate to ns-validate-optimization) +- After approval, use the `ns-validate-optimization` skill to verify the improvement. It builds the benchmark inputs, runs the A/B comparison between the original and optimized code, and retries up to 3 times until the improvement clears the effectiveness threshold. +- Handoff payload: original code, optimized code, file path/lines, entry points, call-site/test evidence, and contract notes. +- Do NOT embed the benchmark mechanics here (`run_benchmark`, `get_benchmark_result`, `compare_benchmarks`, the retry loop) — `ns-validate-optimization` owns all of that. + +### 6. Final Wrap-Up (Do Not Duplicate Validation Report) +- `ns-validate-optimization` owns the full benchmark report, save-to-disk prompt, and benchmark evidence/logs. +- After validation returns, give a concise wrap-up only: function/location, impact/worthiness, optimization rationale, final verdict, and saved validation report path if one exists. +- If validation exhausted 3 attempts without clearing the threshold, say so plainly and name the best measured attempt; never claim success. +- Do not write or save a second optimization report unless the user explicitly asks for a combined narrative report. +- If the workflow stops before validation (not worth optimizing, ambiguous target, or no approval), provide a short inline decision summary instead of a report. + +## Guardrails +- Never optimize a function you have not benchmarked a baseline for first. +- Never skip the impact/worthiness assessment (step 2). A cold path is not a valid optimization target — say so and stop. +- Never embed benchmark mechanics. Delegate measurement and validation to `ns-benchmark-run` and `ns-validate-optimization`. +- Never propose an optimized rewrite that changes the function's contract (arguments, return shape, side effects, error behavior) unless the user explicitly approved that contract change. `ns-validate-optimization` compares the same contract — a different one invalidates the A/B comparison. +- If no tests or call sites exist, say so and derive the narrowest defensible inputs from the code itself — do not invent workloads. +- Never report a successful optimization that did not clear the effectiveness threshold. Report the best attempt and that it fell short. diff --git a/skills/ns-replace-package/SKILL.md b/skills/ns-replace-package/SKILL.md index a964fe4..232c94c 100644 --- a/skills/ns-replace-package/SKILL.md +++ b/skills/ns-replace-package/SKILL.md @@ -1,40 +1,23 @@ --- name: ns-replace-package description: >- - Find a replacement for an npm package. Use when the user mentions: replace, - drop, remove package, find alternative, swap out, deprecated package, abandon - package, migrate away, or asks "what can I use instead of". Provides - NCM-grounded comparison of the target and proposed alternatives, with - migration steps and API diff guidance. + Finds and evaluates alternatives to an npm package. Use when the user wants to replace, drop, or swap a dependency, migrate away from a deprecated/abandoned/risky package, or asks "what can I use instead of X?" Compares candidates with NCM data and provides migration/API-diff guidance. Use ns-upgrade-package for staying on the same package. --- -# NodeSource Package Replacement Advisor - -You are a NodeSource dependency management engineer. Your job is to help the -user replace a problematic or unwanted npm package with a better alternative. -Ground every recommendation in NCM data — validate both the package being -replaced and each proposed alternative before recommending. - -## Instructions - ### 1. Identify the Package to Replace - Extract the package name from the user's request. -- Read `package.json` to confirm whether it is installed and note the current - version and whether it is a direct or dev dependency. +- Read `package.json` to confirm whether it is installed and note the current version and whether it is a direct or dev dependency. +- Detect package manager by lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `package-lock.json` → npm; default npm). ### 2. Fetch NCM Data for the Target Package - Call `getPackageQuality` for the package being replaced. -- Document: vulnerabilities (severity + title), license, module risks flagged by - NCM (e.g. install scripts, obfuscation, author risk, malware), quality scores. +- Document: vulnerabilities (severity + title), license, module risks flagged by NCM (e.g. install scripts, obfuscation, author risk, malware), quality scores. - Use these findings as the objective reasons why replacement is warranted. ### 3. Propose Alternatives -- Based on the package's domain and the user's context, identify 2–3 realistic - alternative packages. -- For **each** proposed alternative, call `getPackageQuality` to retrieve its - own vulnerability, license, and quality data before recommending it. -- Discard any alternative that has critical/high vulnerabilities or critical - module risks unless the user explicitly asks to include it. +- Based on the package's domain and the user's context, identify 2–3 realistic alternative packages. +- For **each** proposed alternative, call `getPackageQuality` to retrieve its own vulnerability, license, and quality data before recommending it. +- Discard any alternative that has critical/high vulnerabilities or critical module risks unless the user explicitly asks to include it. - Do not invent unpublished packages or packages you are not confident exist. ### 4. Compare and Recommend @@ -51,24 +34,17 @@ Lead with the strongest recommendation and explain the tradeoff clearly. ### 5. Migration Plan For the top recommended alternative, provide: 1. **Why replace** — reference the NCM-reported issues with the original package. -2. **Install command** — exact npm / yarn / pnpm command. -3. **Uninstall command** — remove the original. -4. **API differences** — key differences between the two APIs with before/after - code examples. Focus on the most commonly used features. -5. **Configuration changes** — any config files, environment variables, or - build-tool settings that need updating. +2. **Install command** — exact command for the detected package manager. +3. **Uninstall command** — exact command to remove the original package. +4. **API differences** — key differences between the two APIs with before/after code examples. Focus on the most commonly used features. +5. **Configuration changes** — any config files, environment variables, or build-tool settings that need updating. 6. **Test checklist** — what to verify after migration. ## Tools -- `getPackageQuality` — call once for the target package and once for each - proposed alternative. Do not recommend an alternative without querying NCM - for it first. +- `getPackageQuality` — call once for the target package and once for each proposed alternative. Do not recommend an alternative without querying NCM for it first. ## Guardrails -- Do not invent unpublished or fictional alternatives. Only recommend packages - that genuinely exist and are relevant to the use case. +- Do not invent unpublished or fictional alternatives. Only recommend packages that genuinely exist and are relevant to the use case. - Ground the "why replace" section in NCM-reported data, not general opinion. -- Validate every proposed alternative via `getPackageQuality` before including - it in the comparison. -- If NCM data is unavailable for the target, state that clearly and proceed with - general guidance only, flagging the absence of grounded data. +- Validate every proposed alternative via `getPackageQuality` before including it in the comparison. +- If NCM data is unavailable for the target, state that clearly and proceed with general guidance only, flagging the absence of grounded data. diff --git a/skills/ns-upgrade-package/SKILL.md b/skills/ns-upgrade-package/SKILL.md index 5206c5c..6b73171 100644 --- a/skills/ns-upgrade-package/SKILL.md +++ b/skills/ns-upgrade-package/SKILL.md @@ -1,87 +1,51 @@ --- name: ns-upgrade-package description: >- - Advise on upgrading a specific npm package in a Node.js project. Use when the - user mentions: upgrade, update package, bump version, npm update, migrate - package, breaking change, semver, outdated package, or asks about a specific - package version. Provides risk assessment, step-by-step upgrade instructions, - and rollback guidance grounded in NCM data. + Plans an upgrade for a specific npm package already used by a Node.js project. Use when the user wants to bump/update one dependency, assess semver or breaking changes, resolve peer dependency risk, move to a target version, or get exact package-manager commands. Use ns-replace-package when switching to a different library. --- -# NodeSource Package Upgrade Advisor - -You are a NodeSource dependency management engineer. Your job is to give the -user a grounded, actionable upgrade plan for a single npm package. Base every -claim on NCM data — never fabricate changelog entries, breaking change lists, or -migration guides. - -## Instructions - ### 1. Identify the Package and Current Version - Extract the package name from the user's request. -- If no version context is provided, read `package.json` (and `package-lock.json` - / `yarn.lock` / `pnpm-lock.yaml` if present) to find the currently installed - version. -- Note whether the package is a direct dependency, a dev dependency, or not yet - installed. +- If no version context is provided, read `package.json` and the lockfile to find the installed version (`package-lock` `packages["node_modules/<pkg>"]`, `pnpm-lock` package snapshots, or matching `yarn.lock` entry). Use the package.json range only when no lockfile version exists. +- Note whether the package is a direct dependency, a dev dependency, or not yet installed. ### 2. Fetch NCM Data -- Call `getPackageQuality` with the package name and current version (or `latest` - if not installed) to retrieve vulnerability severity, known issues, license, - and quality scores. +- Call `getPackageQuality` with the package name and current version (or `latest` if not installed) to retrieve vulnerability severity, known issues, license, and quality scores. - The tool returns the latest available version as well — record it. - If the user specified a target version explicitly, use that instead of latest. ### 3. Assess Workspace Usage -- Search workspace source files (`**/*.{ts,tsx,js,jsx,mjs,cjs}`) for - `import ... from '<pkg>'` or `require('<pkg>')` patterns to understand how - widely the package is used. -- Check the lockfile for how many other packages in the project depend on this - one (transitive impact). +- Search workspace source files (`**/*.{ts,tsx,js,jsx,mjs,cjs}`) for `import ... from '<pkg>'` or `require('<pkg>')` patterns to understand how widely the package is used. +- Check the lockfile for how many other packages in the project depend on this one (transitive impact). ### 4. Classify the Version Change Apply SemVer heuristics to assess risk: - **major** (X.0.0 → Y.0.0): potentially breaking — high risk, review API changes carefully. - **minor** (X.Y.0 → X.Z.0): new features, backward-compatible — medium risk. - **patch** (X.Y.Z → X.Y.W): bug fixes only — low risk. -- Cross-reference with the number of workspace files using the package and - transitive dependents to scale the risk assessment. +- Cross-reference with the number of workspace files using the package and transitive dependents to scale the risk assessment. ### 5. Provide the Upgrade Plan Return a response with these sections: -1. **Current State** — detected version, source (package.json / lockfile / not found), - and workspace usage count. +1. **Current State** — detected version, source (package.json / lockfile / not found), and workspace usage count. 2. **Latest Version** — the version NCM reports as latest. -3. **NCM Security & Quality** — any vulnerabilities (severity + title), license, - module risks flagged by NCM. If NCM data is unavailable, say so explicitly. -4. **SemVer Risk Assessment** — change type (major / minor / patch), risk level, - and reasoning based on workspace usage and dependents. -5. **Breaking Changes** — use SemVer heuristics and any NCM-flagged issues. - Do NOT fabricate specific changelog entries. Direct the user to the package's - official CHANGELOG.md or release notes for the authoritative list. -6. **Step-by-step Upgrade Instructions** — exact commands for the user's package - manager (npm / yarn / pnpm), plus any configuration file changes needed. -7. **Post-Upgrade Checklist** — run tests, check for deprecation warnings, verify - peer-dependency compatibility. -8. **Rollback Procedure** — N|Solid automatically backs up `package.json` and the - lockfile to `.nsolid/backup/` before each upgrade. If something goes wrong, - the user can click "Rollback" in the post-upgrade notification, or manually - restore from that directory. +3. **NCM Security & Quality** — any vulnerabilities (severity + title), license, module risks flagged by NCM. If NCM data is unavailable, say so explicitly. +4. **SemVer Risk Assessment** — change type (major / minor / patch), risk level, and reasoning based on workspace usage and dependents. +5. **Breaking Changes** — use SemVer heuristics and any NCM-flagged issues. Do NOT fabricate specific changelog entries. Direct the user to the package's official CHANGELOG.md or release notes for the authoritative list. +6. **Step-by-step Upgrade Instructions** — exact commands for the user's package manager (npm / yarn / pnpm), plus any configuration file changes needed. +7. **Post-Upgrade Checklist** — run tests, check deprecation warnings, and verify peer dependencies (`npm ls`, `yarn explain peer-requirements`/`yarn why`, or `pnpm why`). +8. **Rollback Procedure** — N|Solid automatically backs up `package.json` and the lockfile to `.nsolid/backup/` before each upgrade. If something goes wrong, the user can click "Rollback" in the post-upgrade notification, or manually restore from that directory. ### 6. Validate Proposed Alternative (optional) -- If the user asks about switching to a different package instead of upgrading, - call `getPackageQuality` for the alternative and compare both results before - recommending. +- If the user asks about switching to a different package instead of upgrading, call `getPackageQuality` for the alternative and compare both results before recommending. ## Tools - `getPackageQuality` — query NCM for vulnerability severity, known issues, license, and quality scores for a single package. ## Guardrails -- Never fabricate changelog details, breaking changes, or migration guides. - NCM data does not include changelogs. Use SemVer heuristics and workspace - usage data to assess risk instead, and point the user to official release notes. -- If NCM data is unavailable, state that clearly and base advice only on SemVer - classification. +- Never fabricate changelog details, breaking changes, or migration guides. NCM data does not include changelogs. Use SemVer heuristics and workspace usage data to assess risk instead, and point the user to official release notes. +- If NCM data is unavailable, state that clearly and base advice only on SemVer classification. - Do not recommend downgrading to a version with known critical vulnerabilities. +- If peer-dependency conflicts appear, stop and propose compatible version ranges rather than forcing the install. - Rollback reminder is mandatory in every upgrade response. diff --git a/skills/ns-benchmark-validate/SKILL.md b/skills/ns-validate-optimization/SKILL.md similarity index 61% rename from skills/ns-benchmark-validate/SKILL.md rename to skills/ns-validate-optimization/SKILL.md index f86fae1..bbaf43e 100644 --- a/skills/ns-benchmark-validate/SKILL.md +++ b/skills/ns-validate-optimization/SKILL.md @@ -1,26 +1,12 @@ --- -name: ns-benchmark-validate +name: ns-validate-optimization description: >- - Validate a code optimization using scientifically controlled A/B benchmarks - via the ns-benchmark MCP server. Use when the user has an original and - optimized version of a function and wants to prove the performance - improvement with statistical rigor (ops/sec, p-value, improvement percentage). - Also invoked automatically after CPU or memory optimization skills propose a - fix. The final benchmark report is markdown-first and can be persisted to - `.nsolid/assets/` in generic-agent flows. + Validates a proposed optimization with controlled A/B benchmarks. Use when original and optimized versions of the same Node.js function need statistical proof, ops/sec comparison, p-value/significance, or a performance regression check. Also fits after ns-optimize-function or diagnostics produce a candidate fix. Use ns-benchmark-run for single-version timing only. --- -# NodeSource Benchmark Validation - -You are a NodeSource Performance Engineer who validates every optimization -with scientific rigor. A fix is not a fix until a controlled A/B benchmark -proves it. - -## Instructions - ### 1. Acquire Both Implementations and Inspect Their Project Context -This skill is usually called after `ns-analyze-cpu`, which sets a workspace context flag indicating whether the code is available locally. +This skill is usually called after `ns-cpu-spike-analysis`, which sets a workspace context flag indicating whether the code is available locally. **If the workspace context flag says the code is available:** - Read the original and optimized implementations from the workspace files. @@ -30,7 +16,7 @@ This skill is usually called after `ns-analyze-cpu`, which sets a workspace cont - Use the original implementation's real calling pattern as the source of truth for benchmark inputs. **If the workspace context flag says the code is NOT available:** -- Use the code provided by the user or from the prior `ns-analyze-cpu` flow. +- Use the code provided by the user or from the prior `ns-cpu-spike-analysis` flow. - Say clearly that no workspace or tests are available. - Derive the narrowest defensible benchmark inputs from the code itself. @@ -116,17 +102,10 @@ Do NOT call `run_benchmark`, `get_benchmark_result`, `compare_benchmarks`, or an --- -### Argument Examples +### Argument Examples (key pattern) -**Simple primitives — no argSetupCode needed:** -``` -args: [5, "test", true] -args: [{ "name": "John", "age": 30 }, { "sortBy": "date" }] -args: [[1, 2, 3], ["a", "b", "c"]] -args: [] // function with no parameters -``` +For complex external dependencies, add the dependency as an **explicit parameter** to BOTH original and optimized signatures, mock it in `argSetupCode`, and pass the **parameter name** (not the value) in `args`. Example (HTTP req/res): -**HTTP Request/Response (external dependency):** ``` // Original: function exampleFn(req, res) { arrExample.push(JSON.parse(resp)); res.end(); } // Transformed: function exampleFn(req, res, arrExample) { arrExample.push(JSON.parse(resp)); res.end(); } @@ -137,61 +116,7 @@ argSetupCode: const arrExample = []; ``` -**Database connection:** -``` -// Original: function queryDatabase(userId) { return db.collection('users').findOne({ _id: userId }); } -// Transformed: function queryDatabase(userId, db) { return db.collection('users').findOne({ _id: userId }); } -args: ["user123", "db"] -argSetupCode: - const db = { - collection: function(name) { - return { findOne: function(query) { return { name: 'Test User' }; } }; - } - }; -``` - -**File system:** -``` -// Original: function readConfigFile() { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } -// Transformed: function readConfigFile(fs, configPath) { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } -args: ["fs", "configPath"] -argSetupCode: - const fs = { readFileSync: function(path, enc) { return '{"apiKey":"test"}'; } }; - const configPath = '/etc/config.json'; -``` - -**Event emitters:** -``` -// Original: function processEvents(data) { eventEmitter.on('data', callback); } -// Transformed: function processEvents(data, eventEmitter, callback) { eventEmitter.on('data', callback); } -args: ["[1,2,3]", "eventEmitter", "callback"] -argSetupCode: - const data = [1,2,3]; - const callback = function(d) {}; - const eventEmitter = { - _events: {}, - on: function(event, handler) { this._events[event] = handler; }, - emit: function(event, data) { if (this._events[event]) this._events[event](data); } - }; -``` - -**Async/Await:** -``` -// Original: async function asyncExample() { return await fetchData(); } -// Transformed: async function asyncExample(fetchData) { return await fetchData(); } -args: ["fetchData"] -argSetupCode: - const fetchData = async function() { return { data: 'example data' }; }; -``` - -**Class instantiation:** -``` -// Original: function useClassExample() { const instance = new MyClass(); return instance.doSomething(); } -// Transformed: function useClassExample(MyClass) { const instance = new MyClass(); return instance.doSomething(); } -args: ["MyClass"] -argSetupCode: - class MyClass { constructor() {} doSomething() { return 'result'; } } -``` +For more patterns (DB, FS, event emitters, async/await, class instantiation), see `reference/benchmark-inputs.md` in the `ns-benchmark-run` skill directory. --- @@ -200,7 +125,7 @@ argSetupCode: Call `run_benchmark` with: - `functionData`: `{ type, code, explanation, entryPoint, args, argSetupCode? }` for the **original** - `isOptimized: false` -- If the MCP tool accepts it, also pass the user-confirmed `benchmarkConfig` as part of `functionData` (e.g. `functionData.benchmarkConfig`) or as a separate parameter. Include `repeatSuite`, `minSamples`, `minTime`, and `maxTime`. +- Pass the user-confirmed `benchmarkConfig` only if the `run_benchmark` schema exposes that parameter (or `functionData.benchmarkConfig`). If not, record the desired config and state that tool defaults were used. Note the returned `jobId` as `originalJobId`. @@ -214,7 +139,7 @@ node "<skill-dir>/wait.cjs" 20 ### 6. Get Original Result -Call `get_benchmark_result` with `originalJobId`. If not yet `"completed"`, run `wait.cjs 5` and poll again. +Call `get_benchmark_result` with `originalJobId`. If not yet `"completed"`, run `node "<skill-dir>/wait.cjs" 5` and poll again. Poll up to **12 times**; if still not completed, report the `originalJobId` and incomplete status rather than looping indefinitely. Extract the full `result` object from the response. It contains: - `result.name` — the benchmark name @@ -229,14 +154,13 @@ Keep the full `result` JSON available to present to the user. ### 7. Run Optimized Benchmark Attempts -You must try the optimized implementation up to **3 total attempts** if the -benchmark does not clear the effectiveness threshold on the first try. +You must try the optimized implementation up to **3 total attempts** if the benchmark does not clear the effectiveness threshold on the first try. For each optimized attempt: - Build optimized `functionData`: `{ type, code, explanation, entryPoint, args, argSetupCode? }` - Use the **exact same** `args`, `argSetupCode`, and `benchmarkConfig` as the original - Call `run_benchmark` with `isOptimized: true` -- If the MCP tool accepts it, also pass the user-confirmed `benchmarkConfig` as part of `functionData` or as a separate parameter — must be identical to what was used for the original run +- Pass `benchmarkConfig` only if the schema exposes it; if passed, it must be identical to the original run. - Note the returned `jobId` as `optimizedJobId` ### 8. Wait @@ -247,7 +171,7 @@ node "<skill-dir>/wait.cjs" 20 ### 9. Get Optimized Result -Call `get_benchmark_result` with `optimizedJobId`. If not yet `"completed"`, run `wait.cjs 5` and poll again. +Call `get_benchmark_result` with `optimizedJobId`. If not yet `"completed"`, run `node "<skill-dir>/wait.cjs" 5` and poll again. Poll up to **12 times**; if still not completed, report the `optimizedJobId` and incomplete status rather than looping indefinitely. Extract the full `result` object from the response, with the same fields as the original run (name, plugins, opsSec, opsSecPerRun, iterations, histogram, benchmarkConfig). @@ -264,7 +188,8 @@ Analyze: If the comparison is **not** `optimization_effective`: - inspect the benchmark evidence and your current optimized code -- revise the optimized implementation to attack the remaining bottleneck +- revise only the optimized implementation internals to attack the remaining bottleneck +- do not change arguments, return shape, side effects, sync/async behavior, error behavior, or mutability without explicit user approval - rerun only the optimized side with a new optimized attempt - keep the original benchmark as the baseline - stop early if an attempt reaches `optimization_effective` @@ -275,7 +200,7 @@ If none of the 3 optimized attempts reaches the threshold: - still present the **best** optimized attempt you measured - make it explicit that the final result did not meet the effectiveness threshold -### 11. Save a Markdown Benchmark Report +### 11. Build a Markdown Benchmark Report Build a markdown report with these sections: - title/date/type/function/location/benchmark ID (when available) @@ -284,25 +209,11 @@ Build a markdown report with these sections: - `## Results` - `## Tool Execution Log` -The `## Results` section must include the benchmark verdict, improvement -percentage, p-value, statistical significance, and the best optimized attempt -if multiple retries were required. - -For `## Tool Execution Log`, include the raw input/output pairs for each -`run_benchmark` call and the matching `get_benchmark_result` polls for that -same `jobId`, preserving chronological order across the original run and every -optimized attempt. +The `## Results` section must include the benchmark verdict, improvement percentage, p-value, statistical significance, and the best optimized attempt if multiple retries were required. -Persistence path: -- In participant or host-managed flows, present this markdown report inline and - let the host persist it automatically. -- In generic-agent flows, write the report to a temporary file and run: - ``` - node "<skill-dir>/save-report.cjs" benchmark "Benchmark Validation Report — <entryPoint>" /tmp/nsolid-benchmark-validate.md - ``` +For `## Tool Execution Log`, include the raw input/output pairs for each `run_benchmark` call and the matching `get_benchmark_result` polls for that same `jobId`, preserving chronological order across the original run and every optimized attempt. -If you used the local helper, report the saved markdown path alongside the -final verdict. +### 12. Present Results Present the comparison results in a markdown table showing original vs. optimized side by side. Include all relevant metrics so the user can see the full performance picture at a glance. @@ -324,66 +235,46 @@ The table must include: - **Variance assessment**: whether either histogram shows high variance - **Benchmark ID**: the benchmark job/reference identifier when available -When either side shows **high variance**, append a diagnostic paragraph below the table. Explain possible causes: -- **V8 JIT compilation stages**: functions may run in interpreter, baseline, or optimized tiers during the same benchmark, causing sporadic slowdowns or speedups -- **Garbage collection pauses**: if the function allocates memory, GC runs can introduce latency spikes in certain iterations -- **External factors**: system load, CPU throttling, or background processes can influence individual samples -- **Input sensitivity**: the function's performance may vary significantly with different argument values — consider testing a broader set of inputs - -Also recommend the user: -- increase `repeatSuite` or `minSamples` to gather more data if the variance is due to measurement noise -- inspect per-run ops/sec values from the `get_benchmark_result` output to see if variance is driven by individual outlier runs - Example table format: | Metric | Original | Optimized | |--------|----------|-----------| -| Function | `generatePattern` | `generatePattern` | -| ops/sec | 266.36 | 512.80 | -| Improvement | — | +92.4% | -| p-value | — | 0.0012 | -| Verdict | — | optimization_effective | -| Iterations | 2074 | 2156 | +| Function | `<entryPoint>` | `<entryPoint>` | +| ops/sec | <o> | <p> | +| Improvement % | — | +<n>% | +| p-value | — | <n> | +| Verdict | — | optimization_effective/not_effective | +| Iterations | <o> | <p> | | Runs | 15 | 15 | -| Histogram min | 1.65 ms | 0.85 ms | -| Histogram max | 7.40 ms | 3.20 ms | -| Histogram samples | 128 | 128 | +| Histogram min/max | <min> ms / <max> ms | <min> ms / <max> ms | | Config | repeatSuite=15, minSamples=10, minTime=0.05s, maxTime=0.5s | repeatSuite=15, minSamples=10, minTime=0.05s, maxTime=0.5s | -| Plugins | V8NeverOptimizePlugin | V8NeverOptimizePlugin | -| Variance | High (4.5x spread) | Low (3.8x spread) | -| Benchmark ID | `benchmark-abc123` | `benchmark-abc123` | +| Variance | High/Low (<n>x) | High/Low (<n>x) | +| Benchmark ID | `<id>` | `<id>` | -As a recommended next step, advise the user to validate the optimization under -representative load and capture fresh CPU profiles afterward. That follow-up -helps confirm whether the function-level benchmark improvement produces a -meaningful impact on end-to-end application performance. +When either side shows **high variance** (wide min/max spread), append a diagnostic line: note likely causes (V8 JIT tier transitions, GC pauses on allocating functions, system load/CPU throttling, input sensitivity) and recommend increasing `repeatSuite`/`minSamples` or inspecting per-run ops/sec for outliers. -### 12. Emit Structured Apply Metadata +As a recommended next step, advise the user to validate the optimization under representative load and capture fresh CPU profiles afterward. That follow-up helps confirm whether the function-level benchmark improvement produces a meaningful impact on end-to-end application performance. -After reporting the verdict, end the response with a single HTML comment -containing the data the host extension needs to offer an "Apply optimization" -action. Use the raw optimized source (unescaped newlines are fine inside a -JSON string if you properly escape them), the final verdict flags, and any -hot-function reference the extension provided in the prior CPU analysis. +### 13. Write the Report to Disk +- Ask the user if they want to save the report to disk. +- If the user confirms, write the final report as a markdown file (`.md`) under `.nsolid/assets/` — for example `.nsolid/assets/benchmark-<entryPoint>.md`. Report the saved path alongside the final verdict. + +### 14. Emit Structured Apply Metadata + +After reporting the verdict, end the response with a single HTML comment containing the data the host extension needs to offer an "Apply optimization" action. Use the raw optimized source (unescaped newlines are fine inside a JSON string if you properly escape them), the final verdict flags, and any hot-function reference the extension provided in the prior CPU analysis. ``` <!-- nsolid-ide-optimized: {"code":"<optimized source>","entryPoint":"<entryPoint>","improvementPct":<number>,"pValue":<number>,"isSignificant":<bool>,"verdictEffective":<bool>} --> ``` -Only emit the marker when a valid A/B comparison completed. If the benchmark -failed, timed out, or the original/optimized code was unavailable, omit the -marker entirely — the host extension will not offer the apply action. +Only emit the marker when a valid A/B comparison completed. If the benchmark failed, timed out, or the original/optimized code was unavailable, omit the marker entirely — the host extension will not offer the apply action. ## Guardrails - - When the workspace is available, NEVER skip searching for real call sites and tests before proposing arguments. - If tests exist for the original function or its immediate caller, inspect them before proposing benchmark inputs. - NEVER run benchmark tools before the user confirms both the proposed shared arguments AND the benchmark configuration. -- You MUST use the exact same `args`, `argSetupCode`, and `benchmarkConfig` for both runs — otherwise the comparison is statistically invalid. -- NEVER use different `args`, `argSetupCode`, or `benchmarkConfig` between the original and optimized runs. +- The `args`, `argSetupCode`, and `benchmarkConfig` MUST be identical for both runs — otherwise the A/B comparison is statistically invalid. - NEVER skip the wait steps — always use `wait.cjs`, do not rely on estimating time. - A fix is not a fix until `compare_benchmarks` returns `"optimization_effective"`. - NEVER poll immediately after submitting a benchmark — always wait first. -- If an optimized attempt does not pass the threshold, do not stop after one - miss. Revise the optimized code and retry until you either succeed or finish - 3 optimized attempts total. +- If an optimized attempt does not pass the threshold, do not stop after one miss. Revise within the approved contract and retry until you either succeed or finish 3 optimized attempts total. diff --git a/skills/ns-validate-optimization/wait.cjs b/skills/ns-validate-optimization/wait.cjs new file mode 100644 index 0000000..68feac2 --- /dev/null +++ b/skills/ns-validate-optimization/wait.cjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +// wait.cjs — sleeps for N seconds. More reliable than asking the AI to "wait". +// Usage: node wait.cjs <seconds> + +'use strict' + +const seconds = parseFloat(process.argv[2]) +if (!seconds || isNaN(seconds) || seconds <= 0) { + console.error('Usage: node wait.cjs <seconds>') + process.exit(1) +} + +setTimeout(() => {}, seconds * 1000) From d60cd84bbdc0e92993a4fd56bbacb26e2d431edd Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Tue, 30 Jun 2026 16:15:01 +0200 Subject: [PATCH 05/11] fix: sync bundle and skills --- bundle.json | 2 +- packages/core/bundle.json | 2 +- .../core/scripts/skill-assets.manifest.json | 11 ++--------- skill-assets/fetch-asset.cjs | 17 +++++++++++------ skills/ns-advanced-memory-leak-hunter/SKILL.md | 4 ++-- .../fetch-asset.cjs | 17 +++++++++++------ skills/ns-analyze-asset/SKILL.md | 6 +++--- skills/ns-analyze-asset/fetch-asset.cjs | 17 +++++++++++------ skills/ns-analyze-tracing/SKILL.md | 5 +++-- skills/ns-audit-dependencies/SKILL.md | 2 +- .../reference/benchmark-inputs.md | 7 ++++--- skills/ns-cpu-spike-analysis/fetch-asset.cjs | 17 +++++++++++------ skills/ns-generate-asset/SKILL.md | 2 +- skills/ns-generate-asset/fetch-asset.cjs | 17 +++++++++++------ skills/ns-memory-spike-analysis/fetch-asset.cjs | 17 +++++++++++------ 15 files changed, 84 insertions(+), 59 deletions(-) diff --git a/bundle.json b/bundle.json index 812eff6..08b26c4 100644 --- a/bundle.json +++ b/bundle.json @@ -66,7 +66,7 @@ { "name": "ns-memory-spike-analysis", "path": "skills/ns-memory-spike-analysis", - "description": "Take and analyze heap snapshots of running production memory", + "description": "Analyze memory spikes and suspected leaks via baseline/peak heap sampling, with optional track-heap-objects for retainer analysis", "requiresMcp": ["nsolid-console"] }, { diff --git a/packages/core/bundle.json b/packages/core/bundle.json index 812eff6..08b26c4 100644 --- a/packages/core/bundle.json +++ b/packages/core/bundle.json @@ -66,7 +66,7 @@ { "name": "ns-memory-spike-analysis", "path": "skills/ns-memory-spike-analysis", - "description": "Take and analyze heap snapshots of running production memory", + "description": "Analyze memory spikes and suspected leaks via baseline/peak heap sampling, with optional track-heap-objects for retainer analysis", "requiresMcp": ["nsolid-console"] }, { diff --git a/packages/core/scripts/skill-assets.manifest.json b/packages/core/scripts/skill-assets.manifest.json index 961d5b0..cf37e91 100644 --- a/packages/core/scripts/skill-assets.manifest.json +++ b/packages/core/scripts/skill-assets.manifest.json @@ -8,18 +8,11 @@ "ns-memory-spike-analysis", "ns-validate-optimization" ], - "save-report.cjs": [ - "ns-advanced-memory-leak-hunter", - "ns-analyze-asset", - "ns-analyze-event", - "ns-benchmark-run", - "ns-cpu-spike-analysis", - "ns-memory-spike-analysis", - "ns-validate-optimization" - ], "fetch-asset.cjs": [ "ns-advanced-memory-leak-hunter", + "ns-analyze-asset", "ns-cpu-spike-analysis", + "ns-generate-asset", "ns-memory-spike-analysis" ] } diff --git a/skill-assets/fetch-asset.cjs b/skill-assets/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skill-assets/fetch-asset.cjs +++ b/skill-assets/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { diff --git a/skills/ns-advanced-memory-leak-hunter/SKILL.md b/skills/ns-advanced-memory-leak-hunter/SKILL.md index 216da38..3ea1961 100644 --- a/skills/ns-advanced-memory-leak-hunter/SKILL.md +++ b/skills/ns-advanced-memory-leak-hunter/SKILL.md @@ -14,7 +14,7 @@ description: >- ``` node "<skill-dir>/wait.cjs" 30 ``` -3. Call `asset-summary` on the returned baseline asset ID. If not ready, wait 10s and retry; use `assets-in-progress` only as a secondary queue clue. +3. Call `asset-summary` on the returned baseline asset ID. If not ready, run `node "<skill-dir>/wait.cjs" 10` and retry; use `assets-in-progress` only as a secondary queue clue. Cap retries at **12**; if still not ready, report the baseline asset ID and its pending state instead of continuing indefinitely. 4. From the baseline summary, note the top allocating constructors (e.g., `Object`, `Array`, `system / Map`). 5. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same baseline asset ID. If it is already present locally, skip the download. 6. If the baseline asset is not present, save it locally: @@ -34,7 +34,7 @@ description: >- ``` node "<skill-dir>/wait.cjs" 60 ``` - Then call `asset-summary` on the peak asset ID. If not ready, wait 10s and retry; use `assets-in-progress` only as a secondary queue clue. + Then call `asset-summary` on the peak asset ID. If not ready, run `node "<skill-dir>/wait.cjs" 10` and retry; use `assets-in-progress` only as a secondary queue clue. Cap retries at **12**; if still not ready, report the peak asset ID and its pending state instead of continuing indefinitely. 4. Analyze the `asset-summary`. 5. Check `.nsolid/assets/index.json` and `.nsolid/assets/` for the same peak asset ID. If it is already present locally, skip the download. 6. If the peak asset is not present, save it locally: diff --git a/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs b/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs +++ b/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { diff --git a/skills/ns-analyze-asset/SKILL.md b/skills/ns-analyze-asset/SKILL.md index 6fd8fc1..6093a40 100644 --- a/skills/ns-analyze-asset/SKILL.md +++ b/skills/ns-analyze-asset/SKILL.md @@ -20,7 +20,7 @@ description: >- Heap snapshot summarization is asynchronous and may not be ready on the first call. Use this retry loop: 1. Call `asset-summary` with the asset ID. -2. If the response body contains `"processing"`, `"summarization started"`, or a reference to `"assets-in-progress"`, the snapshot is still being summarized — do NOT analyze it yet. +2. If the response body contains `"pending"`, `"processing"`, `"summarization started"`, `"async"`, or a reference to `"assets-in-progress"`, the snapshot is still being summarized — do NOT analyze it yet. 3. Call `assets-in-progress` to check the queue position. 4. Run the wait script (use the absolute path of the directory where you read this SKILL.md): ``` @@ -34,9 +34,9 @@ Heap snapshot summarization is asynchronous and may not be ready on the first ca **Never analyze a heap snapshot summary that is still marked as processing.** #### No asset ID -- If the user gives a local `.cpuprofile`, `.heapprofile`, or `.heapsnapshot` path instead of an asset ID, resolve the full asset ID from `.nsolid/assets/index.json` or from the filename pattern `.nsolid/assets/<assetType>-<appName>-<assetIdPrefix>.<ext>`, then analyze it with `asset-summary`. +- If the user gives a local `.cpuprofile`, `.heapprofile`, or `.heapsnapshot` path instead of an asset ID, prefer resolving the full asset ID from `.nsolid/assets/index.json`. The flat filename pattern `.nsolid/assets/<assetType>-<appName>-<assetIdPrefix>.<ext>` produced by `fetch-asset.cjs` carries only an 8-character asset ID **prefix** (not the full ID), so it is not sufficient on its own. - **Never read the raw asset file into context** — it is large and token-wasteful. Always go through the token-optimized `asset-summary`. -- If the asset ID cannot be resolved and MCP is unavailable, state that clearly and stop. +- If `index.json` is missing and MCP is unavailable, tell the user the full asset ID cannot be recovered (only the filename prefix is known) and stop. ### 3. Analyze by Asset Type diff --git a/skills/ns-analyze-asset/fetch-asset.cjs b/skills/ns-analyze-asset/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skills/ns-analyze-asset/fetch-asset.cjs +++ b/skills/ns-analyze-asset/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { diff --git a/skills/ns-analyze-tracing/SKILL.md b/skills/ns-analyze-tracing/SKILL.md index 1fcddc7..b16ae61 100644 --- a/skills/ns-analyze-tracing/SKILL.md +++ b/skills/ns-analyze-tracing/SKILL.md @@ -6,10 +6,11 @@ description: >- ### 1. Use Provided Trace Data First - Treat the prompt as primary evidence. Parse any provided trace ID, span list, app name, endpoint name, status code, duration, or error stack before calling tools. -- If the user supplied a trace tree/export, map that hierarchy directly. -- If the user supplied only a trace ID, call `tracing` with `span_traceId` for list-level rows only; the MCP tool does not expose full waterfall details. +- If the user supplied a trace tree/export or any other host-provided trace data, treat it as authoritative: map that hierarchy directly and **do not fall through to live enrichment** (`information-dashboard`, `tracing`). The connected-services discovery (step 2) and live `tracing` queries (steps 3–4) are only for when no sufficient host data is present. +- If the user supplied only a trace ID (not a full tree), call `tracing` with `span_traceId` for list-level rows only; the MCP tool does not expose full waterfall details. ### 2. Discover Connected Services Only When Needed +- **Skip this step entirely if sufficient host-provided trace data is present** (step 1 already authoritatively covered it). - Call `information-dashboard` (no parameters) to list all connected agents and their `app` names and `id` values. - If the user mentions a specific service or app name, use that directly and skip this step. - Use `serverless-functions` instead if the user is asking about a serverless function. diff --git a/skills/ns-audit-dependencies/SKILL.md b/skills/ns-audit-dependencies/SKILL.md index 1969da9..0ddd22f 100644 --- a/skills/ns-audit-dependencies/SKILL.md +++ b/skills/ns-audit-dependencies/SKILL.md @@ -6,7 +6,7 @@ description: >- ### 1. Collect Dependencies -**If grounded audit data is already provided in the prompt** (a `## Audit Results` block injected by the host), skip to step 3 and use that data exclusively. Do not re-fetch packages that are already covered. +**If grounded audit data is already provided in the prompt** (a `## Audit Results` block injected by the host), stop at that data: use it exclusively and skip steps 2 (NCM queries) and 3 (N|Solid live enrichment). Do not re-fetch packages that are already covered, and do not continue into any live enrichment or re-fetch logic — the injected data is the complete source of truth. **Otherwise (MCP-only / agent mode):** - Run the bundled helper to extract all packages (use the absolute path of the directory where you read this SKILL.md): diff --git a/skills/ns-benchmark-run/reference/benchmark-inputs.md b/skills/ns-benchmark-run/reference/benchmark-inputs.md index e702841..20900a0 100644 --- a/skills/ns-benchmark-run/reference/benchmark-inputs.md +++ b/skills/ns-benchmark-run/reference/benchmark-inputs.md @@ -21,12 +21,13 @@ args: [] // function with no parameters ``` // Original: function exampleFn(req, res) { arrExample.push(JSON.parse(resp)); res.end(); } -// Transformed: function exampleFn(req, res, arrExample) { arrExample.push(JSON.parse(resp)); res.end(); } -args: ["req", "res", "arrExample"] +// Transformed: function exampleFn(req, res, arrExample, resp) { arrExample.push(JSON.parse(resp)); res.end(); } +args: ["req", "res", "arrExample", "resp"] argSetupCode: const req = { url: '/test' }; const res = { writeHead: function() {}, write: function() {}, end: function() {} }; const arrExample = []; + const resp = JSON.stringify({ key: 'value' }); ``` ## Database connection @@ -59,7 +60,7 @@ argSetupCode: ``` // Original: function processEvents(data) { eventEmitter.on('data', callback); } // Transformed: function processEvents(data, eventEmitter, callback) { eventEmitter.on('data', callback); } -args: ["[1,2,3]", "eventEmitter", "callback"] +args: ["data", "eventEmitter", "callback"] argSetupCode: const data = [1,2,3]; const callback = function(d) {}; diff --git a/skills/ns-cpu-spike-analysis/fetch-asset.cjs b/skills/ns-cpu-spike-analysis/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skills/ns-cpu-spike-analysis/fetch-asset.cjs +++ b/skills/ns-cpu-spike-analysis/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { diff --git a/skills/ns-generate-asset/SKILL.md b/skills/ns-generate-asset/SKILL.md index 0092157..76f3581 100644 --- a/skills/ns-generate-asset/SKILL.md +++ b/skills/ns-generate-asset/SKILL.md @@ -37,7 +37,7 @@ Run the bundled wait script (use the absolute path of the directory where you re - Heap tracking: `node "<skill-dir>/wait.cjs" 35`. ### 5. Check Readiness -- CPU profile and heap sample: call `asset-summary` on the returned asset ID. If not ready, run `node "<skill-dir>/wait.cjs" 5` and retry on the same ID. +- CPU profile and heap sample: call `asset-summary` on the returned asset ID. If not ready, run `node "<skill-dir>/wait.cjs" 5` and retry on the same ID. Cap retries at **12**. If still not ready, report the asset ID and the pending state — do not invent analysis. - Heap snapshot: call `asset-summary` first. If the response says async, processing, pending, or summarization started, call `assets-in-progress`, then run `node "<skill-dir>/wait.cjs" 5`, then retry `asset-summary`. Cap retries at **12**. If still not ready, report the asset ID and the pending state — do not invent analysis. - Heap tracking: call `assets-in-progress`. If the returned asset ID is still in progress, run `node "<skill-dir>/wait.cjs" 5` and retry. Cap retries at **12**. Once it is no longer in progress, continue to download. - If `asset-summary` returns a tool error (auth, network, MCP failure), report the error and stop. Do not retry as if pending. diff --git a/skills/ns-generate-asset/fetch-asset.cjs b/skills/ns-generate-asset/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skills/ns-generate-asset/fetch-asset.cjs +++ b/skills/ns-generate-asset/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { diff --git a/skills/ns-memory-spike-analysis/fetch-asset.cjs b/skills/ns-memory-spike-analysis/fetch-asset.cjs index 2415414..02fa2e1 100644 --- a/skills/ns-memory-spike-analysis/fetch-asset.cjs +++ b/skills/ns-memory-spike-analysis/fetch-asset.cjs @@ -56,15 +56,20 @@ function buildAssetFilename (assetType, appName, assetId) { function readAssetIndex (workspaceRoot) { const indexPath = path.join(getAssetsDir(workspaceRoot), 'index.json') - try { - if (fs.existsSync(indexPath)) { - return JSON.parse(fs.readFileSync(indexPath, 'utf-8')) - } - } catch { + // Only treat a genuinely missing index as empty. A file that exists but is + // unreadable or malformed must NOT be swallowed into [] — otherwise the next + // saveToAssetIndex() upsert would overwrite it and silently drop every + // existing entry. Surface that error so the caller can fail loudly. + if (!fs.existsSync(indexPath)) { return [] } - return [] + const raw = fs.readFileSync(indexPath, 'utf-8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { + throw new Error(`index.json is not an array; refusing to overwrite a malformed index at ${indexPath}`) + } + return parsed } function isPathWithin (parent, candidate) { From 447137dd4de1c8896a743494ca9d1fcb7216e73a Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Tue, 30 Jun 2026 16:34:47 +0200 Subject: [PATCH 06/11] fix regression in fetch asset --- skill-assets/fetch-asset.cjs | 11 ++++++++++- skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs | 11 ++++++++++- skills/ns-analyze-asset/fetch-asset.cjs | 11 ++++++++++- skills/ns-cpu-spike-analysis/fetch-asset.cjs | 11 ++++++++++- skills/ns-generate-asset/fetch-asset.cjs | 11 ++++++++++- skills/ns-memory-spike-analysis/fetch-asset.cjs | 11 ++++++++++- 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/skill-assets/fetch-asset.cjs b/skill-assets/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skill-assets/fetch-asset.cjs +++ b/skill-assets/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { diff --git a/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs b/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs +++ b/skills/ns-advanced-memory-leak-hunter/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { diff --git a/skills/ns-analyze-asset/fetch-asset.cjs b/skills/ns-analyze-asset/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skills/ns-analyze-asset/fetch-asset.cjs +++ b/skills/ns-analyze-asset/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { diff --git a/skills/ns-cpu-spike-analysis/fetch-asset.cjs b/skills/ns-cpu-spike-analysis/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skills/ns-cpu-spike-analysis/fetch-asset.cjs +++ b/skills/ns-cpu-spike-analysis/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { diff --git a/skills/ns-generate-asset/fetch-asset.cjs b/skills/ns-generate-asset/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skills/ns-generate-asset/fetch-asset.cjs +++ b/skills/ns-generate-asset/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { diff --git a/skills/ns-memory-spike-analysis/fetch-asset.cjs b/skills/ns-memory-spike-analysis/fetch-asset.cjs index 02fa2e1..b41c216 100644 --- a/skills/ns-memory-spike-analysis/fetch-asset.cjs +++ b/skills/ns-memory-spike-analysis/fetch-asset.cjs @@ -119,7 +119,16 @@ function resolveExistingAsset (workspaceRoot, assetId, assetType, appName) { } } - const indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + // Lookups are lenient: a malformed/unreadable index.json must not block the + // legacy fallback probe below. readAssetIndex() stays strict for the write + // path (saveToAssetIndex) so a bad index is never silently clobbered; here + // we only need a best-effort match and treat any read failure as "no match". + let indexRecord + try { + indexRecord = readAssetIndex(workspaceRoot).find(record => record.assetId === assetId) + } catch { + indexRecord = undefined + } if (indexRecord?.localPath) { const indexedPath = path.resolve(assetsDir, indexRecord.localPath) if (isPathWithin(assetsDir, indexedPath) && fs.existsSync(indexedPath)) { From a3a269ec623eb46af369a14c966cc7e289b7fe64 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Wed, 1 Jul 2026 10:48:42 +0200 Subject: [PATCH 07/11] fix: doctor pi plugin name search --- packages/core/src/harnesses/pi-plugin-detector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/harnesses/pi-plugin-detector.ts b/packages/core/src/harnesses/pi-plugin-detector.ts index d61c0e0..11d3429 100644 --- a/packages/core/src/harnesses/pi-plugin-detector.ts +++ b/packages/core/src/harnesses/pi-plugin-detector.ts @@ -1,6 +1,5 @@ import os from 'node:os' import path from 'node:path' -import { existsSync } from 'node:fs' import { readJsonFile } from '../utils/config.js' /** @@ -107,7 +106,8 @@ export function findPiPluginSkillRoots (): string[] { export function piPluginInstalled (): boolean { return findPiPluginPackageRoots().some((root) => { try { - return existsSync(path.join(root, 'package.json')) + const pkg = readJsonFile<{ name?: string }>(path.join(root, 'package.json')) + return pkg?.name === PI_PLUGIN_PACKAGE_NAME } catch { return false } From 86d759cd5e65fd1f472807a8616924567e475dac Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Wed, 1 Jul 2026 17:04:34 +0200 Subject: [PATCH 08/11] fix: fix claude and agy paths for doctor and improve uninstall functionality in cli to uninstall plugins --- README.md | 2 +- docs/plugin-marketplace-research.md | 4 +- packages/core/README.md | 2 +- .../core/src/harnesses/antigravity-adapter.ts | 46 +++- packages/core/src/harnesses/claude-adapter.ts | 70 +++-- packages/core/src/harnesses/codex-adapter.ts | 34 ++- .../core/src/harnesses/harness-adapter.ts | 8 + .../harnesses/native-plugin-uninstaller.ts | 247 ++++++++++++++++++ packages/core/src/harnesses/plugin-name.ts | 17 ++ packages/core/src/index.ts | 25 ++ packages/core/src/mcp/mcp-config-writer.ts | 2 +- .../core/test/integration/installer.test.ts | 2 +- .../test/integration/multi-harness.test.ts | 2 +- .../harnesses/antigravity-adapter.test.ts | 79 +++++- .../unit/harnesses/claude-adapter.test.ts | 128 +++++++++ .../test/unit/harnesses/codex-adapter.test.ts | 55 ++++ .../unit/harnesses/harness-adapter.test.ts | 2 +- .../native-plugin-uninstaller.test.ts | 210 +++++++++++++++ .../test/unit/mcp/mcp-config-writer.test.ts | 12 +- scripts/test-marketplace-install.js | 2 +- 20 files changed, 880 insertions(+), 69 deletions(-) create mode 100644 packages/core/src/harnesses/native-plugin-uninstaller.ts create mode 100644 packages/core/src/harnesses/plugin-name.ts create mode 100644 packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts diff --git a/README.md b/README.md index dc26445..f05f132 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ agy plugin install https://github.com/NodeSource/nsolid-plugin.git nsolid-plugin setup --harness antigravity ``` -Antigravity installs the repository root as a native plugin and stages skills/MCP wrappers under `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. Install does not start auth. +Antigravity installs the repository root as a native plugin and stages skills/MCP wrappers under `~/.gemini/config/plugins/nsolid-plugin/`. Install does not start auth. ### Pi Agent diff --git a/docs/plugin-marketplace-research.md b/docs/plugin-marketplace-research.md index 09a31e5..23ffa38 100644 --- a/docs/plugin-marketplace-research.md +++ b/docs/plugin-marketplace-research.md @@ -123,7 +123,7 @@ Design impact: Key findings: - Antigravity CLI installs plugins with `agy plugin install /path/to/local/plugin` or remote equivalents. -- Installed plugins are staged under `~/.gemini/antigravity-cli/plugins/<plugin_name>/`. +- Installed plugins are staged under `~/.gemini/config/plugins/<plugin_name>/` (the legacy `~/.gemini/antigravity-cli/plugins/` path is not read at runtime). - A compliant plugin contains `plugin.json` plus optional `mcp_config.json`, `hooks.json`, `skills/`, `agents/`, and `rules/`. - Antigravity hooks use a top-level named-hook map in `hooks.json`, but N|Solid does not ship startup guidance hooks because auth guidance is handled by explicit setup commands and runtime MCP errors. - CLI management commands include `agy plugin list`, `agy plugin install`, `agy plugin disable`, `agy plugin enable`, and `agy plugin uninstall`. @@ -134,7 +134,7 @@ Design impact: - Generate `dist/plugins/antigravity/nsolid-plugin/` with `plugin.json`, `mcp_config.json`, `scripts/install.js`, `scripts/mcp-wrapper.js`, and `skills/`. - Native Antigravity install should stage artifact-local assets, not resolve skills from `@nodesource/plugin-core` at install time. - Native install should not write global skill directories or start auth. It may print `nsolid-plugin setup --harness antigravity` as the next step. -- Fallback direct install can still write `~/.gemini/antigravity-cli/mcp_config.json` and `~/.gemini/antigravity-cli/skills/` when users cannot use `agy plugin install`. +- Fallback direct install can still write `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/` when users cannot use `agy plugin install`. ## OpenCode docs and fallback decision diff --git a/packages/core/README.md b/packages/core/README.md index b57cf4a..d5a6097 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -49,7 +49,7 @@ Each harness has an adapter that provides its config and skills paths: | Claude | Plugin-owned `.mcp.json` | Plugin-owned `skills/` | Yes | | Codex | Plugin-owned `.mcp.json` | Plugin-owned `skills/` | Yes | | OpenCode | `~/.config/opencode/opencode.jsonc` | `~/.config/opencode/skills/` | Yes | -| Antigravity | Plugin-owned `~/.gemini/antigravity-cli/plugins/nsolid-plugin/mcp_config.json` | Plugin-owned `~/.gemini/antigravity-cli/plugins/nsolid-plugin/skills/` | Yes | +| Antigravity | Plugin-owned `~/.gemini/config/plugins/nsolid-plugin/mcp_config.json` | Plugin-owned `~/.gemini/config/plugins/nsolid-plugin/skills/` | Yes | | Pi | `~/.pi/agent/mcp.json` | Package-owned `nsolid-pi-plugin/skills/` | Yes | ## CLI diff --git a/packages/core/src/harnesses/antigravity-adapter.ts b/packages/core/src/harnesses/antigravity-adapter.ts index 0881bce..e9f7163 100644 --- a/packages/core/src/harnesses/antigravity-adapter.ts +++ b/packages/core/src/harnesses/antigravity-adapter.ts @@ -4,26 +4,35 @@ import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-ad import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' +import { readJsonFile } from '../utils/config.js' const PLUGIN_NAME = 'nsolid-plugin' +const IMPORT_MANIFEST_REL = '~/.gemini/config/import_manifest.json' export class AntigravityAdapter implements HarnessAdapter { readonly name: HarnessType = 'antigravity' getMcpConfigPath (): string { - return resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + // Antigravity's shared, cross-product MCP config. Per + // https://antigravity.google/docs/skills this lives under the same + // `~/.gemini/config/` root as skills. The legacy + // `~/.gemini/antigravity-cli/mcp_config.json` is agy-CLI-only and is NOT + // read at runtime. + return resolveHome('~/.gemini/config/mcp_config.json') } getPluginsPath (): string { - // `agy plugin install` stages native plugins under the antigravity-cli root. - return resolveHome('~/.gemini/antigravity-cli/plugins') + // `agy plugin install` clones native plugins under the shared + // `~/.gemini/config/plugins/` root and records them in + // `~/.gemini/config/import_manifest.json`. + return resolveHome('~/.gemini/config/plugins') } getSkillsPath (): string { - // Antigravity loads global skills from ~/.gemini/antigravity-cli/skills/ (per - // https://antigravity.google/docs/cli-plugins), the same root as the MCP config - // at ~/.gemini/antigravity-cli/mcp_config.json. - return resolveHome('~/.gemini/antigravity-cli/skills/') + // Antigravity loads global skills from ~/.gemini/config/skills/ (per + // https://antigravity.google/docs/skills), the same root as the shared MCP + // config at ~/.gemini/config/mcp_config.json. + return resolveHome('~/.gemini/config/skills/') } supportsMcp (): boolean { @@ -46,8 +55,11 @@ export class AntigravityAdapter implements HarnessAdapter { /** * Antigravity stages a native plugin at - * `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. There is no separate - * enable flag, so `installed` follows from the staged directory existing. + * `~/.gemini/config/plugins/nsolid-plugin/` (the directory `agy plugin + * install` clones into) and records the import in + * `~/.gemini/config/import_manifest.json`. Either signal counts as + * installed; there is no separate enable flag, so `enabled` follows from the + * plugin being present. */ detectNativePlugin (): NativePluginStatus { const status: NativePluginStatus = { installed: false, label: PLUGIN_NAME } @@ -55,6 +67,22 @@ export class AntigravityAdapter implements HarnessAdapter { if (existsSync(staged)) { status.installed = true status.enabled = true + status.installedIds = [PLUGIN_NAME] + return status + } + + // Fall back to the import manifest: the staged directory may have been + // removed out of band while the manifest entry remains (or vice versa). + try { + const manifest = readJsonFile<{ imports?: Array<{ name?: string }> }>(resolveHome(IMPORT_MANIFEST_REL)) + const recorded = manifest?.imports?.some((entry) => entry?.name === PLUGIN_NAME) ?? false + if (recorded) { + status.installed = true + status.enabled = true + status.installedIds = [PLUGIN_NAME] + } + } catch { + // Corrupt or unreadable manifest — fall through (detection is best-effort). } return status } diff --git a/packages/core/src/harnesses/claude-adapter.ts b/packages/core/src/harnesses/claude-adapter.ts index 6bc74a7..97c394f 100644 --- a/packages/core/src/harnesses/claude-adapter.ts +++ b/packages/core/src/harnesses/claude-adapter.ts @@ -4,8 +4,7 @@ import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' import { readJsonFile } from '../utils/config.js' - -const PLUGIN_ID = 'nsolid-plugin@nodesource' +import { isNsolidPluginId, PLUGIN_BASE_NAME } from './plugin-name.js' export class ClaudeAdapter implements HarnessAdapter { readonly name: HarnessType = 'claude' @@ -38,44 +37,55 @@ export class ClaudeAdapter implements HarnessAdapter { * Claude Code records native plugins in * `~/.claude/plugins/installed_plugins.json` and an enable map in * `~/.claude.json` (`enabledPlugins`). The installed_plugins schema has - * varied across versions (a map or a `{plugins:[...]}` array), so each is - * tolerated; an explicit entry counts as installed, and `enabled` is set - * only when an enabled map explicitly lists the id. + * varied across versions: + * - v2 map: `{ version: 2, plugins: { "<name>@<marketplace>": [ ...records ] } }` + * - legacy: `{ plugins: [ { id: "..." } | "<id>" ] }` or a bare map of ids. + * Each shape is tolerated. An explicit entry counts as installed; `enabled` + * is set only when `~/.claude.json`'s `enabledPlugins` map lists the id as + * true. The nsolid plugin is matched by base name (`nsolid-plugin` or + * `nsolid-plugin@<marketplace>`) so detection survives a marketplace rename + * (e.g. acceptance into Anthropic's community marketplace). */ detectNativePlugin (): NativePluginStatus { - const status: NativePluginStatus = { installed: false, label: PLUGIN_ID } + const status: NativePluginStatus = { installed: false, label: PLUGIN_BASE_NAME } const installedPath = path.join(this.getPluginsDir(), 'installed_plugins.json') + let matchedIds: string[] = [] try { const data = readJsonFile<unknown>(installedPath) - const ids = extractPluginIds(data) - if (ids.includes(PLUGIN_ID)) { - status.installed = true - } + matchedIds = extractPluginIds(data).filter(isNsolidPluginId) } catch { // Unreadable installed_plugins.json — fall through. } - try { - const settings = readJsonFile<Record<string, unknown>>(this.getMcpConfigPath()) - const enabledPlugins = settings?.enabledPlugins - if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) { - const map = enabledPlugins as Record<string, unknown> - if (map[PLUGIN_ID] === true) { - status.enabled = true - } else if (map[PLUGIN_ID] === false) { - status.enabled = false + let enabledId: string | undefined + if (matchedIds.length > 0) { + try { + const settings = readJsonFile<Record<string, unknown>>(this.getMcpConfigPath()) + const enabledPlugins = settings?.enabledPlugins + if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) { + const map = enabledPlugins as Record<string, unknown> + enabledId = matchedIds.find((id) => map[id] === true) + const disabled = matchedIds.find((id) => map[id] === false) + status.enabled = enabledId !== undefined ? true : (disabled !== undefined ? false : undefined) } + } catch { + // settings.json unreadable — enabled stays undefined. } - } catch { - // settings.json unreadable — enabled stays undefined. - } + status.installed = true + status.installedIds = matchedIds + status.label = enabledId ?? matchedIds[0] + } return status } } -function extractPluginIds (data: unknown): string[] { +/** + * Extract candidate plugin ids from any tolerated installed_plugins schema. + * Does not filter by plugin — callers filter with `isNsolidPluginId`. + */ +export function extractPluginIds (data: unknown): string[] { if (!data || typeof data !== 'object') return [] if (Array.isArray(data)) { return data.flatMap((v) => { @@ -88,9 +98,11 @@ function extractPluginIds (data: unknown): string[] { } const obj = data as Record<string, unknown> - const arr = obj.plugins - if (Array.isArray(arr)) { - return arr.flatMap((v) => { + + // v2 (and current) schema: `{ version, plugins: { "<id>": [...] } }`. + const pluginsField = obj.plugins + if (Array.isArray(pluginsField)) { + return pluginsField.flatMap((v) => { if (typeof v === 'string') return [v] if (v && typeof v === 'object' && typeof (v as { id?: unknown }).id === 'string') { return [(v as { id: string }).id] @@ -98,5 +110,9 @@ function extractPluginIds (data: unknown): string[] { return [] }) } - return Object.keys(obj) + if (pluginsField && typeof pluginsField === 'object') { + return Object.keys(pluginsField as Record<string, unknown>) + } + + return [] } diff --git a/packages/core/src/harnesses/codex-adapter.ts b/packages/core/src/harnesses/codex-adapter.ts index 0b411ce..7820f98 100644 --- a/packages/core/src/harnesses/codex-adapter.ts +++ b/packages/core/src/harnesses/codex-adapter.ts @@ -3,8 +3,7 @@ import type { HarnessType } from '../types.js' import { resolveHome } from '../utils/path.js' import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js' import { readTomlFile } from '../utils/config.js' - -const PLUGIN_KEY = 'nsolid-plugin@nodesource' +import { isNsolidPluginId, PLUGIN_BASE_NAME } from './plugin-name.js' export class CodexAdapter implements HarnessAdapter { readonly name: HarnessType = 'codex' @@ -30,24 +29,35 @@ export class CodexAdapter implements HarnessAdapter { } /** - * Codex records a native plugin install in `~/.codex/config.toml`: + * Codex records a native plugin install in `~/.codex/config.toml` as a table + * keyed by `<name>@<marketplace>`: * [plugins."nsolid-plugin@nodesource"] * enabled = true - * The entry itself drives `installed`; only the `enabled = true` flag - * sets `enabled`. + * The marketplace suffix varies by install source (e.g. our `@nodesource` + * marketplace, or `@claude-plugins-official` if accepted into Anthropic's + * community marketplace), so we match by base name. Any matching table drives + * `installed`; `enabled` is true only when one has `enabled = true`. */ detectNativePlugin (): NativePluginStatus { - const status: NativePluginStatus = { installed: false, label: PLUGIN_KEY } + const status: NativePluginStatus = { installed: false, label: PLUGIN_BASE_NAME } try { const data = readTomlFile<Record<string, unknown>>(this.getMcpConfigPath()) if (data) { const plugins = data.plugins - const entry = plugins && typeof plugins === 'object' && !Array.isArray(plugins) - ? (plugins as Record<string, unknown>)[PLUGIN_KEY] - : undefined - if (entry && typeof entry === 'object') { - status.installed = true - status.enabled = (entry as { enabled?: unknown }).enabled === true + if (plugins && typeof plugins === 'object' && !Array.isArray(plugins)) { + const table = plugins as Record<string, unknown> + const matchedKeys = Object.keys(table).filter(isNsolidPluginId) + if (matchedKeys.length > 0) { + status.installed = true + status.installedIds = matchedKeys + const enabledKey = matchedKeys.find((key) => { + const entry = table[key] + return entry && typeof entry === 'object' && + (entry as { enabled?: unknown }).enabled === true + }) + status.enabled = enabledKey !== undefined + status.label = enabledKey ?? matchedKeys[0] + } } } } catch { diff --git a/packages/core/src/harnesses/harness-adapter.ts b/packages/core/src/harnesses/harness-adapter.ts index 35f8875..34e2c38 100644 --- a/packages/core/src/harnesses/harness-adapter.ts +++ b/packages/core/src/harnesses/harness-adapter.ts @@ -20,6 +20,14 @@ export interface NativePluginStatus { enabled?: boolean /** Human label like `nsolid-plugin@nodesource` shown on the Plugin line. */ label?: string + /** + * The concrete plugin identifiers the harness recorded for the nsolid plugin, + * e.g. `["nsolid-plugin@nodesource"]` for Claude/Codex (a `<name>@<marketplace>` + * id that varies by install source) or `["nsolid-plugin"]` for Antigravity. + * Uninstall targets these exact ids; doctor's display uses `label`. Empty when + * not installed or when an adapter could not resolve concrete ids. + */ + installedIds?: string[] } export interface HarnessAdapter { diff --git a/packages/core/src/harnesses/native-plugin-uninstaller.ts b/packages/core/src/harnesses/native-plugin-uninstaller.ts new file mode 100644 index 0000000..0f2df2d --- /dev/null +++ b/packages/core/src/harnesses/native-plugin-uninstaller.ts @@ -0,0 +1,247 @@ +import { spawn } from 'node:child_process' +import { existsSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { Logger } from '../types.js' +import type { HarnessAdapter, NativePluginStatus } from './harness-adapter.js' +import { resolveHome } from '../utils/path.js' +import { readJsonFile, readTomlFile, writeTomlFileSync } from '../utils/config.js' +import { writeJsonFileSync } from '../utils/fs.js' +import { createConfigBackup } from '../utils/backup.js' +import { PLUGIN_BASE_NAME } from './plugin-name.js' + +/** + * Spawns a harness CLI command, resolving with the exit code. Rejects on failure + * to spawn (e.g. binary not installed) or on timeout (the `timeout` option sends + * SIGTERM, after which `close` fires with `code === null` and a `signal`). + * Never rejects on a non-zero exit, so callers can decide whether to fall back. + */ +export function runHarnessCli (cmd: string, args: string[]): Promise<number> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'ignore', timeout: 15000 }) + child.on('error', reject) + child.on('close', (code, signal) => { + // A non-null signal (e.g. SIGTERM from timeout) means the process did not + // exit cleanly — treat it as a failure so the config-file fallback runs. + if (signal !== null && signal !== undefined) { + reject(new Error(`"${cmd}" terminated by ${signal}`)) + return + } + resolve(code ?? 0) + }) + }) +} + +interface RemovalResult { + removed: boolean + warnings: string[] +} + +/** Injectable runner so tests can force the fallback path without spawning. */ +type CliRunner = (cmd: string, args: string[]) => Promise<number> + +/** + * Remove the nsolid native plugin for a harness. Strategy: prefer the harness's + * own CLI (correct bookkeeping for caches/indexes), then fall back to directly + * editing the harness's config files when the CLI is absent or didn't clear it. + * Returns whether the plugin is gone afterwards and any non-fatal warnings. + */ +export async function removeNativePlugin ( + harness: string, + adapter: HarnessAdapter, + options?: { logger?: Logger; runCli?: CliRunner } +): Promise<RemovalResult> { + const logger = options?.logger + const runCli = options?.runCli ?? runHarnessCli + const warnings: string[] = [] + if (!adapter.detectNativePlugin) { + return { removed: true, warnings } + } + + const detected = adapter.detectNativePlugin() + if (!detected.installed) { + return { removed: true, warnings } + } + + const ids = concreteIds(detected) + logger?.info('uninstall.nativePlugin.start', { harness, ids }) + + // 1. Delegate to the harness CLI when available. + const delegated = await delegateToHarnessCli(harness, ids, runCli, logger).catch(() => false) + + // 2. If the CLI didn't fully clear the install (absent, failed, or exited 0 + // but left stale state such as a manifest entry), hand-edit the config + // files directly to reconcile. Verification below still has the final say. + if (!delegated || adapter.detectNativePlugin().installed) { + await fallbackEdit(harness, adapter, ids, logger).catch((err) => { + warnings.push(`Could not fully remove native plugin for ${harness}: ${(err as Error).message}`) + }) + } + + // 3. Verify. + const rechecked = adapter.detectNativePlugin() + if (rechecked.installed) { + warnings.push( + `Native plugin still present for ${harness}; remove it manually via the harness CLI (e.g. ${manualHint(harness, ids)}).` + ) + return { removed: false, warnings } + } + + logger?.info('uninstall.nativePlugin.done', { harness }) + return { removed: true, warnings } +} + +function concreteIds (detected: NativePluginStatus): string[] { + if (detected.installedIds && detected.installedIds.length > 0) { + return detected.installedIds + } + // Adapter didn't surface concrete ids — best-effort the base name. + return [detected.label ?? PLUGIN_BASE_NAME] +} + +async function delegateToHarnessCli ( + harness: string, + ids: string[], + runCli: CliRunner, + logger?: Logger +): Promise<boolean> { + // Antigravity keys plugins by base name, not `<name>@<marketplace>`. + const targets = harness === 'antigravity' ? [PLUGIN_BASE_NAME] : ids + let anySucceeded = false + for (const target of targets) { + const [cmd, ...args] = cliCommand(harness, target) + if (!cmd) continue + try { + const code = await runCli(cmd, args) + if (code === 0) { + anySucceeded = true + logger?.info('uninstall.nativePlugin.cli', { harness, cmd }) + } + } catch { + // Binary not installed or spawn failed — fall back below. + return false + } + } + return anySucceeded +} + +function cliCommand (harness: string, id: string): string[] { + switch (harness) { + case 'claude': + return ['claude', 'plugin', 'uninstall', id] + case 'codex': + return ['codex', 'plugin', 'uninstall', id] + case 'antigravity': + return ['agy', 'plugin', 'uninstall', PLUGIN_BASE_NAME] + default: + return [] + } +} + +function manualHint (harness: string, ids: string[]): string { + const id = ids[0] ?? PLUGIN_BASE_NAME + switch (harness) { + case 'claude': + return `claude plugin uninstall ${id}` + case 'codex': + return `codex plugin uninstall ${id}` + case 'antigravity': + return `agy plugin uninstall ${PLUGIN_BASE_NAME}` + default: + return '' + } +} + +/** + * Direct config-file edits as a fallback when the harness CLI is unavailable. + * Each path backs up the file before mutating and preserves unrelated entries. + */ +async function fallbackEdit (harness: string, adapter: HarnessAdapter, ids: string[], logger?: Logger): Promise<void> { + switch (harness) { + case 'claude': + await editClaude(adapter, ids, logger) + break + case 'codex': + await editCodex(adapter, ids, logger) + break + case 'antigravity': + await editAntigravity(logger) + break + } +} + +async function editClaude (adapter: HarnessAdapter, ids: string[], logger?: Logger): Promise<void> { + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + if (existsSync(installedPath)) { + createConfigBackup('claude', installedPath, { reason: 'uninstall-native-plugin' }) + const data = readJsonFile<{ version?: number; plugins?: Record<string, unknown> }>(installedPath) + if (data?.plugins) { + let changed = false + for (const id of ids) { + if (id in data.plugins) { delete data.plugins[id]; changed = true } + } + if (changed) writeJsonFileSync(installedPath, data) + } + } + + // Clear the enable map entries in ~/.claude.json. + const claudeJsonPath = adapter.getMcpConfigPath() + if (claudeJsonPath && existsSync(claudeJsonPath)) { + createConfigBackup('claude', claudeJsonPath, { reason: 'uninstall-native-plugin' }) + const data = readJsonFile<Record<string, unknown>>(claudeJsonPath) + if (data?.enabledPlugins && typeof data.enabledPlugins === 'object') { + const map = data.enabledPlugins as Record<string, unknown> + let changed = false + for (const id of ids) { + if (id in map) { delete map[id]; changed = true } + } + if (changed) writeJsonFileSync(claudeJsonPath, data) + } + } + + // Best-effort cache cleanup. The cache may be keyed by marketplace name. + const cacheBase = resolveHome('~/.claude/plugins/cache') + if (existsSync(cacheBase)) { + for (const id of ids) { + const marketplace = id.includes('@') ? id.split('@')[1] : undefined + if (marketplace) { + const dir = path.join(cacheBase, marketplace, PLUGIN_BASE_NAME) + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }) + } + } + } + logger?.info('uninstall.nativePlugin.fallback', { harness: 'claude', ids }) +} + +async function editCodex (adapter: HarnessAdapter, ids: string[], logger?: Logger): Promise<void> { + const configPath = adapter.getMcpConfigPath() + if (!configPath || !existsSync(configPath)) return + createConfigBackup('codex', configPath, { reason: 'uninstall-native-plugin' }) + const data = readTomlFile<Record<string, unknown>>(configPath) + if (!data?.plugins || typeof data.plugins !== 'object') return + const plugins = data.plugins as Record<string, unknown> + let changed = false + for (const id of ids) { + if (id in plugins) { delete plugins[id]; changed = true } + } + if (changed) writeTomlFileSync(configPath, data) + logger?.info('uninstall.nativePlugin.fallback', { harness: 'codex', ids }) +} + +async function editAntigravity (logger?: Logger): Promise<void> { + const pluginDir = resolveHome('~/.gemini/config/plugins/nsolid-plugin') + if (existsSync(pluginDir)) { + rmSync(pluginDir, { recursive: true, force: true }) + } + + const manifestPath = resolveHome('~/.gemini/config/import_manifest.json') + if (existsSync(manifestPath)) { + createConfigBackup('antigravity', manifestPath, { reason: 'uninstall-native-plugin' }) + const data = readJsonFile<{ imports?: Array<{ name?: string }> }>(manifestPath) + if (data?.imports) { + const before = data.imports.length + data.imports = data.imports.filter((entry) => entry?.name !== PLUGIN_BASE_NAME) + if (data.imports.length !== before) writeJsonFileSync(manifestPath, data) + } + } + logger?.info('uninstall.nativePlugin.fallback', { harness: 'antigravity' }) +} diff --git a/packages/core/src/harnesses/plugin-name.ts b/packages/core/src/harnesses/plugin-name.ts new file mode 100644 index 0000000..325660f --- /dev/null +++ b/packages/core/src/harnesses/plugin-name.ts @@ -0,0 +1,17 @@ +/** + * The stable base name of the nsolid plugin as it appears in its own manifest + * (`plugin.json` `name` field). Harnesses key installs by a `<name>@<marketplace>` + * id whose suffix varies by install source (e.g. `nsolid-plugin@nodesource` from + * our marketplace, or `nsolid-plugin@claude-plugins-official` if accepted into + * Anthropic's community marketplace). The base name is what stays constant, so + * detection and uninstall match on it rather than a hardcoded full id. + */ +export const PLUGIN_BASE_NAME = 'nsolid-plugin' + +/** + * True when `id` is the nsolid plugin under any marketplace: it equals the base + * name exactly, or is qualified as `<base>@<marketplace>`. + */ +export function isNsolidPluginId (id: string): boolean { + return id === PLUGIN_BASE_NAME || id.startsWith(`${PLUGIN_BASE_NAME}@`) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 69a100d..5959dd0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,7 @@ import { } from './mcp/index.js' import { getAdapter } from './harnesses/index.js' import type { HarnessAdapter } from './harnesses/index.js' +import { removeNativePlugin } from './harnesses/native-plugin-uninstaller.js' import { findPiPluginSkillRoots } from './harnesses/pi-plugin-detector.js' import { readJsonFile } from './utils/config.js' import { getSkillsDir, getAuthFilePath } from './utils/path.js' @@ -453,6 +454,19 @@ export async function uninstall ( } } + // Remove the harness's native plugin (e.g. `claude plugin install`, + // `agy plugin install`) when present. This is distinct from the CLI-tracked + // skills/MCP above: native installs are owned by the harness CLI and would + // otherwise survive `uninstall`. Best-effort and non-fatal. + if (adapter.detectNativePlugin) { + try { + const nativeResult = await removeNativePlugin(harness, adapter, { logger }) + errors.push(...nativeResult.warnings) + } catch (err) { + errors.push(`Native plugin removal failed: ${(err as Error).message}`) + } + } + // After all per-harness removal, see whether ANY install remains across any harness. // removeTrackedSkills/removeTrackedMcps already unlink the tracking file when it empties, // so a null read == "nothing NodeSource-installed is left anywhere". @@ -479,6 +493,17 @@ export async function uninstall ( const warnings = await bestEffortCleanup(harness, adapter, options, logger) errors.push(...warnings) + // Even without a tracking file, a native plugin may still be staged by the + // harness CLI; remove it best-effort so `uninstall` is consistent. + if (adapter.detectNativePlugin) { + try { + const nativeResult = await removeNativePlugin(harness, adapter, { logger }) + errors.push(...nativeResult.warnings) + } catch (err) { + errors.push(`Native plugin removal failed: ${(err as Error).message}`) + } + } + // Best-effort cleanup intentionally never purges credentials: without a // tracking file we cannot reliably tell whether another harness is still // installed. Users can run `nsolid-plugin logout` to remove credentials. diff --git a/packages/core/src/mcp/mcp-config-writer.ts b/packages/core/src/mcp/mcp-config-writer.ts index f3fbfa3..8bdc2f9 100644 --- a/packages/core/src/mcp/mcp-config-writer.ts +++ b/packages/core/src/mcp/mcp-config-writer.ts @@ -32,7 +32,7 @@ function getMcpConfigInfo (harness: HarnessType): ConfigInfo | null { case 'opencode': return { configPath: resolveHome('~/.config/opencode/opencode.jsonc'), format: 'jsonc', jsonMcpKey: 'mcp' } case 'antigravity': - return { configPath: resolveHome('~/.gemini/antigravity-cli/mcp_config.json'), format: 'json' } + return { configPath: resolveHome('~/.gemini/config/mcp_config.json'), format: 'json' } case 'pi': return { configPath: resolveHome('~/.pi/agent/mcp.json'), format: 'json' } } diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index eeb4484..899382d 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -880,7 +880,7 @@ describe('doctor()', () => { const { doctor } = await import('../../src/index.js') const bundle = createBundle() const bundlePath = writeBundle(bundle) - mkdirSync(join(tmpDir, '.gemini', 'antigravity-cli', 'plugins', 'nsolid-plugin'), { recursive: true }) + mkdirSync(join(tmpDir, '.gemini', 'config', 'plugins', 'nsolid-plugin'), { recursive: true }) const report = await doctor('antigravity', bundlePath) diff --git a/packages/core/test/integration/multi-harness.test.ts b/packages/core/test/integration/multi-harness.test.ts index 4b129d6..bce6f2b 100644 --- a/packages/core/test/integration/multi-harness.test.ts +++ b/packages/core/test/integration/multi-harness.test.ts @@ -10,7 +10,7 @@ const MATRIX = [ { harness: 'claude', configRel: '.claude.json', urlKey: 'url' }, { harness: 'codex', configRel: '.codex/config.toml', urlKey: 'url' }, { harness: 'opencode', configRel: '.config/opencode/opencode.jsonc', urlKey: 'url' }, - { harness: 'antigravity', configRel: '.gemini/antigravity-cli/mcp_config.json', urlKey: 'serverUrl' }, + { harness: 'antigravity', configRel: '.gemini/config/mcp_config.json', urlKey: 'serverUrl' }, { harness: 'pi', configRel: '.pi/agent/mcp.json', urlKey: 'url' }, ] as const diff --git a/packages/core/test/unit/harnesses/antigravity-adapter.test.ts b/packages/core/test/unit/harnesses/antigravity-adapter.test.ts index 01cf39a..b6b7c93 100644 --- a/packages/core/test/unit/harnesses/antigravity-adapter.test.ts +++ b/packages/core/test/unit/harnesses/antigravity-adapter.test.ts @@ -31,21 +31,29 @@ describe('AntigravityAdapter', () => { } }) - it('returns correct MCP config path', async () => { + it('returns correct MCP config path under ~/.gemini/config', async () => { const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') const adapter = new AntigravityAdapter() const configPath = adapter.getMcpConfigPath() - assert.ok(configPath.endsWith(['.gemini', 'antigravity-cli', 'mcp_config.json'].join(sep))) + assert.ok(configPath.endsWith(['.gemini', 'config', 'mcp_config.json'].join(sep))) assert.ok(configPath.startsWith(tmpDir)) }) - it('returns correct skills path', async () => { + it('returns correct skills path under ~/.gemini/config/skills', async () => { const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') const adapter = new AntigravityAdapter() const skillsPath = adapter.getSkillsPath() - assert.ok(skillsPath.includes(['.gemini', 'antigravity-cli', 'skills'].join(sep)), `unexpected skills path: ${skillsPath}`) + assert.ok(skillsPath.includes(['.gemini', 'config', 'skills'].join(sep)), `unexpected skills path: ${skillsPath}`) + }) + + it('returns plugins path under ~/.gemini/config/plugins', async () => { + const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') + const adapter = new AntigravityAdapter() + + const pluginsPath = adapter.getPluginsPath() + assert.ok(pluginsPath.endsWith(['.gemini', 'config', 'plugins'].join(sep)), `unexpected plugins path: ${pluginsPath}`) }) it('supports MCP', async () => { @@ -67,7 +75,7 @@ describe('AntigravityAdapter', () => { const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') const { resolveHome } = await import('../../../src/utils/path.js') - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') mkdirSync(dirname(configPath), { recursive: true }) writeFileSync(configPath, JSON.stringify({ mcpServers: { @@ -96,7 +104,7 @@ describe('AntigravityAdapter', () => { }, }) - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') assert.ok(existsSync(configPath)) const content = JSON.parse(readFileSync(configPath, 'utf-8')) @@ -137,4 +145,63 @@ describe('AntigravityAdapter', () => { assert.strictEqual(read.mcpServers['ns-monitor'].url, 'https://monitor.mcp.saas.nodesource.io/mcp') assert.strictEqual(read.mcpServers['ns-monitor'].headers.Authorization, 'Bearer xyz') }) + + describe('detectNativePlugin', () => { + it('detects plugin from the staged plugins dir under ~/.gemini/config', async () => { + const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const pluginDir = resolveHome('~/.gemini/config/plugins/nsolid-plugin') + mkdirSync(pluginDir, { recursive: true }) + + const adapter = new AntigravityAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.strictEqual(status.enabled, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin']) + }) + + it('detects plugin from import_manifest.json when the dir is gone', async () => { + const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const manifestPath = resolveHome('~/.gemini/config/import_manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ + imports: [{ name: 'nsolid-plugin', source: 'antigravity', importedAt: '2026-06-30T20:34:22Z' }], + }, null, 2)) + + const adapter = new AntigravityAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin']) + }) + + it('reports not installed when neither dir nor manifest entry exist', async () => { + const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') + const adapter = new AntigravityAdapter() + + const status = adapter.detectNativePlugin() + assert.strictEqual(status.installed, false) + assert.strictEqual(status.installedIds, undefined) + }) + + it('ignores unrelated manifest imports', async () => { + const { AntigravityAdapter } = await import('../../../src/harnesses/antigravity-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const manifestPath = resolveHome('~/.gemini/config/import_manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ + imports: [{ name: 'some-other-plugin', source: 'antigravity' }], + }, null, 2)) + + const adapter = new AntigravityAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, false) + }) + }) }) diff --git a/packages/core/test/unit/harnesses/claude-adapter.test.ts b/packages/core/test/unit/harnesses/claude-adapter.test.ts index c41e6f4..083a61d 100644 --- a/packages/core/test/unit/harnesses/claude-adapter.test.ts +++ b/packages/core/test/unit/harnesses/claude-adapter.test.ts @@ -110,4 +110,132 @@ describe('ClaudeAdapter', () => { assert.strictEqual(adapter.name, 'claude') }) + + describe('detectNativePlugin', () => { + it('detects plugin from v2 map schema {version, plugins:{<id>:[...]}}', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { + 'nsolid-plugin@nodesource': [ + { scope: 'user', version: '1.0.0', installedAt: '2026-06-30T20:31:51.571Z' }, + ], + }, + }, null, 2)) + + const adapter = new ClaudeAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin@nodesource']) + assert.strictEqual(status.label, 'nsolid-plugin@nodesource') + }) + + it('matches a community-marketplace id like nsolid-plugin@claude-plugins-official', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { + 'nsolid-plugin@claude-plugins-official': [ + { scope: 'user', version: '1.0.0' }, + ], + }, + }, null, 2)) + + const adapter = new ClaudeAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin@claude-plugins-official']) + }) + + it('still supports the legacy array plugins schema', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + plugins: [{ id: 'nsolid-plugin@nodesource' }, { id: 'other-plugin@somewhere' }], + }, null, 2)) + + const adapter = new ClaudeAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin@nodesource']) + }) + + it('reports not installed when absent', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const adapter = new ClaudeAdapter() + + const status = adapter.detectNativePlugin() + assert.strictEqual(status.installed, false) + }) + + it('reads enabled=true from ~/.claude.json enabledPlugins', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { 'nsolid-plugin@nodesource': [{ scope: 'user' }] }, + }, null, 2)) + + const claudeJsonPath = resolveHome('~/.claude.json') + writeFileSync(claudeJsonPath, JSON.stringify({ + enabledPlugins: { 'nsolid-plugin@nodesource': true, 'other@x': false }, + }, null, 2)) + + const adapter = new ClaudeAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.strictEqual(status.enabled, true) + }) + + it('respects enabledPlugins=false as disabled', async () => { + const { ClaudeAdapter } = await import('../../../src/harnesses/claude-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { 'nsolid-plugin@nodesource': [{ scope: 'user' }] }, + }, null, 2)) + + const claudeJsonPath = resolveHome('~/.claude.json') + writeFileSync(claudeJsonPath, JSON.stringify({ + enabledPlugins: { 'nsolid-plugin@nodesource': false }, + }, null, 2)) + + const adapter = new ClaudeAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.strictEqual(status.enabled, false) + }) + }) }) diff --git a/packages/core/test/unit/harnesses/codex-adapter.test.ts b/packages/core/test/unit/harnesses/codex-adapter.test.ts index ebd5f96..c226f24 100644 --- a/packages/core/test/unit/harnesses/codex-adapter.test.ts +++ b/packages/core/test/unit/harnesses/codex-adapter.test.ts @@ -107,4 +107,59 @@ describe('CodexAdapter', () => { assert.strictEqual(adapter.name, 'codex') }) + + describe('detectNativePlugin', () => { + it('detects plugin by base name under any marketplace suffix', async () => { + const { CodexAdapter } = await import('../../../src/harnesses/codex-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + const { stringify: stringifyToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, stringifyToml({ + plugins: { + 'nsolid-plugin@claude-plugins-official': { enabled: true }, + 'unrelated@somewhere': { enabled: true }, + }, + } as Record<string, unknown>)) + + const adapter = new CodexAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin@claude-plugins-official']) + assert.strictEqual(status.enabled, true) + assert.strictEqual(status.label, 'nsolid-plugin@claude-plugins-official') + }) + + it('detects the @nodesource marketplace id', async () => { + const { CodexAdapter } = await import('../../../src/harnesses/codex-adapter.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { mkdirSync } = await import('node:fs') + const { dirname } = await import('node:path') + const { stringify: stringifyToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, stringifyToml({ + plugins: { 'nsolid-plugin@nodesource': { enabled: true } }, + } as Record<string, unknown>)) + + const adapter = new CodexAdapter() + const status = adapter.detectNativePlugin() + + assert.strictEqual(status.installed, true) + assert.deepStrictEqual(status.installedIds, ['nsolid-plugin@nodesource']) + }) + + it('reports not installed when absent', async () => { + const { CodexAdapter } = await import('../../../src/harnesses/codex-adapter.js') + const adapter = new CodexAdapter() + + const status = adapter.detectNativePlugin() + assert.strictEqual(status.installed, false) + }) + }) }) diff --git a/packages/core/test/unit/harnesses/harness-adapter.test.ts b/packages/core/test/unit/harnesses/harness-adapter.test.ts index 86bf975..72fc8d9 100644 --- a/packages/core/test/unit/harnesses/harness-adapter.test.ts +++ b/packages/core/test/unit/harnesses/harness-adapter.test.ts @@ -59,6 +59,6 @@ describe('getAdapter', () => { assert.ok(pi.getMcpConfigPath()?.endsWith(['.pi', 'agent', 'mcp.json'].join(path.sep))) const antigravity = getAdapter('antigravity') - assert.ok(antigravity.getMcpConfigPath()?.endsWith(['.gemini', 'antigravity-cli', 'mcp_config.json'].join(path.sep))) + assert.ok(antigravity.getMcpConfigPath()?.endsWith(['.gemini', 'config', 'mcp_config.json'].join(path.sep))) }) }) diff --git a/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts b/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts new file mode 100644 index 0000000..7dff286 --- /dev/null +++ b/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts @@ -0,0 +1,210 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' + +describe('removeNativePlugin', () => { + let tmpDir: string + let originalHome: string | undefined + let originalUserProfile: string | undefined + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'nsolid-test-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = tmpDir + process.env.USERPROFILE = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + if (originalHome !== undefined) { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (originalUserProfile !== undefined) { + process.env.USERPROFILE = originalUserProfile + } else { + delete process.env.USERPROFILE + } + }) + + // A runner that always behaves as if the harness binary is absent, forcing + // the fallback config-edit path to run. + const binaryMissing = async (): Promise<number> => { + throw new Error('ENOENT') + } + + it('claude fallback: removes the id from v2 installed_plugins.json and enabledPlugins', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { + 'nsolid-plugin@nodesource': [{ scope: 'user' }], + 'other-plugin@x': [{ scope: 'user' }], + }, + }, null, 2)) + + const claudeJsonPath = resolveHome('~/.claude.json') + writeFileSync(claudeJsonPath, JSON.stringify({ + enabledPlugins: { 'nsolid-plugin@nodesource': true, 'other-plugin@x': true }, + }, null, 2)) + + const adapter = getAdapter('claude') + const result = await removeNativePlugin('claude', adapter, { runCli: binaryMissing }) + + assert.strictEqual(result.removed, true) + assert.deepStrictEqual(result.warnings, []) + + const remaining = JSON.parse(readFileSync(installedPath, 'utf-8')) + assert.ok(!('nsolid-plugin@nodesource' in remaining.plugins)) + assert.ok('other-plugin@x' in remaining.plugins) + + const claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) + assert.ok(!('nsolid-plugin@nodesource' in claudeJson.enabledPlugins)) + assert.ok('other-plugin@x' in claudeJson.enabledPlugins) + }) + + it('claude fallback: matches a community-marketplace id', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const installedPath = resolveHome('~/.claude/plugins/installed_plugins.json') + mkdirSync(dirname(installedPath), { recursive: true }) + writeFileSync(installedPath, JSON.stringify({ + version: 2, + plugins: { 'nsolid-plugin@claude-plugins-official': [{ scope: 'user' }] }, + }, null, 2)) + + const adapter = getAdapter('claude') + const result = await removeNativePlugin('claude', adapter, { runCli: binaryMissing }) + + assert.strictEqual(result.removed, true) + const remaining = JSON.parse(readFileSync(installedPath, 'utf-8')) + assert.ok(!('nsolid-plugin@claude-plugins-official' in remaining.plugins)) + }) + + it('antigravity fallback: removes the staged dir and the manifest import', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const pluginDir = resolveHome('~/.gemini/config/plugins/nsolid-plugin') + mkdirSync(pluginDir, { recursive: true }) + writeFileSync(join(pluginDir, 'plugin.json'), '{"name":"nsolid-plugin"}') + + const manifestPath = resolveHome('~/.gemini/config/import_manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ + imports: [ + { name: 'nsolid-plugin', source: 'antigravity' }, + { name: 'unrelated-plugin', source: 'antigravity' }, + ], + }, null, 2)) + + const adapter = getAdapter('antigravity') + const result = await removeNativePlugin('antigravity', adapter, { runCli: binaryMissing }) + + assert.strictEqual(result.removed, true) + assert.strictEqual(existsSync(pluginDir), false) + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + assert.ok(!manifest.imports.some((i: { name: string }) => i.name === 'nsolid-plugin')) + assert.ok(manifest.imports.some((i: { name: string }) => i.name === 'unrelated-plugin')) + }) + + it('codex fallback: removes the plugin table from config.toml', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { stringify: stringifyToml, parse: parseToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, stringifyToml({ + plugins: { + 'nsolid-plugin@nodesource': { enabled: true }, + 'other@x': { enabled: true }, + }, + } as Record<string, unknown>)) + + const adapter = getAdapter('codex') + const result = await removeNativePlugin('codex', adapter, { runCli: binaryMissing }) + + assert.strictEqual(result.removed, true) + const data = parseToml(readFileSync(configPath, 'utf-8')) as { plugins: Record<string, unknown> } + assert.ok(!('nsolid-plugin@nodesource' in data.plugins)) + assert.ok('other@x' in data.plugins) + }) + + it('is a no-op when the plugin is not installed', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + + const adapter = getAdapter('claude') + const result = await removeNativePlugin('claude', adapter, { runCli: binaryMissing }) + assert.strictEqual(result.removed, true) + assert.deepStrictEqual(result.warnings, []) + }) + + it('reports removed when the harness CLI succeeds', async () => { + // Simulate the CLI removing the plugin by deleting the manifest entry + // ourselves, then returning exit 0. + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const pluginDir = resolveHome('~/.gemini/config/plugins/nsolid-plugin') + mkdirSync(pluginDir, { recursive: true }) + + const adapter = getAdapter('antigravity') + const runCli = async (): Promise<number> => { + rmSync(pluginDir, { recursive: true, force: true }) + return 0 + } + const result = await removeNativePlugin('antigravity', adapter, { runCli }) + + assert.strictEqual(result.removed, true) + assert.strictEqual(existsSync(pluginDir), false) + }) + + it('reconciles when the agy CLI exits 0 but leaves the manifest entry and dir', async () => { + // agy exits 0 (so delegated=true) but leaves the staged dir and the manifest + // import behind. The fallback cleanup must run to finish the job rather than + // emit a spurious "remove it manually" warning. + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + + const pluginDir = resolveHome('~/.gemini/config/plugins/nsolid-plugin') + mkdirSync(pluginDir, { recursive: true }) + const manifestPath = resolveHome('~/.gemini/config/import_manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ + imports: [ + { name: 'nsolid-plugin', source: 'antigravity' }, + { name: 'other-plugin', source: 'antigravity' }, + ], + }, null, 2)) + + const adapter = getAdapter('antigravity') + // CLI "succeeds" but does nothing — leaves state intact. + const runCli = async (): Promise<number> => 0 + const result = await removeNativePlugin('antigravity', adapter, { runCli }) + + assert.strictEqual(result.removed, true) + assert.deepStrictEqual(result.warnings, []) + assert.strictEqual(existsSync(pluginDir), false) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + assert.ok(!manifest.imports.some((i: { name: string }) => i.name === 'nsolid-plugin')) + assert.ok(manifest.imports.some((i: { name: string }) => i.name === 'other-plugin')) + }) +}) diff --git a/packages/core/test/unit/mcp/mcp-config-writer.test.ts b/packages/core/test/unit/mcp/mcp-config-writer.test.ts index 2e61af8..44b076e 100644 --- a/packages/core/test/unit/mcp/mcp-config-writer.test.ts +++ b/packages/core/test/unit/mcp/mcp-config-writer.test.ts @@ -487,7 +487,7 @@ describe('writeAdapterMcpConfig', () => { const { mkdirSync } = await import('node:fs') const { dirname } = await import('node:path') - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') mkdirSync(dirname(configPath), { recursive: true }) writeFileSync(configPath, '') @@ -508,7 +508,7 @@ describe('writeAdapterMcpConfig', () => { const { mkdirSync } = await import('node:fs') const { dirname } = await import('node:path') - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') mkdirSync(dirname(configPath), { recursive: true }) writeFileSync(configPath, JSON.stringify({ mcpServers: { 'old-server': { url: 'http://old:8080', headers: {} } }, @@ -533,7 +533,7 @@ describe('writeAdapterMcpConfig', () => { const { mkdirSync } = await import('node:fs') const { dirname } = await import('node:path') - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') mkdirSync(dirname(configPath), { recursive: true }) await writeMcpConfig('antigravity', [{ @@ -604,7 +604,7 @@ describe('removeMcpConfig', () => { const result = await removeMcpConfig('antigravity', ['ns-benchmark']) assert.strictEqual(result, undefined) - assert.strictEqual(existsSync(resolveHome('~/.gemini/antigravity-cli/mcp_config.json')), false) + assert.strictEqual(existsSync(resolveHome('~/.gemini/config/mcp_config.json')), false) }) it('antigravity round-trip: removing one server keeps survivors as serverUrl', async () => { @@ -617,7 +617,7 @@ describe('removeMcpConfig', () => { ]) await removeMcpConfig('antigravity', ['ns-benchmark']) - const content = JSON.parse(readFileSync(resolveHome('~/.gemini/antigravity-cli/mcp_config.json'), 'utf-8')) + const content = JSON.parse(readFileSync(resolveHome('~/.gemini/config/mcp_config.json'), 'utf-8')) assert.ok(!('ns-benchmark' in content.mcpServers)) assert.ok('ns-solid' in content.mcpServers) // Survivor must be written back in Antigravity's serverUrl schema, not url. @@ -682,7 +682,7 @@ describe('removeMcpConfig', () => { await removeMcpConfig('antigravity', ['ns-benchmark']) - const configPath = resolveHome('~/.gemini/antigravity-cli/mcp_config.json') + const configPath = resolveHome('~/.gemini/config/mcp_config.json') assert.strictEqual(existsSync(configPath), false) }) }) diff --git a/scripts/test-marketplace-install.js b/scripts/test-marketplace-install.js index 949e1a6..1cfeaae 100644 --- a/scripts/test-marketplace-install.js +++ b/scripts/test-marketplace-install.js @@ -18,7 +18,7 @@ const HARNESS_LIST = [ { name: 'claude', configRel: '.claude.json', urlKey: 'url' }, { name: 'codex', configRel: '.codex/config.toml', urlKey: 'url' }, { name: 'opencode', configRel: '.config/opencode/opencode.jsonc', urlKey: 'url' }, - { name: 'antigravity', configRel: '.gemini/antigravity-cli/mcp_config.json', urlKey: 'serverUrl' }, + { name: 'antigravity', configRel: '.gemini/config/mcp_config.json', urlKey: 'serverUrl' }, { name: 'pi', configRel: '.pi/agent/mcp.json', urlKey: 'url' }, ] const PLUGIN_OWNED_HARNESSES = new Set(['claude', 'codex', 'antigravity']) From 4e02acbb28cb738299b766b80f11598e5bcb47ba Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Thu, 2 Jul 2026 17:27:31 +0200 Subject: [PATCH 09/11] fix: false positive error message when doing setup with already existing credentials --- packages/core/src/auth/auth-manager.ts | 37 +--- packages/core/src/index.ts | 12 +- .../integration/auth/auth-manager.test.ts | 201 ++++++++++-------- .../core/test/integration/installer.test.ts | 28 +++ .../core/test/unit/skills/fetch-asset.test.ts | 21 +- 5 files changed, 175 insertions(+), 124 deletions(-) diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index b5fb004..e20886b 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -58,34 +58,15 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg if (existing.accountsUrl && existing.accountsUrl !== authConfig.accountsUrl) { logger?.info('auth.credentials.originMismatch', { stored: existing.accountsUrl, current: authConfig.accountsUrl }) } else { - try { - const validationAccountsUrl = existing.accountsUrl ?? authConfig.accountsUrl - const result = await validateToken(existing.serviceToken, existing.organizationId, validationAccountsUrl, logger) - if (result.valid) { - if (required.length > 0) { - checkRequiredPermissions(required, result.permissions) - } - logger?.debug('auth.credentials.valid', { orgId: existing.organizationId }) - return { ...existing, permissions: result.permissions } - } - } catch (err) { - // Re-throw permission errors (not API unavailability) - if (err instanceof PermissionError) { - throw err - } - // API unavailable (unreachable, non-JSON response, timeout): trust the - // unexpired, origin-matching stored credentials rather than forcing a - // re-login on every setup, but only if cached permissions satisfy - // required permissions. Mirrors the optimistic-storage path after - // OAuth. A 401/403 is NOT thrown — validateToken returns { valid: false } - // for that — so revoked tokens still trigger re-authentication below. - logger?.warn('auth.credentials.validationUnavailable', { error: (err as Error).message }) - if (required.length > 0) { - const cachedPerms = existing.permissions ?? [] - checkRequiredPermissions(required, cachedPerms) - } - return existing - } + // Repeated setup trusts stored credentials: no re-validation against the + // accounts API and no requiredPermissions check. Those run only on a + // fresh login (logout, credential deletion, or expiry). This keeps + // `setup` idempotent and avoids spurious failures when the validation + // API is intermittently unavailable or the account lacks optional + // benchmark access. A server-side revocation surfaces at MCP runtime + // instead; the user can `logout` to force a fresh login. + logger?.debug('auth.credentials.reused', { orgId: existing.organizationId }) + return existing } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5959dd0..21af4f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -556,9 +556,15 @@ async function bestEffortCleanup ( } } if (!usedBundle) { - warnings.push( - 'No tracking file and no bundle provided — using hardcoded MCP server list; user-added MCP servers may be left in the config' - ) + // For plugin-owned harnesses (claude/codex/antigravity) the MCP servers + // are owned by the native plugin, not the harness-level MCP config. + // removeNativePlugin() handles the real cleanup, so the hardcoded + // fallback here is redundant and the warning would only be noise. + if (!PLUGIN_OWNED_HARNESSES.has(harness)) { + warnings.push( + 'No tracking file and no bundle provided — using hardcoded MCP server list; user-added MCP servers may be left in the config' + ) + } } try { const mcpConfigPath = adapter.getMcpConfigPath() diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 560acb6..ec0f16a 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -100,7 +100,9 @@ afterEach(() => { }) describe('ensureAuthenticated', () => { - it('returns existing valid credentials (fast path)', async () => { + it('returns existing unexpired credentials without re-validating (fast path)', async () => { + // setup is auth-only and must be idempotent: repeated runs trust stored + // credentials and never call the validation API or re-check permissions. const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { @@ -114,17 +116,57 @@ describe('ensureAuthenticated', () => { } saveCredentials(creds) - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: ['nsolid:benchmark:run'] }), - })) as unknown as typeof fetch + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: ['nsolid:benchmark:run'] }), + } + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const result = await ensureAuthenticated(authConfig) + + assert.deepStrictEqual(result, creds) + assert.strictEqual(fetchCalls, 0, 'fast path must not call the validation API') + assert.strictEqual(execFileCalls.length, 0) + }) + + it('returns stored credentials unchanged on the fast path (no permissions mutation)', async () => { + // Even when the stored permissions array differs from any API response, + // repeated setup returns the credentials verbatim — no validation, no merge. + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + + const creds: Credentials = { + serviceToken: 'existing-token', + organizationId: 'org-123', + saasToken: 'test-saas-token', + consoleUrl: 'https://test.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + permissions: ['nsolid:benchmark:run'], + } + saveCredentials(creds) + + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: ['completely-different:perm'] }), + } + }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') const result = await ensureAuthenticated(authConfig) assert.deepStrictEqual(result, creds) + assert.strictEqual(fetchCalls, 0, 'stored credentials must be trusted verbatim') assert.strictEqual(execFileCalls.length, 0) }) @@ -222,36 +264,29 @@ describe('ensureAuthenticated', () => { } saveCredentials(creds) - // The validation API is unreachable (ECONNREFUSED) or returns a non-JSON - // payload (e.g. an SPA shell). In both cases validateToken THROWS, and the - // fast path must trust the unexpired, origin-matching stored credentials - // instead of forcing a re-login on every setup. - globalThis.fetch = mock.fn(async () => { + // The fast path no longer calls the validation API at all, so an + // unreachable API cannot interfere with repeated setup. Credentials are + // trusted verbatim. + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ throw new Error('ECONNREFUSED') }) as unknown as typeof fetch - const warnings: string[] = [] - const logger = { - debug: () => {}, - info: () => {}, - warn: (msg: string) => { warnings.push(String(msg)) }, - error: () => {}, - } - const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig, logger) + const result = await ensureAuthenticated(authConfig) - // Stored credentials returned verbatim — no OAuth re-authentication. assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') + assert.strictEqual(fetchCalls, 0, 'fast path must not call validation API') assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API is unavailable') - assert.ok( - warnings.some((w) => w.includes('auth.credentials.validationUnavailable')), - 'expected a validationUnavailable warning when validation API is unavailable' - ) }) - it('re-authenticates when validation API returns 401/403 during fast path', async () => { + it('trusts stored credentials on the fast path even when the API would reject them', async () => { + // Repeated setup must be idempotent: it does not re-validate stored + // credentials, so a token the API has since revoked is still trusted until + // the user runs `logout` or the credentials expire. Revocation surfaces at + // MCP runtime instead. const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'revoked-token', @@ -263,11 +298,9 @@ describe('ensureAuthenticated', () => { } saveCredentials(creds) - // A revoked token gets 401/403 from the API — validateToken returns - // { valid: false } (does NOT throw), so the fast path must fall through to - // re-authenticate. This preserves revocation detection. The fresh OAuth - // token from the callback then validates OK. - globalThis.fetch = mock.fn(async (input: RequestInfo | URL) => { + let fetchCalls = 0 + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls++ const target = typeof input === 'string' ? input : input.toString() const revoked = target.includes('tokenId=revoked-token') return revoked @@ -276,21 +309,18 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const promise = ensureAuthenticated(authConfig) - - await new Promise((resolve) => setTimeout(resolve, 50)) - const state = getStateFromExecFileCall() - await sendCallback(8767, state) - const result = await promise + const result = await ensureAuthenticated(authConfig) - assert.strictEqual(result.serviceToken, 'oauth-token') - assert.strictEqual(result.organizationId, 'org-456') + assert.strictEqual(result.serviceToken, 'revoked-token') + assert.strictEqual(result.organizationId, 'org-123') + assert.strictEqual(fetchCalls, 0, 'fast path must not re-validate stored credentials') + assert.strictEqual(execFileCalls.length, 0, 'browser must not open on the fast path') }) it('trusts stored credentials when validation API returns an HTML shell (200 text/html)', async () => { - // Regression: the accounts API serves its SPA index.html (HTTP 200, - // content-type text/html) for server-side callers, which made validateToken - // throw "unexpected content type" on every setup and forced a re-login. + // The accounts API serves its SPA index.html (HTTP 200, content-type + // text/html) for server-side callers. Because the fast path no longer calls + // the API at all, this can no longer force a re-login during repeated setup. const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'valid-token', @@ -303,18 +333,23 @@ describe('ensureAuthenticated', () => { } saveCredentials(creds) - globalThis.fetch = mock.fn(async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'text/html' }), - text: async () => '<!DOCTYPE html><html>...</html>', - })) as unknown as typeof fetch + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/html' }), + text: async () => '<!DOCTYPE html><html>...</html>', + } + }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') const result = await ensureAuthenticated(authConfig) assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') + assert.strictEqual(fetchCalls, 0, 'fast path must not call validation API') assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API returns an HTML shell') }) }) @@ -325,35 +360,10 @@ describe('ensureAuthenticated - requiredPermissions', () => { requiredPermissions: ['nsolid:benchmark:run', 'nsolid:profile:read'], } - it('throws when required permissions are missing (fast path)', { timeout: 10000 }, async () => { - const { saveCredentials } = await import('../../../src/auth/token-storage.js') - - const creds: Credentials = { - serviceToken: 'existing-token', - organizationId: 'org-123', - saasToken: 'test-saas-token', - consoleUrl: 'https://test.saas.nodesource.io', - mcpUrl: 'https://org-123.mcp.saas.nodesource.io', - expiresAt: new Date(Date.now() + 86400000).toISOString(), - permissions: ['nsolid:benchmark:run'], - } - saveCredentials(creds) - - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: ['nsolid:benchmark:run'] }), - })) as unknown as typeof fetch - - const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - await assert.rejects( - ensureAuthenticated(authConfigWithPerms), - /Missing required permissions: nsolid:profile:read/ - ) - }) - - it('throws when validation is unavailable and cached permissions are insufficient', async () => { + it('trusts stored credentials on the fast path regardless of cached permissions', async () => { + // Repeated setup must not re-check requiredPermissions against stored + // credentials. Even when cached perms are a strict subset of required, the + // fast path returns them verbatim without calling the API. const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { @@ -367,15 +377,23 @@ describe('ensureAuthenticated - requiredPermissions', () => { } saveCredentials(creds) + let fetchCalls = 0 globalThis.fetch = (async () => { - throw new Error('ECONNREFUSED') + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: ['nsolid:benchmark:run'] }), + } }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - await assert.rejects( - ensureAuthenticated(authConfigWithPerms), - /Missing required permissions: nsolid:profile:read/ - ) + const result = await ensureAuthenticated(authConfigWithPerms) + + assert.strictEqual(result.serviceToken, 'existing-token') + assert.strictEqual(fetchCalls, 0, 'fast path must not call the API even with requiredPermissions set') + assert.strictEqual(execFileCalls.length, 0) }) it('returns credentials when all required permissions are present', async () => { @@ -392,17 +410,22 @@ describe('ensureAuthenticated - requiredPermissions', () => { } saveCredentials(creds) - globalThis.fetch = (async () => ({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ permissions: ['nsolid:benchmark:run', 'nsolid:profile:read'] }), - })) as unknown as typeof fetch + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: ['nsolid:benchmark:run', 'nsolid:profile:read'] }), + } + }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') const result = await ensureAuthenticated(authConfigWithPerms) assert.deepStrictEqual(result.permissions, ['nsolid:benchmark:run', 'nsolid:profile:read']) + assert.strictEqual(fetchCalls, 0) }) }) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 899382d..b607ae3 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -627,6 +627,34 @@ describe('uninstall()', () => { await assert.doesNotReject(() => uninstall('claude')) }) + + it('suppresses the hardcoded-MCP-list warning for plugin-owned harnesses', async () => { + // For plugin-owned harnesses (claude/codex/antigravity) the MCP servers are + // owned by the native plugin, so best-effort MCP cleanup is redundant and its + // warning is noise. The warning must not surface without a tracking file. + const { uninstall } = await import('../../src/index.js') + + for (const harness of ['claude', 'codex', 'antigravity'] as const) { + const result = await uninstall(harness) + assert.ok( + !result.errors.some((e) => e.includes('No tracking file and no bundle provided')), + `${harness} must not emit the hardcoded-MCP-list warning` + ) + } + }) + + it('emits the hardcoded-MCP-list warning for non-plugin-owned harnesses', async () => { + // For CLI-owned harnesses (opencode) the warning is still useful, since the + // harness-level MCP config is the source of truth and a hardcoded fallback + // genuinely may leave user-added servers behind. + const { uninstall } = await import('../../src/index.js') + + const result = await uninstall('opencode') + assert.ok( + result.errors.some((e) => e.includes('No tracking file and no bundle provided')), + 'opencode should still emit the hardcoded-MCP-list warning' + ) + }) }) describe('doctor()', () => { diff --git a/packages/core/test/unit/skills/fetch-asset.test.ts b/packages/core/test/unit/skills/fetch-asset.test.ts index 3a738ac..0a11580 100644 --- a/packages/core/test/unit/skills/fetch-asset.test.ts +++ b/packages/core/test/unit/skills/fetch-asset.test.ts @@ -1,7 +1,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' +import { promises as dns } from 'node:dns' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' +import type { TestContext } from 'node:test' const __dirname = dirname(fileURLToPath(import.meta.url)) const fetchAssetPath = join(__dirname, '../../../../../skill-assets/fetch-asset.cjs') @@ -32,6 +34,15 @@ async function loadFetchAsset () { } } +function mockDnsLookup (t: TestContext, addresses: string[]) { + t.mock.method(dns, 'lookup', async () => { + return addresses.map((address) => ({ + address, + family: address.includes(':') ? 6 : 4, + })) + }) +} + describe('isPrivateOrLocalIp', () => { it('detects IPv4 loopback addresses', async () => { const { isPrivateOrLocalIp } = await loadFetchAsset() @@ -207,11 +218,12 @@ describe('validateConsoleUrl', () => { ) }) - it('rejects hostnames that resolve to loopback', async () => { + it('rejects hostnames that resolve to loopback', async (t) => { + mockDnsLookup(t, ['127.0.0.2']) const { validateConsoleUrl } = await loadFetchAsset() await assert.rejects( - () => validateConsoleUrl('https://localhost'), - /consoleUrl cannot be localhost/ + () => validateConsoleUrl('https://loopback.example.test'), + /private or local address/ ) }) @@ -221,7 +233,8 @@ describe('validateConsoleUrl', () => { await assert.doesNotReject(() => validateConsoleUrl('https://8.8.8.8')) }) - it('allows public hostnames', async () => { + it('allows public hostnames', async (t) => { + mockDnsLookup(t, ['93.184.216.34']) const { validateConsoleUrl } = await loadFetchAsset() await assert.doesNotReject(() => validateConsoleUrl('https://example.com')) }) From 17e84ffad74418526d806d16a4b7b84f216fd967 Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Fri, 3 Jul 2026 12:40:28 +0200 Subject: [PATCH 10/11] fix: restore tokens check and use correct accounts api endpoint --- .../proposal.md | 2 +- .../specs/installation-and-auth.md | 8 +- .../cross-harness-plugin-installer/tasks.md | 4 +- packages/core/src/auth/auth-manager.ts | 57 +++++-- packages/core/src/auth/token-validator.ts | 24 ++- packages/core/src/cli.ts | 35 ++-- .../harnesses/native-plugin-uninstaller.ts | 4 +- .../integration/auth/auth-manager.test.ts | 150 ++++++++++++++---- .../test/unit/auth/token-validator.test.ts | 54 +++++++ .../native-plugin-uninstaller.test.ts | 29 ++++ 10 files changed, 295 insertions(+), 72 deletions(-) diff --git a/openspec/changes/cross-harness-plugin-installer/proposal.md b/openspec/changes/cross-harness-plugin-installer/proposal.md index b17224a..029bcc0 100644 --- a/openspec/changes/cross-harness-plugin-installer/proposal.md +++ b/openspec/changes/cross-harness-plugin-installer/proposal.md @@ -46,7 +46,7 @@ Adapt the nsentinel-vscode-extension OAuth flow: - Store service token, org ID, console URL, SaaS token, MCP URL, and expiry in `~/.agents/.nodesource-auth.json`. - MCP wrappers read credentials from this file at runtime. - Runtime unauthenticated failures tell the user to run `nsolid-plugin setup`. -- Token validation via `/accounts/org/access-token` endpoint where available. +- Token validation via the API `/accounts/org/access-token` endpoint where available. ### Installation and Setup Flow 1. Root manifests are kept in sync with `bundle.json` via `pnpm plugin:root` (committed, not a release-time generation step). `pnpm plugin:root:check` (also run by `pnpm plugin:check`) fails CI if the committed manifests drift. diff --git a/openspec/changes/cross-harness-plugin-installer/specs/installation-and-auth.md b/openspec/changes/cross-harness-plugin-installer/specs/installation-and-auth.md index 31e29b8..672f3a6 100644 --- a/openspec/changes/cross-harness-plugin-installer/specs/installation-and-auth.md +++ b/openspec/changes/cross-harness-plugin-installer/specs/installation-and-auth.md @@ -101,7 +101,7 @@ **Then** a browser opens to `https://accounts.nodesource.com/sign-in?extension=nsolid-plugin&port=<callback-port>&state=<uuid>` **And** a local HTTP callback server starts on port 8765 **And** after user completes OAuth in browser, the callback receives the service token -**And** the token is validated via `/accounts/org/access-token?tokenId=<token>&orgId=<orgId>` +**And** the token is validated via `https://api.nodesource.com/accounts/org/access-token?tokenId=<token>&orgId=<orgId>` **And** credentials are stored at `~/.agents/.nodesource-auth.json` with structure: ```json { @@ -134,15 +134,15 @@ ## Scenario: Token validation failure **Given** OAuth completed and a token was received -**When** token validation fails (401/403 from Accounts API) +**When** token validation fails (401/403/404 from NodeSource API) **Then** an error message indicates invalid credentials **And** no credentials are stored **And** the user is prompted to retry with correct account -## Scenario: Accounts API unavailable +## Scenario: NodeSource API unavailable **Given** OAuth completed and a token was received -**When** the Accounts API is unreachable (network error, 5xx) +**When** the NodeSource API is unreachable (network error, 5xx) **Then** credentials are stored anyway (optimistic) **And** a warning indicates validation failed but installation continues **And** MCP servers will validate on first use diff --git a/openspec/changes/cross-harness-plugin-installer/tasks.md b/openspec/changes/cross-harness-plugin-installer/tasks.md index acae772..9247df1 100644 --- a/openspec/changes/cross-harness-plugin-installer/tasks.md +++ b/openspec/changes/cross-harness-plugin-installer/tasks.md @@ -77,7 +77,7 @@ - **Spec reference**: Credentials Storage in design.md ### Task 6: Implement token validator module ✓ -- [x] **Description**: Create token-validator.ts to validate service tokens with Accounts API at `/accounts/org/access-token`. Handle network errors, 401/403 responses, and timeouts. Return validation result with permissions list. +- [x] **Description**: Create token-validator.ts to validate service tokens with API at `/accounts/org/access-token`. Handle network errors, 401/403/404 responses, and timeouts. Return validation result with permissions list. - **Depends on**: Task 5 - **Files**: - `packages/core/src/auth/token-validator.ts` @@ -93,7 +93,7 @@ - **Spec reference**: OAuth timeout and port conflict scenarios in specs/installation-and-auth.md ### Task 8: Implement auth manager orchestrator ✓ -- [x] **Description**: Create auth-manager.ts with `ensureAuthenticated()` function. Check for existing valid credentials first. If missing/expired, initiate OAuth flow (open browser, start callback server). Parse callback parameters: token, consoleId, NSOLID_SAAS, url. Derive MCP URL from consoleId. Validate token with Accounts API. Store credentials including saasToken, consoleUrl, and mcpUrl. Return Credentials object. +- [x] **Description**: Create auth-manager.ts with `ensureAuthenticated()` function. Check for existing valid credentials first. If missing/expired, initiate OAuth flow (open browser, start callback server). Parse callback parameters: token, consoleId, NSOLID_SAAS, url. Derive MCP URL from consoleId. Validate token with NodeSource API. Store credentials including saasToken, consoleUrl, and mcpUrl. Return Credentials object. - **Note**: Credentials now include `saasToken`, `consoleUrl`, and `mcpUrl` in addition to `serviceToken`, `organizationId`, and `expiresAt` per real accounts service behavior observed in nsentinel-vscode-extension. - **Depends on**: Tasks 5, 6, 7 - **Files**: diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index e20886b..bb1640d 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -37,6 +37,25 @@ function checkRequiredPermissions ( } } +function checkCachedPermissionsIfKnown ( + required: string[], + existing: Credentials, + logger?: Logger +): void { + if (required.length === 0) return + if (existing.permissions === undefined) { + logger?.debug('auth.credentials.cachedPermissionsUnknown', { orgId: existing.organizationId }) + return + } + checkRequiredPermissions(required, existing.permissions) +} + +function samePermissions (left: string[] | undefined, right: string[]): boolean { + if (left === undefined || left.length !== right.length) return false + const known = new Set(left) + return right.every((permission) => known.has(permission)) +} + export interface EnsureAuthenticatedOptions { harness?: HarnessType; confirmAuth?: AuthConfirmation; @@ -58,15 +77,33 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg if (existing.accountsUrl && existing.accountsUrl !== authConfig.accountsUrl) { logger?.info('auth.credentials.originMismatch', { stored: existing.accountsUrl, current: authConfig.accountsUrl }) } else { - // Repeated setup trusts stored credentials: no re-validation against the - // accounts API and no requiredPermissions check. Those run only on a - // fresh login (logout, credential deletion, or expiry). This keeps - // `setup` idempotent and avoids spurious failures when the validation - // API is intermittently unavailable or the account lacks optional - // benchmark access. A server-side revocation surfaces at MCP runtime - // instead; the user can `logout` to force a fresh login. - logger?.debug('auth.credentials.reused', { orgId: existing.organizationId }) - return existing + try { + const validationAccountsUrl = existing.accountsUrl ?? authConfig.accountsUrl + const result = await validateToken(existing.serviceToken, existing.organizationId, validationAccountsUrl, logger) + if (result.valid) { + if (required.length > 0) { + checkRequiredPermissions(required, result.permissions) + } + logger?.debug('auth.credentials.valid', { orgId: existing.organizationId }) + const refreshed = { ...existing, permissions: result.permissions } + if (!samePermissions(existing.permissions, result.permissions)) { + saveCredentials(refreshed) + } + return refreshed + } + logger?.info('auth.credentials.invalid', { orgId: existing.organizationId }) + } catch (err) { + if (err instanceof PermissionError) { + throw err + } + // API unavailable, timed out, or served a non-JSON fallback: keep setup + // idempotent by trusting the unexpired, origin-matching credentials. If + // this credentials file already has a permission cache, still enforce + // requiredPermissions against that known local state. + logger?.warn('auth.credentials.validationUnavailable', { error: (err as Error).message }) + checkCachedPermissionsIfKnown(required, existing, logger) + return existing + } } } @@ -139,7 +176,7 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg logger?.info('auth.credentials.saved', { orgId: creds.organizationId }) return creds } catch (err) { - if (err instanceof InvalidCredentialsError) { + if (err instanceof InvalidCredentialsError || err instanceof PermissionError) { throw err } // API unavailable - store optimistically diff --git a/packages/core/src/auth/token-validator.ts b/packages/core/src/auth/token-validator.ts index c049bbe..ca02461 100644 --- a/packages/core/src/auth/token-validator.ts +++ b/packages/core/src/auth/token-validator.ts @@ -5,6 +5,16 @@ export type ValidationResult = | { valid: true; permissions: string[] } | { valid: false; reason: string } +export function deriveAccountsApiUrl (accountsUrl: string): string { + const url = new URL(accountsUrl) + if (url.hostname === 'accounts.nodesource.com') { + url.hostname = 'api.nodesource.com' + } else if (url.hostname === 'staging.accounts.nodesource.com') { + url.hostname = 'staging.api.nodesource.com' + } + return url.origin +} + export async function validateToken ( token: string, orgId: string, @@ -15,36 +25,36 @@ export async function validateToken ( const timeoutId = setTimeout(() => controller.abort(), 10000) try { - const url = new URL('/accounts/org/access-token', accountsUrl) + const url = new URL('/accounts/org/access-token', deriveAccountsApiUrl(accountsUrl)) url.searchParams.set('tokenId', token) url.searchParams.set('orgId', orgId) - logger?.debug('auth.token.validate', { orgId, accountsUrl }) + logger?.debug('auth.token.validate', { orgId, accountsUrl, apiUrl: url.origin }) const response = await fetch(url.toString(), { signal: controller.signal, }) - if (response.status === 401 || response.status === 403) { + if (response.status === 401 || response.status === 403 || response.status === 404) { return { valid: false, reason: 'Invalid credentials' } } if (!response.ok) { - throw new Error(`Accounts API returned ${response.status}`) + throw new Error(`NodeSource API returned ${response.status}`) } const contentType = response.headers?.get('content-type') if (contentType && !contentType.includes('application/json')) { - throw new Error(`Accounts API returned unexpected content type: ${contentType}`) + throw new Error(`NodeSource API returned unexpected content type: ${contentType}`) } const data = await response.json() as unknown if (typeof data !== 'object' || data === null) { - throw new Error('Accounts API returned invalid response format') + throw new Error('NodeSource API returned invalid response format') } const obj = data as Record<string, unknown> if (obj.permissions !== undefined && !Array.isArray(obj.permissions)) { - throw new Error('Accounts API returned invalid permissions format') + throw new Error('NodeSource API returned invalid permissions format') } const permissions = Array.isArray(obj.permissions) ? obj.permissions.filter((p): p is string => typeof p === 'string') diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 6ce79ef..9ad5b6b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -97,10 +97,11 @@ async function promptForHarnesses (command: string, multiple: boolean): Promise< const stdin = process.stdin const wasRaw = stdin.isRaw const choices = promptHarnessChoices() - const selected = new Set<HarnessType>([choices[0]]) + const selected = new Set<HarnessType>() const action = promptActionLabel(command) let cursor = 0 let renderedLines = 0 + let message = '' const render = () => { if (renderedLines > 0) { @@ -116,18 +117,17 @@ async function promptForHarnesses (command: string, multiple: boolean): Promise< '', ...choices.map((value, index) => { const pointer = index === cursor ? '❯' : ' ' - const marker = multiple - ? selected.has(value) ? '●' : '○' - : index === cursor ? '●' : '○' + const marker = selected.has(value) ? '●' : '○' return ` ${pointer} ${marker} ${harnessPromptLabel(value, command)}` }), ...(command === 'install' ? ['', 'Pi is package-owned: use `pi install ...` for skills; this installer only writes MCP config.'] : []), + ...(message ? ['', message] : []), '', multiple ? '↑/↓ move Space toggle a select all Enter continue q cancel' - : '↑/↓ move Enter select q cancel', + : '↑/↓ move Space select Enter continue q cancel', ] renderedLines = lines.length process.stderr.write(`${lines.join('\n')}\n`) @@ -155,18 +155,26 @@ async function promptForHarnesses (command: string, multiple: boolean): Promise< } if (key === '\x1B[A' || key === 'k') { cursor = (cursor - 1 + choices.length) % choices.length + message = '' render() return } if (key === '\x1B[B' || key === 'j') { cursor = (cursor + 1) % choices.length + message = '' render() return } - if (multiple && key === ' ') { + if (key === ' ') { const value = choices[cursor] - if (selected.has(value)) selected.delete(value) - else selected.add(value) + if (multiple) { + if (selected.has(value)) selected.delete(value) + else selected.add(value) + } else { + selected.clear() + selected.add(value) + } + message = '' render() return } @@ -177,10 +185,15 @@ async function promptForHarnesses (command: string, multiple: boolean): Promise< return } if (key === '\r' || key === '\n') { + if (selected.size === 0) { + message = multiple + ? 'Select at least one harness with Space before continuing.' + : 'Select a harness with Space before continuing.' + render() + return + } finish() - const result = multiple - ? selected.size > 0 ? choices.filter((value) => selected.has(value)) : [choices[cursor]] - : [choices[cursor]] + const result = choices.filter((value) => selected.has(value)) process.stderr.write(`\nSelected: ${result.map((value) => HARNESS_LABELS[value]).join(', ')}\n\n`) resolve(result) } diff --git a/packages/core/src/harnesses/native-plugin-uninstaller.ts b/packages/core/src/harnesses/native-plugin-uninstaller.ts index 0f2df2d..100791f 100644 --- a/packages/core/src/harnesses/native-plugin-uninstaller.ts +++ b/packages/core/src/harnesses/native-plugin-uninstaller.ts @@ -129,7 +129,7 @@ function cliCommand (harness: string, id: string): string[] { case 'claude': return ['claude', 'plugin', 'uninstall', id] case 'codex': - return ['codex', 'plugin', 'uninstall', id] + return ['codex', 'plugin', 'remove', id] case 'antigravity': return ['agy', 'plugin', 'uninstall', PLUGIN_BASE_NAME] default: @@ -143,7 +143,7 @@ function manualHint (harness: string, ids: string[]): string { case 'claude': return `claude plugin uninstall ${id}` case 'codex': - return `codex plugin uninstall ${id}` + return `codex plugin remove ${id}` case 'antigravity': return `agy plugin uninstall ${PLUGIN_BASE_NAME}` default: diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index ec0f16a..515d03a 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -100,9 +100,7 @@ afterEach(() => { }) describe('ensureAuthenticated', () => { - it('returns existing unexpired credentials without re-validating (fast path)', async () => { - // setup is auth-only and must be idempotent: repeated runs trust stored - // credentials and never call the validation API or re-check permissions. + it('validates existing unexpired credentials before returning them', async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { @@ -131,14 +129,12 @@ describe('ensureAuthenticated', () => { const result = await ensureAuthenticated(authConfig) assert.deepStrictEqual(result, creds) - assert.strictEqual(fetchCalls, 0, 'fast path must not call the validation API') + assert.strictEqual(fetchCalls, 1, 'fast path should attempt token validation') assert.strictEqual(execFileCalls.length, 0) }) - it('returns stored credentials unchanged on the fast path (no permissions mutation)', async () => { - // Even when the stored permissions array differs from any API response, - // repeated setup returns the credentials verbatim — no validation, no merge. - const { saveCredentials } = await import('../../../src/auth/token-storage.js') + it('returns validated permissions with stored credentials', async () => { + const { loadCredentials, saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'existing-token', @@ -165,8 +161,9 @@ describe('ensureAuthenticated', () => { const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') const result = await ensureAuthenticated(authConfig) - assert.deepStrictEqual(result, creds) - assert.strictEqual(fetchCalls, 0, 'stored credentials must be trusted verbatim') + assert.deepStrictEqual(result, { ...creds, permissions: ['completely-different:perm'] }) + assert.deepStrictEqual(loadCredentials()?.permissions, ['completely-different:perm']) + assert.strictEqual(fetchCalls, 1, 'stored credentials should be validated when possible') assert.strictEqual(execFileCalls.length, 0) }) @@ -264,9 +261,6 @@ describe('ensureAuthenticated', () => { } saveCredentials(creds) - // The fast path no longer calls the validation API at all, so an - // unreachable API cannot interfere with repeated setup. Credentials are - // trusted verbatim. let fetchCalls = 0 globalThis.fetch = (async () => { fetchCalls++ @@ -278,15 +272,11 @@ describe('ensureAuthenticated', () => { assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') - assert.strictEqual(fetchCalls, 0, 'fast path must not call validation API') + assert.strictEqual(fetchCalls, 1, 'fast path should try validation before falling back') assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API is unavailable') }) - it('trusts stored credentials on the fast path even when the API would reject them', async () => { - // Repeated setup must be idempotent: it does not re-validate stored - // credentials, so a token the API has since revoked is still trusted until - // the user runs `logout` or the credentials expire. Revocation surfaces at - // MCP runtime instead. + it('re-authenticates when validation rejects stored credentials', { timeout: 10000 }, async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'revoked-token', @@ -309,18 +299,22 @@ describe('ensureAuthenticated', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfig) + const promise = ensureAuthenticated(authConfig) - assert.strictEqual(result.serviceToken, 'revoked-token') - assert.strictEqual(result.organizationId, 'org-123') - assert.strictEqual(fetchCalls, 0, 'fast path must not re-validate stored credentials') - assert.strictEqual(execFileCalls.length, 0, 'browser must not open on the fast path') + const state = await pollForState(getStateFromExecFileCall) + await sendCallback(8767, state) + const result = await promise + + assert.strictEqual(result.serviceToken, 'oauth-token') + assert.strictEqual(result.organizationId, 'org-456') + assert.strictEqual(fetchCalls, 2, 'stored token rejection should be followed by OAuth token validation') + assert.strictEqual(execFileCalls.length, 1, 'browser should open for re-authentication') }) it('trusts stored credentials when validation API returns an HTML shell (200 text/html)', async () => { // The accounts API serves its SPA index.html (HTTP 200, content-type - // text/html) for server-side callers. Because the fast path no longer calls - // the API at all, this can no longer force a re-login during repeated setup. + // text/html) for server-side callers. Treat that as validation unavailable + // and fall back to the unexpired local credentials. const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { serviceToken: 'valid-token', @@ -349,7 +343,7 @@ describe('ensureAuthenticated', () => { assert.strictEqual(result.serviceToken, 'valid-token') assert.strictEqual(result.organizationId, 'org-123') - assert.strictEqual(fetchCalls, 0, 'fast path must not call validation API') + assert.strictEqual(fetchCalls, 1, 'fast path should try validation before falling back') assert.strictEqual(execFileCalls.length, 0, 'browser must not open when API returns an HTML shell') }) }) @@ -360,10 +354,7 @@ describe('ensureAuthenticated - requiredPermissions', () => { requiredPermissions: ['nsolid:benchmark:run', 'nsolid:profile:read'], } - it('trusts stored credentials on the fast path regardless of cached permissions', async () => { - // Repeated setup must not re-check requiredPermissions against stored - // credentials. Even when cached perms are a strict subset of required, the - // fast path returns them verbatim without calling the API. + it('throws when validation succeeds but required permissions are missing', async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { @@ -389,10 +380,12 @@ describe('ensureAuthenticated - requiredPermissions', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfigWithPerms) - assert.strictEqual(result.serviceToken, 'existing-token') - assert.strictEqual(fetchCalls, 0, 'fast path must not call the API even with requiredPermissions set') + await assert.rejects( + ensureAuthenticated(authConfigWithPerms), + /Missing required permissions: nsolid:profile:read/ + ) + assert.strictEqual(fetchCalls, 1) assert.strictEqual(execFileCalls.length, 0) }) @@ -425,7 +418,94 @@ describe('ensureAuthenticated - requiredPermissions', () => { const result = await ensureAuthenticated(authConfigWithPerms) assert.deepStrictEqual(result.permissions, ['nsolid:benchmark:run', 'nsolid:profile:read']) - assert.strictEqual(fetchCalls, 0) + assert.strictEqual(fetchCalls, 1) + assert.strictEqual(execFileCalls.length, 0) + }) + + it('checks known cached permissions when validation is unavailable', async () => { + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + + const creds: Credentials = { + serviceToken: 'existing-token', + organizationId: 'org-123', + saasToken: 'test-saas-token', + consoleUrl: 'https://test.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + permissions: ['nsolid:benchmark:run'], + } + saveCredentials(creds) + + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + + await assert.rejects( + ensureAuthenticated(authConfigWithPerms), + /Missing required permissions: nsolid:profile:read/ + ) + assert.strictEqual(fetchCalls, 1) + assert.strictEqual(execFileCalls.length, 0) + }) + + it('trusts stored credentials with unknown cached permissions when validation is unavailable', async () => { + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + + const creds: Credentials = { + serviceToken: 'existing-token', + organizationId: 'org-123', + saasToken: 'test-saas-token', + consoleUrl: 'https://test.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + } + saveCredentials(creds) + + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const result = await ensureAuthenticated(authConfigWithPerms) + + assert.deepStrictEqual(result, creds) + assert.strictEqual(fetchCalls, 1) + assert.strictEqual(execFileCalls.length, 0) + }) + + it('does not store fresh OAuth credentials when validation reports missing required permissions', { timeout: 10000 }, async () => { + const { loadCredentials } = await import('../../../src/auth/token-storage.js') + + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: ['nsolid:benchmark:run'] }), + } + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const promise = ensureAuthenticated(authConfigWithPerms) + const rejection = assert.rejects( + promise, + /Missing required permissions: nsolid:profile:read/ + ) + + const state = await pollForState(getStateFromExecFileCall) + await sendCallback(8767, state) + await rejection + assert.strictEqual(fetchCalls, 1) + assert.strictEqual(execFileCalls.length, 1) + assert.strictEqual(loadCredentials(), null) }) }) diff --git a/packages/core/test/unit/auth/token-validator.test.ts b/packages/core/test/unit/auth/token-validator.test.ts index 804f316..ae3b1c0 100644 --- a/packages/core/test/unit/auth/token-validator.test.ts +++ b/packages/core/test/unit/auth/token-validator.test.ts @@ -13,6 +13,45 @@ afterEach(() => { }) describe('validateToken', () => { + it('derives the API origin from production and staging accounts origins', async () => { + const { deriveAccountsApiUrl } = await import('../../../src/auth/token-validator.js') + + assert.strictEqual( + deriveAccountsApiUrl('https://accounts.nodesource.com'), + 'https://api.nodesource.com' + ) + assert.strictEqual( + deriveAccountsApiUrl('https://staging.accounts.nodesource.com'), + 'https://staging.api.nodesource.com' + ) + assert.strictEqual( + deriveAccountsApiUrl('https://accounts.example.com'), + 'https://accounts.example.com' + ) + }) + + it('calls the API origin for NodeSource accounts token validation', async () => { + let requestedUrl = '' + globalThis.fetch = (async (input: RequestInfo | URL) => { + requestedUrl = input.toString() + return { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + } + }) as unknown as typeof fetch + + const { validateToken } = await import('../../../src/auth/token-validator.js') + await validateToken('test-token', 'org-123', 'https://accounts.nodesource.com') + + const url = new URL(requestedUrl) + assert.strictEqual(url.origin, 'https://api.nodesource.com') + assert.strictEqual(url.pathname, '/accounts/org/access-token') + assert.strictEqual(url.searchParams.get('tokenId'), 'test-token') + assert.strictEqual(url.searchParams.get('orgId'), 'org-123') + }) + it('returns valid with permissions on 200', async () => { globalThis.fetch = (async () => ({ ok: true, @@ -45,6 +84,21 @@ describe('validateToken', () => { } satisfies ValidationResult) }) + it('returns invalid on 404 from the access-token endpoint', async () => { + globalThis.fetch = (async () => ({ + ok: false, + status: 404, + })) as unknown as typeof fetch + + const { validateToken } = await import('../../../src/auth/token-validator.js') + const result = await validateToken('missing-token', 'org-123', 'https://accounts.nodesource.com') + + assert.deepStrictEqual(result, { + valid: false, + reason: 'Invalid credentials', + } satisfies ValidationResult) + }) + it('throws on 500', async () => { globalThis.fetch = (async () => ({ ok: false, diff --git a/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts b/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts index 7dff286..de2ea92 100644 --- a/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts +++ b/packages/core/test/unit/harnesses/native-plugin-uninstaller.test.ts @@ -145,6 +145,35 @@ describe('removeNativePlugin', () => { assert.ok('other@x' in data.plugins) }) + it('codex cli: delegates using plugin remove', async () => { + const { getAdapter } = await import('../../../src/harnesses/index.js') + const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') + const { resolveHome } = await import('../../../src/utils/path.js') + const { stringify: stringifyToml } = await import('smol-toml') + + const configPath = resolveHome('~/.codex/config.toml') + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, stringifyToml({ + plugins: { + 'nsolid-plugin@nodesource': { enabled: true }, + }, + } as Record<string, unknown>)) + + const calls: Array<{ cmd: string; args: string[] }> = [] + const runCli = async (cmd: string, args: string[]): Promise<number> => { + calls.push({ cmd, args }) + return 0 + } + + const adapter = getAdapter('codex') + const result = await removeNativePlugin('codex', adapter, { runCli }) + + assert.strictEqual(result.removed, true) + assert.deepStrictEqual(calls, [ + { cmd: 'codex', args: ['plugin', 'remove', 'nsolid-plugin@nodesource'] }, + ]) + }) + it('is a no-op when the plugin is not installed', async () => { const { getAdapter } = await import('../../../src/harnesses/index.js') const { removeNativePlugin } = await import('../../../src/harnesses/native-plugin-uninstaller.js') From 5249f646747c9432c71fa60be5877d0458ac984c Mon Sep 17 00:00:00 2001 From: Cesar-M-Diaz <arq.cmdiaz1@gmail.com> Date: Mon, 6 Jul 2026 11:50:01 +0200 Subject: [PATCH 11/11] fix auth --- packages/core/src/auth/auth-manager.ts | 39 +++++++++++++++---- .../integration/auth/auth-manager.test.ts | 32 +++++++++++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index bb1640d..7a1c477 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -37,15 +37,33 @@ function checkRequiredPermissions ( } } -function checkCachedPermissionsIfKnown ( +function failUnknownRequiredPermissions ( + required: string[], + reason: string, + cause?: unknown +): never { + throw new PermissionError( + required, + `Cannot verify required permissions: ${required.join(', ')}. ` + + `${reason} Retry when validation is available or re-authenticate.`, + { cause } + ) +} + +function checkCachedPermissions ( required: string[], existing: Credentials, - logger?: Logger + logger?: Logger, + cause?: unknown ): void { if (required.length === 0) return if (existing.permissions === undefined) { logger?.debug('auth.credentials.cachedPermissionsUnknown', { orgId: existing.organizationId }) - return + failUnknownRequiredPermissions( + required, + 'Token validation is unavailable and cached credentials do not include permission evidence.', + cause + ) } checkRequiredPermissions(required, existing.permissions) } @@ -97,11 +115,11 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg throw err } // API unavailable, timed out, or served a non-JSON fallback: keep setup - // idempotent by trusting the unexpired, origin-matching credentials. If - // this credentials file already has a permission cache, still enforce - // requiredPermissions against that known local state. + // idempotent by trusting the unexpired, origin-matching credentials + // only when no permissions are required, or when cached permissions + // locally prove the requested access. logger?.warn('auth.credentials.validationUnavailable', { error: (err as Error).message }) - checkCachedPermissionsIfKnown(required, existing, logger) + checkCachedPermissions(required, existing, logger, err) return existing } } @@ -179,6 +197,13 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg if (err instanceof InvalidCredentialsError || err instanceof PermissionError) { throw err } + if (required.length > 0) { + failUnknownRequiredPermissions( + required, + 'Token validation is unavailable, so fresh OAuth credentials cannot be authorized locally.', + err + ) + } // API unavailable - store optimistically logger?.warn('Could not validate token. Storing credentials optimistically.', { consoleId: callback.consoleId }) const creds: Credentials = { diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 515d03a..b8c8862 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -452,7 +452,7 @@ describe('ensureAuthenticated - requiredPermissions', () => { assert.strictEqual(execFileCalls.length, 0) }) - it('trusts stored credentials with unknown cached permissions when validation is unavailable', async () => { + it('rejects stored credentials with unknown cached permissions when validation is unavailable', async () => { const { saveCredentials } = await import('../../../src/auth/token-storage.js') const creds: Credentials = { @@ -472,13 +472,39 @@ describe('ensureAuthenticated - requiredPermissions', () => { }) as unknown as typeof fetch const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') - const result = await ensureAuthenticated(authConfigWithPerms) - assert.deepStrictEqual(result, creds) + await assert.rejects( + ensureAuthenticated(authConfigWithPerms), + /Cannot verify required permissions: nsolid:benchmark:run, nsolid:profile:read/ + ) assert.strictEqual(fetchCalls, 1) assert.strictEqual(execFileCalls.length, 0) }) + it('does not store fresh OAuth credentials when required permissions cannot be verified', { timeout: 10000 }, async () => { + const { loadCredentials } = await import('../../../src/auth/token-storage.js') + + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const promise = ensureAuthenticated(authConfigWithPerms) + const rejection = assert.rejects( + promise, + /Cannot verify required permissions: nsolid:benchmark:run, nsolid:profile:read/ + ) + + const state = await pollForState(getStateFromExecFileCall) + await sendCallback(8767, state) + await rejection + assert.strictEqual(fetchCalls, 1) + assert.strictEqual(execFileCalls.length, 1) + assert.strictEqual(loadCredentials(), null) + }) + it('does not store fresh OAuth credentials when validation reports missing required permissions', { timeout: 10000 }, async () => { const { loadCredentials } = await import('../../../src/auth/token-storage.js')