# Add Skill Install Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add an “Add/Install Skill” workflow to the Skills & Extensions page so users can search skills.sh, choose global/project scope, install a selected skill package, and see the refreshed skills list.

**Architecture:** Reuse the existing Glimpse bridge message flow instead of adding HTTP routes. Extract skill search/install parsing and argument-building into a small tested utility module, keep `index.ts` responsible for bridge handling and process execution, and keep the frontend as a vanilla `web/app.js` inline panel. Installation must only run `npx skills add` with validated `owner/repo@skill` package IDs selected from the current backend-returned search result set.

**Tech Stack:** TypeScript extension backend (`index.ts`), vanilla browser frontend (`web/app.js`), Glimpse bridge (`window.glimpse.send` / `window.__desktopReceive`), Node verification scripts, `npx skills`, skills.sh API.

---

## Current State

- `web/app.js` renders Skills & Extensions around `renderSkillsView()` and currently supports listing skills/extensions, Refresh, and click-to-load skill behavior.
- `web/app.js` handles `update-skills` in `window.__desktopReceive(message)` but does not handle `skill-search-results` or `skill-install-result`.
- `index.ts` already contains search/install helper logic and bridge cases for `search-skill-packages` / `install-skill-package`; do not duplicate this blindly.
- `index.ts` currently scans global skill directories and project `.pi/skills` / `.agents/skills`; project `.pi/agent/skills` is not a pi skill location and should not be introduced.
- Pi docs list project skill locations as `.pi/skills/` and `.agents/skills/`; the current desktop extension scans the active workspace `cwd` only. Do not add ancestor traversal in this Add Skill UI task unless a separate failing behavior test requires it.
- Reference implementation in `agegr/pi-web` uses skills.sh search, result cards, scope selector, install path preview, installing state, and installed status.

## File Structure

- Create: `skill-package-utils.js`
  - Export pure helpers for package validation, install argument construction, install-count formatting/parsing, API result normalization, CLI output parsing, and project skill directory construction.
- Modify: `index.ts`
  - Import utility helpers from `skill-package-utils.js`.
  - Keep process execution in `index.ts` via `runCommandCapture()`.
  - Maintain an allowlist of package IDs from the current backend search; clear it on each search request and reject install requests outside it.
  - Refresh skills/extensions after successful install.
- Modify: `web/app.js`
  - Add frontend state for add/search/install workflow.
  - Add Add Skill button, inline panel, search input, scope selector, result list, install state, and error/success states.
  - Add bridge message handling for `skill-search-results` and `skill-install-result`.
- Create: `scripts/test-skill-package-utils.mjs`
  - Behavior tests for pure package utilities.
- Create: `scripts/test-skills-view-state.mjs`
  - Smoke/regression checks for frontend bridge message names, send payloads, and visible state labels.
- Modify: `package.json`
  - Add verification scripts and include them in `npm run check`.
- Optional modify: `README.md`
  - Document Add Skill behavior and global/project scope locations if user-facing docs are desired.

---

## Chunk 1: Extract Tested Skill Package Utilities

### Task 1: Add pure utility contract

**Tier:** standard

**Files:**
- Create: `skill-package-utils.js`
- Modify: `index.ts`
- Test: `scripts/test-skill-package-utils.mjs`

- [ ] **Step 1: Write failing utility tests**

Create `scripts/test-skill-package-utils.mjs`:

