nexus/.planning/milestones/v1.2.1-phases/19-adapter-aware-install-uninstall/19-02-PLAN.md
Mikkel Georgsen 6c4272ce85 [nexus] chore: migrate .planning/ from agent repo to nexus repo
Planning artifacts (milestones v1.0-v1.2.1, v1.3 queue, PROJECT.md,
STATE.md, config) now live alongside the code they describe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:42 +00:00

265 lines
12 KiB
Markdown

---
phase: 19-adapter-aware-install-uninstall
plan: "02"
type: execute
wave: 2
depends_on: ["19-01"]
files_modified:
- server/src/routes/skill-registry.ts
- server/src/routes/skill-registry-groups.ts
- server/src/app.ts
autonomous: true
requirements: [INST-01, INST-02, INST-03, INST-04, HERM-02]
must_haves:
truths:
- "Route handlers resolve agentSkillsDir from agentId via resolveAdapterSkillConfig, not from request body"
- "Install route accepts agentId in body and resolves the target directory server-side"
- "Uninstall route accepts agentId as query param and passes resolved dir to service"
- "Rollback route accepts agentId in body and resolves the target directory server-side"
- "Group assign/remove routes resolve dir from the agentId URL param, not from request body"
- "DELETE route for native skills returns 403"
- "app.ts passes db to both route factories"
artifacts:
- path: "server/src/routes/skill-registry.ts"
provides: "Adapter-aware install/uninstall/rollback routes with agentId resolution"
contains: "resolveSkillsDirForAgent"
- path: "server/src/routes/skill-registry-groups.ts"
provides: "Adapter-aware group assign/remove routes"
contains: "resolveSkillsDirForAgent"
- path: "server/src/app.ts"
provides: "db passed to skillRegistryRoutes and skillGroupRoutes"
contains: "skillRegistryRoutes(db)"
key_links:
- from: "server/src/routes/skill-registry.ts"
to: "server/src/services/agents.ts"
via: "agentService(db).getById for adapter type lookup"
pattern: "agentService.*getById"
- from: "server/src/routes/skill-registry.ts"
to: "@paperclipai/adapter-utils"
via: "resolveAdapterSkillConfig for path resolution"
pattern: "resolveAdapterSkillConfig"
- from: "server/src/app.ts"
to: "server/src/routes/skill-registry.ts"
via: "skillRegistryRoutes(db) factory call"
pattern: "skillRegistryRoutes\\(db\\)"
---
<objective>
Wire route handlers to resolve skill directories from agentId server-side using the Phase 18 adapter path resolver, replacing client-supplied agentSkillsDir in all request bodies.
Purpose: The UI should never compute filesystem paths. The server owns path resolution via the agent's adapter type. This plan connects the service changes from Plan 01 to the HTTP layer.
Output: Updated routes accepting agentId, app.ts passing db to route factories, native skill protection at route level.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-adapter-aware-install-uninstall/19-RESEARCH.md
@.planning/phases/18-adapter-path-resolver/18-01-SUMMARY.md
@.planning/phases/19-adapter-aware-install-uninstall/19-01-SUMMARY.md
Read these source files before modifying:
- server/src/routes/skill-registry.ts
- server/src/routes/skill-registry-groups.ts
- server/src/app.ts
- server/src/services/agents.ts (read-only — understand getById signature)
- packages/adapter-utils/src/index.ts (read-only — understand resolveAdapterSkillConfig export)
<interfaces>
<!-- From Phase 18 (adapter-utils) -->
resolveAdapterSkillConfig(adapterType: string): AdapterSkillConfig
returns: { skillDir: string | null, nativeSkillDir: string | null, format: string, supportsInstall: boolean, unsupportedReason?: string }
<!-- From Plan 01 (service layer updates) -->
install(skillId: string, agentSkillsDir: string): Promise<...>
uninstall(skillId: string, agentSkillsDir: string): Promise<void>
rollback(skillId: string, versionId: string, agentSkillsDir: string): Promise<...>
assignGroup(groupId: string, agentId: string, agentSkillsDir: string): Promise<...>
removeGroup(groupId: string, agentId: string, agentSkillsDir: string): Promise<...>
syncHermesNativeSkills(agentId: string): Promise<void>
listAgentSkills(agentId: string): Promise<Array<{ skillId: string; source: "managed" | "native"; installedAt: number }>>
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add resolveSkillsDirForAgent helper and update route factories</name>
<files>
server/src/routes/skill-registry.ts,
server/src/routes/skill-registry-groups.ts,
server/src/app.ts
</files>
<read_first>
server/src/routes/skill-registry.ts,
server/src/routes/skill-registry-groups.ts,
server/src/app.ts,
server/src/services/agents.ts
</read_first>
<action>
**Add shared helper** — either at the top of `skill-registry.ts` (and import in groups) or in a small shared file. The helper resolves agentId to a skill directory:
```typescript
import { agentService } from "../services/agents.js";
import { resolveAdapterSkillConfig } from "@paperclipai/adapter-utils";
import os from "node:os";
import type { Db } from "@paperclipai/db";
async function resolveSkillsDirForAgent(db: Db, agentId: string): Promise<string> {
const agent = await agentService(db).getById(agentId);
if (!agent) throw Object.assign(new Error("Agent not found"), { status: 404 });
const config = resolveAdapterSkillConfig(agent.adapterType);
if (!config.supportsInstall || !config.skillDir) {
throw Object.assign(
new Error(config.unsupportedReason ?? "Adapter does not support skill install"),
{ status: 422 },
);
}
return config.skillDir.replace(/^~/, os.homedir());
}
```
**skill-registry.ts** — Change factory signature to `skillRegistryRoutes(db: Db): Router`:
1. **Install route** (`POST .../install`):
- Change body from `{ agentSkillsDir: string }` to `{ agentId: string }`
- Validate: `if (!agentId) return res.status(400).json({ error: "agentId required" });`
- Resolve: `const agentSkillsDir = await resolveSkillsDirForAgent(db, agentId);`
- Pass resolved dir to `svc.install(skillId, agentSkillsDir)`
- Wrap in try/catch — if error has `.status`, use it; otherwise 500
2. **Uninstall route** (`DELETE ...`):
- Add `req.query.agentId` (query param for DELETE, per HTTP semantics)
- Validate: `if (!agentId) return res.status(400).json({ error: "agentId required" });`
- Resolve dir, pass to `svc.uninstall(skillId, agentSkillsDir)`
3. **Rollback route** (`POST .../rollback`):
- Change body from `{ versionId, agentSkillsDir }` to `{ versionId, agentId }`
- Resolve dir, pass to `svc.rollback(skillId, versionId, agentSkillsDir)`
4. **List agent skills route** (`GET .../agents/:agentId/skills`):
- Look up agent to check if adapter is hermes_local
- If hermes: call `syncHermesNativeSkills(agentId)` before listing
- Return the full object array (not string[])
5. **Native skill protection** (HERM-02):
- In DELETE route, before calling uninstall, check if the skill's `agentSkills` row has `source === 'native'`
- If native: `return res.status(403).json({ error: "Cannot remove native skills" });`
**skill-registry-groups.ts** — Change factory signature to `skillGroupRoutes(db: Db): Router`:
1. **Assign group route** (`POST .../groups`):
- Remove `agentSkillsDir` from body — the `agentId` is already in the URL param
- Resolve: `const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId);`
- Pass resolved dir to `svc.assignGroup(..., agentSkillsDir)`
2. **Remove group route** (`DELETE .../groups/:groupId`):
- Remove `agentSkillsDir` from body
- Resolve dir from URL param `agentId`
- Pass resolved dir to `svc.removeGroup(..., agentSkillsDir)`
**app.ts** — Update mount calls:
- `api.use(skillRegistryRoutes(db));` (was: `skillRegistryRoutes()`)
- `api.use(skillGroupRoutes(db));` (was: `skillGroupRoutes()`)
- Add `Db` import if not present
**Error handling pattern** for resolveSkillsDirForAgent errors in routes:
```typescript
try {
const agentSkillsDir = await resolveSkillsDirForAgent(db, agentId);
// ... proceed
} catch (err: any) {
const status = err.status ?? 500;
return res.status(status).json({ error: err.message });
}
```
</action>
<verify>
<automated>cd nexus && pnpm tsc --noEmit --project server/tsconfig.json 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- skillRegistryRoutes accepts db: Db parameter
- skillGroupRoutes accepts db: Db parameter
- app.ts passes db to both route factories
- Install route reads agentId from body, not agentSkillsDir
- Uninstall route reads agentId from query params
- Rollback route reads agentId from body, not agentSkillsDir
- Group routes resolve dir from URL agentId param
- resolveSkillsDirForAgent helper exists and uses resolveAdapterSkillConfig
- DELETE route checks source=native and returns 403
- List agent skills route calls syncHermesNativeSkills for hermes agents
- No reference to agentSkillsDir in request bodies
- No reference to defaultSkillsDir anywhere
- TypeScript compiles without errors
</acceptance_criteria>
<done>All route handlers resolve skill directories server-side from agentId, app.ts wires db to both factories, native skill DELETE returns 403</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Route-level integration tests</name>
<files>
server/src/__tests__/skill-registry-routes-adapter.test.ts
</files>
<read_first>
server/src/__tests__/skill-registry.test.ts (if exists — for supertest patterns),
server/src/routes/skill-registry.ts
</read_first>
<behavior>
- Test: POST install with agentId resolves correct dir and succeeds
- Test: POST install without agentId returns 400
- Test: POST install with unknown agentId returns 404
- Test: POST install with unsupported adapter returns 422
- Test: DELETE uninstall with agentId query param resolves dir and removes files
- Test: DELETE uninstall of native skill returns 403
- Test: POST rollback with agentId resolves correct dir
- Test: GET agent skills for hermes agent calls sync and returns objects with source
- Test: POST assign group resolves dir from URL agentId
</behavior>
<action>
Create `skill-registry-routes-adapter.test.ts` using supertest (following existing test patterns in `server/src/__tests__/`).
Mock `agentService(db).getById` to return agents with different adapter types. Mock `resolveAdapterSkillConfig` to return known configs. Mock filesystem operations.
Test the route-level behavior: correct status codes, correct error messages, correct delegation to service methods with resolved paths.
Run: `cd nexus && pnpm --filter @paperclipai/server exec vitest run src/__tests__/skill-registry-routes-adapter.test.ts`
</action>
<verify>
<automated>cd nexus && pnpm --filter @paperclipai/server exec vitest run src/__tests__/skill-registry-routes-adapter.test.ts</automated>
</verify>
<acceptance_criteria>
- Test file exists with supertest-based route tests
- Tests cover: 400 (missing agentId), 404 (unknown agent), 422 (unsupported adapter), 403 (native skill delete)
- Tests verify install/uninstall/rollback/group routes accept agentId not agentSkillsDir
- All tests pass
</acceptance_criteria>
<done>Route-level tests verify adapter-aware path resolution and error handling for all INST requirements plus HERM-02 native protection</done>
</task>
</tasks>
<verification>
- TypeScript compiles: `cd nexus && pnpm tsc --noEmit --project server/tsconfig.json`
- Route tests pass: `cd nexus && pnpm --filter @paperclipai/server exec vitest run src/__tests__/skill-registry-routes-adapter.test.ts`
- No agentSkillsDir in request bodies: grep should find NO matches for `agentSkillsDir` in route handler body parsing
- db passed to factories: grep for `skillRegistryRoutes(db)` in app.ts
</verification>
<success_criteria>
- All route handlers accept agentId and resolve paths server-side
- Native skill deletion blocked at route layer with 403
- app.ts passes db to both route factories
- Route tests verify all error paths and happy paths
</success_criteria>
<output>
After completion, create `.planning/phases/19-adapter-aware-install-uninstall/19-02-SUMMARY.md`
</output>