```js
import assert from "node:assert/strict";
import { join, sep } from "node:path";
import {
  buildSkillInstallArgs,
  formatInstalls,
  isValidSkillPackageSource,
  getProjectSkillDirs,
  normalizeSkillApiResults,
  parseInstallCount,
  parseSkillSearchOutput,
} from "../skill-package-utils.js";

assert.deepEqual(getProjectSkillDirs("/tmp/workspace"), [
  join("/tmp/workspace", ".pi", "skills"),
  join("/tmp/workspace", ".agents", "skills"),
]);
assert.equal(getProjectSkillDirs("/tmp/workspace").some((dir) => dir.includes(`${sep}.pi${sep}agent${sep}skills`)), false);

assert.equal(isValidSkillPackageSource("owner/repo@testing"), true);
assert.equal(isValidSkillPackageSource("owner/repo@skill-name"), true);
assert.equal(isValidSkillPackageSource("owner/repo;rm -rf /@skill"), false);
assert.equal(isValidSkillPackageSource("npm:@scope/package"), false);
assert.equal(isValidSkillPackageSource("https://example.com/repo@skill"), false);

assert.deepEqual(
  buildSkillInstallArgs("owner/repo@testing", "global"),
  ["skills", "add", "owner/repo@testing", "-y", "--agent", "pi", "-g"],
);
assert.deepEqual(
  buildSkillInstallArgs("owner/repo@testing", "project"),
  ["skills", "add", "owner/repo@testing", "-y", "--agent", "pi"],
);
assert.throws(() => buildSkillInstallArgs("npm:@scope/package", "global"), /Invalid skill package/);

assert.equal(formatInstalls(1), "1 install");
assert.equal(formatInstalls(1200), "1.2K installs");
assert.equal(parseInstallCount("1.2K installs"), 1200);
assert.equal(parseInstallCount("1,234 installs"), 1234);
assert.equal(parseInstallCount(""), 0);

const cliOutput = `
owner/repo@testing  1.2K installs
└ https://skills.sh/owner/repo/testing
bad line
other/repo@deploy  12 installs
`;
assert.deepEqual(parseSkillSearchOutput(cliOutput, 10), [
  { package: "owner/repo@testing", installs: "1.2K installs", url: "https://skills.sh/owner/repo/testing" },
  { package: "other/repo@deploy", installs: "12 installs", url: "" },
]);

assert.deepEqual(normalizeSkillApiResults([
  { id: "owner/repo/testing", name: "testing", source: "owner/repo", installs: 2 },
  { id: "bad", name: "bad", source: "npm:@bad/pkg", installs: 999 },
  { id: "owner/repo/deploy", name: "deploy", source: "owner/repo", installs: 1000 },
  { id: "solo/repo/build", name: "build", installs: 2000 },
], "https://skills.sh"), [
  { package: "solo/repo@build", installs: "2K installs", url: "https://skills.sh/solo/repo/build" },
  { package: "owner/repo@deploy", installs: "1K installs", url: "https://skills.sh/owner/repo/deploy" },
  { package: "owner/repo@testing", installs: "2 installs", url: "https://skills.sh/owner/repo/testing" },
]);

console.log("skill package utility checks passed");
```

- [ ] **Step 2: Run test to verify RED**

Run: `node scripts/test-skill-package-utils.mjs`

Expected: FAIL with module-not-found or missing-export error because `skill-package-utils.js` does not exist yet.

- [ ] **Step 3: Implement utility module**

Create `skill-package-utils.js` with exported functions:

```js
import { join } from "node:path";

export const ANSI_RE = /\x1B\[[0-9;]*m/g;
export const SKILL_PACKAGE_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$/;

export function getProjectSkillDirs(cwd) {
  return cwd ? [join(cwd, ".pi", "skills"), join(cwd, ".agents", "skills")] : [];
}

export function isValidSkillPackageSource(source) {
  return typeof source === "string" && SKILL_PACKAGE_RE.test(source.trim());
}

export function buildSkillInstallArgs(source, scope) {
  const trimmed = String(source || "").trim();
  if (!isValidSkillPackageSource(trimmed)) throw new Error("Invalid skill package. Expected owner/repo@skill.");
  const args = ["skills", "add", trimmed, "-y", "--agent", "pi"];
  if (scope !== "project") args.push("-g");
  return args;
}

export function parseInstallCount(installs) {
  const match = String(installs || "").match(/^([\d.,]+)([KMB])?\s+installs?$/);
  if (!match) return 0;
  const value = Number(match[1].replace(/,/g, ""));
  if (!Number.isFinite(value)) return 0;
  const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
  return value * multiplier;
}

export function formatInstalls(count) {
  if (!count || count <= 0) return "";
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
  if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
  return `${count} install${count === 1 ? "" : "s"}`;
}

export function parseSkillSearchOutput(raw, limit = 20) {
  const clean = String(raw || "").replace(ANSI_RE, "");
  const results = [];
  const lines = clean.split("\n");
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();
    const match = line.match(/^([\w.-]+\/[\w.-]+@[\w.-]+)\s+([\d.,]+[KMB]?\s+installs)$/);
    if (!match) continue;
    const urlLine = (lines[i + 1] || "").trim().replace(/^└\s*/, "");
    results.push({ package: match[1], installs: match[2], url: urlLine.startsWith("https://") ? urlLine : "" });
  }
  return results.slice(0, limit);
}

export function normalizeSkillApiResults(skills, apiBase) {
  return (Array.isArray(skills) ? skills : [])
    .map((skill) => {
      const name = skill?.name?.trim();
      const source = skill?.source?.trim();
      const slug = skill?.id?.trim();
      if (!name || (!source && !slug)) return null;
      let pkg = source ? `${source}@${name}` : "";
      if (!pkg && slug) {
        const parts = slug.split("/").filter(Boolean);
        if (parts.length >= 3) pkg = `${parts[0]}/${parts[1]}@${parts[2]}`;
      }
      if (!isValidSkillPackageSource(pkg)) return null;
      return { package: pkg, installs: formatInstalls(skill.installs), url: slug ? `${apiBase}/${slug}` : "" };
    })
    .filter(Boolean)
    .sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
}
```

- [ ] **Step 4: Run utility test to verify GREEN**

Run: `node scripts/test-skill-package-utils.mjs`

Expected: PASS with `skill package utility checks passed`.

- [ ] **Step 5: Refactor `index.ts` to use helpers**

Import helpers near the existing imports:

```ts
import {
  ANSI_RE,
  buildSkillInstallArgs,
  getProjectSkillDirs,
  normalizeSkillApiResults,
  parseSkillSearchOutput,
} from "./skill-package-utils.js";
```

Remove duplicated `ANSI_RE`, `SKILL_PACKAGE_RE`, `parseInstallCount`, `formatInstalls`, and `parseSkillSearchOutput` definitions from `index.ts` after equivalent behavior is covered by the utility test.

Update `getSkills(cwd)` to append project directories via `getProjectSkillDirs(cwd)`, preserving existing global directories.

Update `searchSkillPackages()` API mapping to call:

```ts
return normalizeSkillApiResults(data.skills || [], apiBase);
```

Update `installSkillPackage()` to call:

```ts
const args = buildSkillInstallArgs(source, scope);
```

- [ ] **Step 6: Run utility and type checks**

Run:

```bash
node scripts/test-skill-package-utils.mjs
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
```

Expected: both PASS.

- [ ] **Step 7: Commit utility extraction**

Run only if committing is requested by the user:

```bash
git add skill-package-utils.js index.ts scripts/test-skill-package-utils.mjs
git commit -m "test: cover skill package utilities"
```

---

## Chunk 2: Backend Bridge Security And Refresh

### Task 2: Restrict installs to current backend search results

**Tier:** reasoning

**Files:**
- Modify: `index.ts`
- Test: `scripts/test-skill-package-utils.mjs`

- [ ] **Step 1: Add failing allowlist smoke check**

Extend `scripts/test-skill-package-utils.mjs` with a source-level smoke check for the bridge policy:

```js
import { readFileSync } from "node:fs";
const backendSource = readFileSync("index.ts", "utf8");
assert.match(backendSource, /skillInstallAllowlist/);
assert.match(backendSource, /function isSkillInstallAllowed\(pkg: string\)/);
assert.match(backendSource, /isSkillInstallAllowed\(pkg\)/);
assert.match(backendSource, /case "search-skill-packages"[\s\S]*skillInstallAllowlist\.clear\(\)[\s\S]*await searchSkillPackages/);
assert.match(backendSource, /skillInstallAllowlist\.set\(result\.package/);
```

Run: `node scripts/test-skill-package-utils.mjs`

Expected: FAIL until backend maintains the allowlist.

- [ ] **Step 2: Add short-lived allowlist state**

Near other extension-level state in `index.ts`, add:

```ts
const SKILL_INSTALL_ALLOWLIST_TTL_MS = 10 * 60 * 1000;
const skillInstallAllowlist = new Map<string, number>();

function rememberSkillSearchResults(results: SkillSearchResult[]): void {
  const expiresAt = Date.now() + SKILL_INSTALL_ALLOWLIST_TTL_MS;
  for (const result of results) skillInstallAllowlist.set(result.package, expiresAt);
}

function isSkillInstallAllowed(pkg: string): boolean {
  const expiresAt = skillInstallAllowlist.get(pkg);
  if (!expiresAt) return false;
  if (expiresAt < Date.now()) {
    skillInstallAllowlist.delete(pkg);
    return false;
  }
  return true;
}
```

- [ ] **Step 3: Clear and remember search results**

In the `search-skill-packages` message case, clear stale allowlist entries before attempting the search, then remember only successful current results:

```ts
skillInstallAllowlist.clear();
const results = await searchSkillPackages(query, 20);
rememberSkillSearchResults(results);
sendToWindow({ type: "skill-search-results", query, results });
```

On search failure, leave the allowlist empty:

```ts
sendToWindow({ type: "skill-search-results", query: msg.query || "", results: [], error: String((err as Error).message || err) });
```

- [ ] **Step 4: Reject non-search install requests**

In the `install-skill-package` message case, before `installSkillPackage()`:

```ts
if (!isSkillInstallAllowed(pkg)) {
  throw new Error("Install package must be selected from current search results.");
}
```

This preserves the security boundary: the webview can request installation only for package IDs in the current backend search result set, with a TTL to prevent stale installs after a long delay.

- [ ] **Step 5: Refresh skills after successful install**

Keep successful install response:

```ts
const skills = getSkills(lastCtx?.cwd);
const extensions = getExtensions();
sendToWindow({ type: "skill-install-result", package: pkg, success: true, output: output.slice(-2000), skills, extensions });
```

- [ ] **Step 6: Run backend checks**

Run:

```bash
node scripts/test-skill-package-utils.mjs
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
```

Expected: both PASS.

- [ ] **Step 7: Commit backend bridge changes**

Run only if committing is requested by the user:

```bash
git add index.ts scripts/test-skill-package-utils.mjs
git commit -m "feat: restrict skill installs to search results"
```

---

## Chunk 3: Frontend Add Skill UI

### Task 3: Add frontend state and controls

**Tier:** standard

**Files:**
- Modify: `web/app.js`
- Test: `scripts/test-skills-view-state.mjs`

- [ ] **Step 1: Write failing frontend smoke test**

Create `scripts/test-skills-view-state.mjs`:

```js
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const source = readFileSync("web/app.js", "utf8");

assert.match(source, /skillAddOpen/);
assert.match(source, /btn-add-skill/);
assert.match(source, /search-skill-packages/);
assert.match(source, /install-skill-package/);
assert.match(source, /case "skill-search-results"/);
assert.match(source, /case "skill-install-result"/);
assert.match(source, /Searching/);
assert.match(source, /Installing/);
assert.match(source, /Installed/);
assert.match(source, /scope:\s*state\.skillInstallScope/);
assert.match(source, /state\.skillAddOpen\s*=\s*!state\.skillAddOpen/);
assert.match(source, /state\.skillSearchQuery\s*=\s*input\.value/);
assert.match(source, /state\.skillInstallScope\s*=\s*button\.dataset\.skillScope/);
assert.match(source, /\.pi\/skills\//);

console.log("skills view smoke checks passed");
```

This is a smoke test, not a full DOM behavior test. It guards against accidentally removing the required bridge/UI hooks in a project that currently does not have a browser test harness.

- [ ] **Step 2: Run test to verify RED**

Run: `node scripts/test-skills-view-state.mjs`

Expected: FAIL because the Add Skill UI and result message cases are not implemented.

- [ ] **Step 3: Add skill install state**

Extend `state` in `web/app.js`:

```js
skillAddOpen: false,
skillSearchQuery: "",
skillSearchResults: [],
skillSearchError: null,
skillSearching: false,
skillInstalling: null,
skillInstallError: null,
skillInstalledPackages: {},
skillInstallScope: "global",
```

- [ ] **Step 4: Add Add Skill button to header**

In `renderSkillsView()`, change the header action area from only Refresh to Add Skill + Refresh:

```html
<button id="btn-add-skill" ...>Add Skill</button>
<button id="btn-refresh-skills" ...>Refresh</button>
```

Keep styling consistent with existing compact buttons and `var(--accent)`.

- [ ] **Step 5: Render inline add/search panel**

Add a helper near `renderSkillsView()`:

```js
function renderSkillAddPanel() {
  // returns search input, Search button, global/project toggle,
  // install path hint, error text, and result cards
}
```

The panel must include:

- Search placeholder: `e.g. react, testing, deploy`.
- Scope buttons: `global` and `project`.
- Global install path hint: `~/.pi/agent/skills/`.
- Project install path hint: `${data.cwd || "current workspace"}/.pi/skills/`.
- Result cards with skill name, package source, install count, optional `skills.sh ↗` link, and Install button.

- [ ] **Step 6: Wire frontend event handlers**

After `messagesEl.innerHTML = html`, add listeners for:

```js
document.getElementById("btn-add-skill")
document.getElementById("skill-search-input")
document.getElementById("btn-search-skills")
document.querySelectorAll("[data-skill-scope]")
document.querySelectorAll("[data-install-skill-package]")
```

Add button behavior:

```js
state.skillAddOpen = !state.skillAddOpen;
state.skillInstallError = null;
state.skillSearchError = null;
renderSkillsView();
```

Input behavior:

```js
state.skillSearchQuery = input.value;
```

Scope behavior:

```js
state.skillInstallScope = button.dataset.skillScope === "project" ? "project" : "global";
state.skillInstallError = null;
renderSkillsView();
```

Search handler behavior:

```js
state.skillSearching = true;
state.skillSearchError = null;
state.skillSearchResults = [];
send({ type: "search-skill-packages", query: state.skillSearchQuery });
renderSkillsView();
```

Install handler behavior:

```js
state.skillInstalling = pkg;
state.skillInstallError = null;
send({
  type: "install-skill-package",
  package: pkg,
  scope: state.skillInstallScope,
});
renderSkillsView();
```

- [ ] **Step 7: Preserve existing skill click behavior**

Keep the existing `[data-skill]` click handler that sends:

```js
send({ type: "send-message", text: `Load the ${skillName} skill and tell me what it does.` });
```

Do not let Add Skill panel buttons trigger the skill-card click handler.

- [ ] **Step 8: Run frontend smoke test**

Run: `node scripts/test-skills-view-state.mjs`

Expected: PASS with `skills view smoke checks passed`.

- [ ] **Step 9: Commit frontend UI changes**

Run only if committing is requested by the user:

```bash
git add web/app.js scripts/test-skills-view-state.mjs
git commit -m "feat: add skill install UI"
```

---

## Chunk 4: Bridge Result Handling

### Task 4: Handle search and install responses

**Tier:** standard

**Files:**
- Modify: `web/app.js`
- Test: `scripts/test-skills-view-state.mjs`

- [ ] **Step 1: Add RED checks for result mutation**

Extend `scripts/test-skills-view-state.mjs`:

```js
assert.match(source, /state\.skillSearchResults\s*=\s*message\.results/);
assert.match(source, /state\.skillInstalledPackages\[message\.package\]/);
assert.match(source, /data\.skills\s*=\s*message\.skills/);
assert.match(source, /data\.extensions\s*=\s*message\.extensions\s*\|\|/);
```

Run: `node scripts/test-skills-view-state.mjs`

Expected: FAIL until result cases mutate state.

- [ ] **Step 2: Add `skill-search-results` case**

In `window.__desktopReceive(message)`, add:

```js
case "skill-search-results":
  state.skillSearching = false;
  state.skillSearchResults = message.results || [];
  state.skillSearchError = message.error || (state.skillSearchResults.length ? null : "No skills found");
  if (state.activeView === "skills") renderSkillsView();
  break;
```

- [ ] **Step 3: Add `skill-install-result` case**

In `window.__desktopReceive(message)`, add:

```js
case "skill-install-result":
  state.skillInstalling = null;
  if (message.success) {
    state.skillInstallError = null;
    if (message.package) state.skillInstalledPackages[message.package] = true;
    data.skills = message.skills || data.skills || [];
    data.extensions = message.extensions || data.extensions || [];
  } else {
    state.skillInstallError = message.error || "Install failed";
  }
  if (state.activeView === "skills") renderSkillsView();
  break;
```

- [ ] **Step 4: Keep install UX single-flight**

Disable all install buttons while `state.skillInstalling` is non-null.

Expected behavior:

- Before install: button says `Install`.
- Active install: button says `Installing…`.
- Successful package: button says `✓ Installed` and remains disabled.
- Failed install: error appears and buttons re-enable.

- [ ] **Step 5: Run frontend smoke test**

Run: `node scripts/test-skills-view-state.mjs`

Expected: PASS.

- [ ] **Step 6: Commit bridge handling changes**

Run only if committing is requested by the user:

```bash
git add web/app.js scripts/test-skills-view-state.mjs
git commit -m "feat: handle skill install bridge results"
```

---

## Chunk 5: Skill Discovery Compatibility

### Task 5: Keep skill discovery aligned with pi docs

**Tier:** standard

**Files:**
- Modify: `index.ts`
- Test: `scripts/test-skill-package-utils.mjs`

- [ ] **Step 1: Add project directory helper checks**

Use the `getProjectSkillDirs(cwd)` helper from `skill-package-utils.js` instead of relying on brittle `index.ts` source regexes. Extend `scripts/test-skill-package-utils.mjs` with behavior checks:

```js
assert.deepEqual(getProjectSkillDirs("/tmp/workspace"), [
  join("/tmp/workspace", ".pi", "skills"),
  join("/tmp/workspace", ".agents", "skills"),
]);
assert.equal(getProjectSkillDirs("/tmp/workspace").some((dir) => dir.includes(`${sep}.pi${sep}agent${sep}skills`)), false);
```

Run: `node scripts/test-skill-package-utils.mjs`

Expected: PASS if project discovery is already correct; if it fails, fix discovery before continuing.

- [ ] **Step 2: Support root `.md` skill files in `.pi/skills` if missing**

Pi discovers direct root `.md` files in `.pi/skills/`. Before changing discovery behavior, add a focused temporary-directory behavior test or split discovery path calculation into a pure helper that can be tested without touching the real home directory. If `getSkills(cwd)` only handles directories containing `SKILL.md`, add this focused follow-up implementation:

```ts
function addSkillFile(skills: { name: string; desc: string }[], filePath: string): void {
  if (!filePath.toLowerCase().endsWith(".md")) return;
  const name = basename(filePath, extname(filePath));
  if (!skills.find(s => s.name === name)) {
    skills.push({ name, desc: parseSkillDescription(filePath) });
  }
}
```

Then call it only for direct `.md` files in global `~/.pi/agent/skills/` and project `.pi/skills/`, not for `.agents/skills` root files.

- [ ] **Step 3: Preserve recursive `SKILL.md` directory discovery**

Do not remove existing directory-based discovery:

```ts
addSkillFromDir(skills, dir, entry)
```

Do not add broad recursive discovery in this feature unless a failing behavior test demonstrates it is required; keep the install workflow focused and avoid expanding discovery semantics beyond the current UI need.

- [ ] **Step 4: Run backend checks**

Run:

```bash
node scripts/test-skill-package-utils.mjs
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
```

Expected: both PASS.

- [ ] **Step 5: Commit discovery compatibility changes**

Run only if committing is requested by the user:

```bash
git add index.ts scripts/test-skill-package-utils.mjs
git commit -m "fix: align skill discovery with pi docs"
```

---

## Chunk 6: Package Scripts And Full Verification

### Task 6: Wire verification scripts

**Tier:** fast

**Files:**
- Modify: `package.json`
- Test: `npm run check`

- [ ] **Step 1: Run new checks directly before package wiring**

Run:

```bash
node scripts/test-skill-package-utils.mjs
node scripts/test-skills-view-state.mjs
```

Expected: both PASS before adding them to `npm run check`.

- [ ] **Step 2: Add scripts to `package.json`**

Add to `scripts`:

```json
"verify:skill-packages": "node scripts/test-skill-package-utils.mjs",
"verify:skills-view": "node scripts/test-skills-view-state.mjs"
```

- [ ] **Step 3: Include scripts in `check`**

Append to the existing `check` script:

```bash
&& npm run verify:skill-packages && npm run verify:skills-view
```

Keep existing `tsc`, `node --check`, and existing verification scripts intact.

- [ ] **Step 4: Run full verification**

Run:

```bash
npm run check
```

Expected: PASS.

- [ ] **Step 5: Commit verification wiring**

Run only if committing is requested by the user:

```bash
git add package.json
git commit -m "test: verify skill install workflow"
```

---

## Chunk 7: Manual Acceptance

### Task 7: Verify user workflow manually

**Tier:** standard

**Files:**
- No code changes unless bugs are found.

- [ ] **Step 1: Start pi desktop UI**

Run the normal local workflow, then open the desktop window with `/desktop` or the existing launcher.

Expected: Skills & Extensions page opens without console errors.

- [ ] **Step 2: Open Add Skill panel**

Click `Skills & Extensions` → `Add Skill`.

Expected:

- Search input appears.
- Scope selector defaults to `global`.
- Install path hint shows `~/.pi/agent/skills/`.

- [ ] **Step 3: Search for a known skill**

Search a term such as:

```text
testing
```

Expected:

- UI shows `Searching…` while pending.
- Results show skill name, package `owner/repo@skill`, install count, and optional skills.sh link.
- Empty results show `No skills found`.

- [ ] **Step 4: Install one global result**

Click `Install` on a result.

Expected:

- Button changes to `Installing…`.
- Other install buttons are disabled.
- Success changes the button to `✓ Installed`.
- Skills list refreshes and includes the newly installed skill if pi discovers it immediately.

- [ ] **Step 5: Verify project scope with care**

Switch scope to `project`, search, and install a non-conflicting test skill only if acceptable for the workspace.

Expected:

- Backend omits `-g`.
- Command runs with `cwd` set to the active workspace.
- Installed skill is discoverable from `.pi/skills/`; `.agents/skills/` remains a compatibility scan location but is not the expected `npx skills add --agent pi` project target.

- [ ] **Step 6: Verify rejected install behavior**

From the webview console or a targeted local probe, attempt `install-skill-package` for a valid-looking package that was not returned by the current search.

Expected:

- Backend rejects it with `Install package must be selected from current search results.`
- UI shows the error and does not mark the package installed.

- [ ] **Step 7: Verify search failure behavior**

Temporarily set `SKILLS_API_URL` to an invalid endpoint or test with network unavailable.

Expected:

- Search falls back to `npx skills find` when possible.
- Backend clears the install allowlist before the failed search result is returned.
- Errors render in the panel without breaking the skills page.
- Install errors do not mark packages installed.

---

## Security Notes

- Do not expose arbitrary shell command execution from the webview.
- Keep package validation to `owner/repo@skill` for this workflow.
- Reject install requests unless the package is in the current backend search result set.
- Do not pass user-controlled strings as shell fragments; use `spawn(command, args)` with argument arrays, and verify Windows `shell: true` behavior remains safe because package IDs are allowlisted and validated before spawning.
- Keep install output escaped in the frontend; never render command output as raw HTML.
- Do not auto-install from a search result without an explicit user click.

## Acceptance Criteria

- [ ] Skills & Extensions page has an `Add Skill` entry point.
- [ ] User can search skills from skills.sh through the extension backend.
- [ ] User can choose global or project install scope.
- [ ] User can install a selected `owner/repo@skill` package from the current backend search results.
- [ ] UI shows searching, no-results, installing, installed, and error states.
- [ ] Successful install refreshes `data.skills` and `data.extensions`.
- [ ] Project install/discovery references `.pi/skills/`, not `.pi/agent/skills/`.
- [ ] `npm run check` passes, including new verification scripts.
- [ ] No arbitrary command execution route is introduced.

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-07-08-add-skill-install.md`. Before implementation, use `superpowers:executing-plans` in this harness or `superpowers:subagent-driven-development` if subagents are available.
