578 lines
28 KiB
Markdown
578 lines
28 KiB
Markdown
# Pluggable Tool-Adapter Registry Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Collapse the 7 hardcoded external tools (duplicated across 8 structures) into one derived `BUILTIN_TOOL_MANIFESTS` source of truth + an agent registry, and let a self-hosted operator add a PATH-based CLI runtime via `~/.waggle/adapters/*.json` (zod-validated, no `require()`).
|
||
|
||
**Architecture:** Declarative `ToolManifest` data lives in `@waggle/shared` (the web bundle + sidecar both read it); the cohort/name/pointer consts derive from it. The agent owns behavior: a `tool-registry.ts` that merges built-in manifests with loaded third-party ones, a `tool-manifest-loader.ts` (declarative, data-only), and a `detectInstalledTools` loop driven by the registry. The 3 desktop candidate-path resolvers stay built-in code (escape hatch); third-party adapters are PATH-only.
|
||
|
||
**Tech Stack:** TypeScript, zod, Vitest. No new runtime deps.
|
||
|
||
## Global Constraints
|
||
|
||
- **`SUPPORTED_TOOLS` stays the `as const` literal anchor** — `ToolId` (the union) is unchanged → zero blast radius on the ~5 `Record<ToolId,…>` consumers.
|
||
- **Derived consts must equal today's values** for the 7 built-ins (regression-lock).
|
||
- **Third-party adapters: `detect.kind:'path'` only.** `'candidates'` is built-in-code-only (the escape hatch).
|
||
- **Loader is data-only:** zod + safe-string refinement (no `; | & $ \` ( )`, no `..`, no path separators in `binaryName`); never `require()`/eval; never throws into detection; missing dir → `[]`.
|
||
- **Built-in ids win** over any third-party manifest claiming the same id.
|
||
- **Registry = pure data (no functions);** the candidate resolvers stay in `tool-detection.ts` keyed by id (no `registry → detection` cycle: loader ← registry ← detection).
|
||
- **Gates:** `tsc` 0 (shared/agent/server/web); existing `tool-detection`/`tool-launcher`/`tools-routes*`/`LauncherApp` suites stay green; new units RED→GREEN.
|
||
- **Fast-follow (NOT in v1):** applying a third-party `promptArgTemplate` (the prompt-arg path is web-side `apps/web/src/lib/launcher-prompt-args.ts`; the field is captured but unwired).
|
||
|
||
## Built-in manifest data (the canonical 7 — used verbatim in Task 1)
|
||
|
||
| id | displayName | launchable | hookCapable | hookPointer | detect |
|
||
|---|---|---|---|---|---|
|
||
| claude-code | Claude Code | yes | yes | `.claude/hive-mind-install.json` | `path` binary `claude` |
|
||
| claude-desktop | Claude Desktop | yes | **no** | `.config/Claude/hive-mind-install.json` | `candidates` |
|
||
| cursor | Cursor | yes | yes | `.cursor/hive-mind-install.json` | `candidates` |
|
||
| codex | Codex CLI | yes | yes | `.codex/hive-mind-install.json` | `path` binary `codex` |
|
||
| codex-desktop | Codex Desktop | yes | yes | `.codex/hive-mind-install.json` | `candidates` |
|
||
| hermes | Hermes Agent | yes | yes | `.hermes/hive-mind-install.json` | `path` binary `hermes` |
|
||
| openclaw | OpenClaw | yes | yes | `.openclaw/hive-mind-install.json` | `path` binary `openclaw` |
|
||
|
||
---
|
||
|
||
### Task 1: `shared` — `ToolManifest` + `BUILTIN_TOOL_MANIFESTS` + derived consts
|
||
|
||
**Files:**
|
||
- Modify: `packages/shared/src/tool-detection.ts`
|
||
- Test: `packages/shared/tests/tool-manifests.test.ts` (new; if `packages/shared/tests` doesn't exist, place under the shared package's test glob — check `packages/shared` for an existing `*.test.ts` to mirror the location)
|
||
|
||
**Interfaces:**
|
||
- Produces: `ToolDetectSpec`, `ToolManifest`, `BUILTIN_TOOL_MANIFESTS: readonly ToolManifest[]`, and **re-derived** `TOOL_DISPLAY_NAMES` / `LAUNCH_COHORT`. `SUPPORTED_TOOLS` / `ToolId` unchanged.
|
||
|
||
- [ ] **Step 1: Write the failing test** — `packages/shared/tests/tool-manifests.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
BUILTIN_TOOL_MANIFESTS, SUPPORTED_TOOLS, LAUNCH_COHORT, TOOL_DISPLAY_NAMES,
|
||
} from '../src/tool-detection.js';
|
||
|
||
describe('BUILTIN_TOOL_MANIFESTS', () => {
|
||
it('has one manifest per supported tool, ids matching SUPPORTED_TOOLS', () => {
|
||
expect(BUILTIN_TOOL_MANIFESTS.map((m) => m.id).sort()).toEqual([...SUPPORTED_TOOLS].sort());
|
||
});
|
||
it('marks every built-in as builtin:true and launchable', () => {
|
||
for (const m of BUILTIN_TOOL_MANIFESTS) { expect(m.builtin).toBe(true); expect(m.launchable).toBe(true); }
|
||
});
|
||
it('claude-desktop is the only non-hook-capable tool', () => {
|
||
const nonHook = BUILTIN_TOOL_MANIFESTS.filter((m) => !m.hookCapable).map((m) => m.id);
|
||
expect(nonHook).toEqual(['claude-desktop']);
|
||
});
|
||
it('derives TOOL_DISPLAY_NAMES + LAUNCH_COHORT from the manifests (unchanged values)', () => {
|
||
expect(TOOL_DISPLAY_NAMES['claude-code']).toBe('Claude Code');
|
||
expect(TOOL_DISPLAY_NAMES['codex']).toBe('Codex CLI');
|
||
expect([...LAUNCH_COHORT].sort()).toEqual([...SUPPORTED_TOOLS].sort());
|
||
});
|
||
it('claude-code detects by PATH binary "claude" (not its id)', () => {
|
||
const cc = BUILTIN_TOOL_MANIFESTS.find((m) => m.id === 'claude-code')!;
|
||
expect(cc.detect).toEqual({ kind: 'path', binaryName: 'claude' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run packages/shared/tests/tool-manifests.test.ts`
|
||
Expected: FAIL — `BUILTIN_TOOL_MANIFESTS` not exported.
|
||
|
||
- [ ] **Step 3: Implement** in `packages/shared/src/tool-detection.ts` — keep `SUPPORTED_TOOLS`/`ToolId`/`DetectedTool`/`ToolDetectionResult` exactly as-is. Add the types + manifests, and REPLACE the hand-authored `LAUNCH_COHORT` and `TOOL_DISPLAY_NAMES` with derived versions:
|
||
|
||
```ts
|
||
export type ToolDetectSpec =
|
||
| { kind: 'path'; binaryName: string }
|
||
| { kind: 'candidates' };
|
||
|
||
export interface ToolManifest {
|
||
id: string;
|
||
displayName: string;
|
||
launchable: boolean;
|
||
hookCapable: boolean;
|
||
hookPointer: string;
|
||
detect: ToolDetectSpec;
|
||
/** Declarative inline-prompt arg template for THIRD-PARTY path adapters (e.g.
|
||
* ['--print', '{prompt}']). Built-ins keep their logic in launcher-prompt-args.ts.
|
||
* Captured in v1; application is a documented fast-follow. */
|
||
promptArgTemplate?: string[];
|
||
/** true = first-party; false/absent = loaded third-party. */
|
||
builtin?: boolean;
|
||
}
|
||
|
||
export const BUILTIN_TOOL_MANIFESTS: readonly ToolManifest[] = [
|
||
{ id: 'claude-code', displayName: 'Claude Code', launchable: true, hookCapable: true, hookPointer: '.claude/hive-mind-install.json', detect: { kind: 'path', binaryName: 'claude' }, builtin: true },
|
||
{ id: 'claude-desktop', displayName: 'Claude Desktop', launchable: true, hookCapable: false, hookPointer: '.config/Claude/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
|
||
{ id: 'cursor', displayName: 'Cursor', launchable: true, hookCapable: true, hookPointer: '.cursor/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
|
||
{ id: 'codex', displayName: 'Codex CLI', launchable: true, hookCapable: true, hookPointer: '.codex/hive-mind-install.json', detect: { kind: 'path', binaryName: 'codex' }, builtin: true },
|
||
{ id: 'codex-desktop', displayName: 'Codex Desktop', launchable: true, hookCapable: true, hookPointer: '.codex/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
|
||
{ id: 'hermes', displayName: 'Hermes Agent', launchable: true, hookCapable: true, hookPointer: '.hermes/hive-mind-install.json', detect: { kind: 'path', binaryName: 'hermes' }, builtin: true },
|
||
{ id: 'openclaw', displayName: 'OpenClaw', launchable: true, hookCapable: true, hookPointer: '.openclaw/hive-mind-install.json', detect: { kind: 'path', binaryName: 'openclaw' }, builtin: true },
|
||
] as const;
|
||
|
||
// Derived from the manifests (single source of truth). SUPPORTED_TOOLS stays the
|
||
// type anchor above; these keep their ToolId-typed shapes via the cast.
|
||
export const TOOL_DISPLAY_NAMES = Object.fromEntries(
|
||
BUILTIN_TOOL_MANIFESTS.map((m) => [m.id, m.displayName]),
|
||
) as Record<ToolId, string>;
|
||
|
||
export const LAUNCH_COHORT: readonly ToolId[] =
|
||
BUILTIN_TOOL_MANIFESTS.filter((m) => m.launchable).map((m) => m.id as ToolId);
|
||
```
|
||
|
||
(Delete the old literal `LAUNCH_COHORT` and `TOOL_DISPLAY_NAMES` blocks.)
|
||
|
||
- [ ] **Step 4: Run test + typecheck**
|
||
|
||
Run: `npx vitest run packages/shared/tests/tool-manifests.test.ts && npx tsc --noEmit -p packages/shared/tsconfig.json`
|
||
Expected: PASS + tsc 0.
|
||
|
||
- [ ] **Step 5: Build shared dist + commit** (downstream tsc reads dist)
|
||
|
||
```bash
|
||
npx tsc -b packages/shared
|
||
git add packages/shared/src/tool-detection.ts packages/shared/tests/tool-manifests.test.ts
|
||
git commit -m "feat(shared): BUILTIN_TOOL_MANIFESTS source of truth + derived consts (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `agent` — declarative manifest loader
|
||
|
||
**Files:**
|
||
- Create: `packages/agent/src/tool-manifest-loader.ts`
|
||
- Test: `packages/agent/tests/tool-manifest-loader.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ToolManifest` (Task 1).
|
||
- Produces: `loadThirdPartyManifests(deps?: ManifestLoaderDeps): ToolManifest[]`; `ManifestLoaderDeps { dir?: string; readDir?: (dir: string) => string[]; readFile?: (p: string) => string }`.
|
||
|
||
- [ ] **Step 1: Write the failing test** — `packages/agent/tests/tool-manifest-loader.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { loadThirdPartyManifests } from '../src/tool-manifest-loader.js';
|
||
|
||
function deps(files: Record<string, unknown>) {
|
||
return {
|
||
dir: '/fake',
|
||
readDir: () => Object.keys(files),
|
||
readFile: (p: string) => JSON.stringify(files[p.split('/').pop()!]),
|
||
};
|
||
}
|
||
|
||
describe('loadThirdPartyManifests', () => {
|
||
it('loads a valid PATH manifest, stamped builtin:false', () => {
|
||
const out = loadThirdPartyManifests(deps({
|
||
'foo.json': { id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } },
|
||
}));
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0]).toMatchObject({ id: 'foo-cli', builtin: false, detect: { kind: 'path', binaryName: 'foo' } });
|
||
});
|
||
|
||
it('rejects detect.kind:candidates (code-only strategy)', () => {
|
||
const out = loadThirdPartyManifests(deps({
|
||
'bad.json': { id: 'bad', displayName: 'B', launchable: true, hookCapable: false, hookPointer: '.b/hm.json', detect: { kind: 'candidates' } },
|
||
}));
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('rejects shell-metachar / traversal in fields', () => {
|
||
const out = loadThirdPartyManifests(deps({
|
||
'evil.json': { id: 'evil', displayName: 'E', launchable: true, hookCapable: false, hookPointer: '../../etc/passwd', detect: { kind: 'path', binaryName: 'foo; rm -rf /' } },
|
||
}));
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
it('returns [] when the dir is missing (readDir throws)', () => {
|
||
expect(loadThirdPartyManifests({ dir: '/none', readDir: () => { throw new Error('ENOENT'); }, readFile: () => '' })).toEqual([]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-manifest-loader.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement** `packages/agent/src/tool-manifest-loader.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* AI-OS #5 — declarative loader for third-party tool adapters from
|
||
* ~/.waggle/adapters/*.json. Data only: zod-validated, safe-string-refined,
|
||
* PATH-detection only, never require()/eval. Never throws into detection.
|
||
*/
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import fs from 'node:fs';
|
||
import { z } from 'zod';
|
||
import type { ToolManifest } from '@waggle/shared';
|
||
|
||
// No shell metacharacters, no path traversal, non-empty, bounded.
|
||
const SAFE = /^[A-Za-z0-9._/\\-]+$/;
|
||
const safe = (max: number) =>
|
||
z.string().min(1).max(max).refine((s) => SAFE.test(s) && !s.includes('..'), 'unsafe string');
|
||
|
||
const ManifestSchema = z.object({
|
||
id: safe(64),
|
||
displayName: z.string().min(1).max(80),
|
||
launchable: z.boolean(),
|
||
hookCapable: z.boolean(),
|
||
hookPointer: safe(256),
|
||
detect: z.object({ kind: z.literal('path'), binaryName: safe(128).refine((s) => !/[\\/]/.test(s), 'binaryName has a path separator') }),
|
||
promptArgTemplate: z.array(z.string().max(256)).max(20).optional(),
|
||
});
|
||
|
||
export interface ManifestLoaderDeps {
|
||
dir?: string;
|
||
readDir?: (dir: string) => string[];
|
||
readFile?: (p: string) => string;
|
||
}
|
||
|
||
export function loadThirdPartyManifests(deps: ManifestLoaderDeps = {}): ToolManifest[] {
|
||
const dir = deps.dir ?? path.join(os.homedir(), '.waggle', 'adapters');
|
||
const readDir = deps.readDir ?? ((d) => fs.readdirSync(d));
|
||
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
|
||
let names: string[];
|
||
try { names = readDir(dir).filter((n) => n.endsWith('.json')); } catch { return []; }
|
||
const out: ToolManifest[] = [];
|
||
for (const name of names) {
|
||
try {
|
||
const parsed = ManifestSchema.safeParse(JSON.parse(readFile(path.join(dir, name))));
|
||
if (parsed.success) out.push({ ...parsed.data, builtin: false });
|
||
} catch { /* skip malformed file */ }
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-manifest-loader.test.ts`
|
||
Expected: PASS (4 cases).
|
||
|
||
- [ ] **Step 5: Commit** (export wired in Task 3's commit)
|
||
|
||
```bash
|
||
git add packages/agent/src/tool-manifest-loader.ts packages/agent/tests/tool-manifest-loader.test.ts
|
||
git commit -m "feat(agent): declarative third-party tool-manifest loader (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `agent` — `getToolRegistry()`
|
||
|
||
**Files:**
|
||
- Create: `packages/agent/src/tool-registry.ts`
|
||
- Modify: `packages/agent/src/index.ts` (exports)
|
||
- Test: `packages/agent/tests/tool-registry.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BUILTIN_TOOL_MANIFESTS` (Task 1), `loadThirdPartyManifests` (Task 2).
|
||
- Produces: `getToolRegistry(deps?: ManifestLoaderDeps): ToolManifest[]` (built-ins + loaded third-party; built-in ids win).
|
||
|
||
- [ ] **Step 1: Write the failing test** — `packages/agent/tests/tool-registry.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { getToolRegistry } from '../src/tool-registry.js';
|
||
import { SUPPORTED_TOOLS } from '@waggle/shared';
|
||
|
||
const fakeLoaderDeps = (manifests: unknown[]) => ({
|
||
dir: '/fake',
|
||
readDir: () => manifests.map((_, i) => `m${i}.json`),
|
||
readFile: (p: string) => JSON.stringify(manifests[Number(p.match(/m(\d+)\.json/)![1])]),
|
||
});
|
||
|
||
describe('getToolRegistry', () => {
|
||
it('includes all built-in tools when no third-party present', () => {
|
||
const ids = getToolRegistry({ dir: '/none', readDir: () => { throw new Error('ENOENT'); }, readFile: () => '' }).map((m) => m.id);
|
||
expect(ids.sort()).toEqual([...SUPPORTED_TOOLS].sort());
|
||
});
|
||
|
||
it('merges a valid third-party PATH adapter', () => {
|
||
const reg = getToolRegistry(fakeLoaderDeps([
|
||
{ id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } },
|
||
]));
|
||
expect(reg.find((m) => m.id === 'foo-cli')).toMatchObject({ builtin: false });
|
||
expect(reg.length).toBe(SUPPORTED_TOOLS.length + 1);
|
||
});
|
||
|
||
it('built-in id wins a third-party collision', () => {
|
||
const reg = getToolRegistry(fakeLoaderDeps([
|
||
{ id: 'claude-code', displayName: 'HIJACK', launchable: true, hookCapable: false, hookPointer: '.x/hm.json', detect: { kind: 'path', binaryName: 'x' } },
|
||
]));
|
||
const cc = reg.filter((m) => m.id === 'claude-code');
|
||
expect(cc).toHaveLength(1);
|
||
expect(cc[0].displayName).toBe('Claude Code');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-registry.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Implement** `packages/agent/src/tool-registry.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* AI-OS #5 — the tool-adapter registry. Pure data: the built-in manifests
|
||
* (source of truth in @waggle/shared) merged with validated third-party
|
||
* manifests from the loader. Built-in ids always win a collision so a
|
||
* third-party file can never hijack a first-party tool.
|
||
*/
|
||
import { BUILTIN_TOOL_MANIFESTS, type ToolManifest } from '@waggle/shared';
|
||
import { loadThirdPartyManifests, type ManifestLoaderDeps } from './tool-manifest-loader.js';
|
||
|
||
export function getToolRegistry(deps?: ManifestLoaderDeps): ToolManifest[] {
|
||
const builtins = [...BUILTIN_TOOL_MANIFESTS];
|
||
const builtinIds = new Set(builtins.map((m) => m.id));
|
||
const thirdParty = loadThirdPartyManifests(deps).filter((m) => !builtinIds.has(m.id));
|
||
return [...builtins, ...thirdParty];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Export + run** — add to `packages/agent/src/index.ts` (after the existing tool exports):
|
||
|
||
```ts
|
||
export { getToolRegistry } from './tool-registry.js';
|
||
export { loadThirdPartyManifests, type ManifestLoaderDeps } from './tool-manifest-loader.js';
|
||
```
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-registry.test.ts`
|
||
Expected: PASS (3 cases).
|
||
|
||
- [ ] **Step 5: Typecheck + commit**
|
||
|
||
```bash
|
||
npx tsc --noEmit -p packages/agent/tsconfig.json
|
||
git add packages/agent/src/tool-registry.ts packages/agent/src/index.ts
|
||
git commit -m "feat(agent): getToolRegistry — built-ins + third-party, built-in wins (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `agent` — drive `detectInstalledTools` from the registry
|
||
|
||
**Files:**
|
||
- Modify: `packages/agent/src/tool-detection.ts` (the `detectInstalledTools` orchestrator + remove per-tool wrappers + `detectorsById`; keep `detectByPath`/`detectByCandidates`/`probeHooks`/candidate-path helpers)
|
||
- Test: `packages/agent/tests/tool-detection.test.ts` (existing — must stay green; add 1 case for a third-party path adapter)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getToolRegistry` (Task 3), `BUILTIN_TOOL_MANIFESTS` (for the candidate-resolver keys).
|
||
|
||
- [ ] **Step 1: Read** the existing `tool-detection.test.ts` to confirm the injected deps (`pathFromEnv`, `exists`, `execVersion`) and that it drives `detectInstalledTools(opts)`. The refactor must keep those exact seams.
|
||
|
||
- [ ] **Step 2: Write the failing test** — append to `tool-detection.test.ts` (mirror its existing `detectInstalledTools({...injected deps...})` setup; add a manifest-loader dep so a third-party adapter is present):
|
||
|
||
```ts
|
||
it('detects a third-party PATH adapter from the registry', async () => {
|
||
const result = await detectInstalledTools({
|
||
platform: 'linux',
|
||
pathFromEnv: async (bin: string) => (bin === 'foo' ? '/usr/bin/foo' : null),
|
||
exists: async (p: string) => p === '/usr/bin/foo',
|
||
execVersion: async () => '1.0.0',
|
||
// NEW: inject the manifest-loader deps so the registry includes a 3rd-party tool
|
||
manifestLoader: {
|
||
dir: '/fake',
|
||
readDir: () => ['foo.json'],
|
||
readFile: () => JSON.stringify({ id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } }),
|
||
},
|
||
});
|
||
const foo = result.tools.find((t) => t.id === 'foo-cli');
|
||
expect(foo?.installed).toBe(true);
|
||
expect(foo?.installedPath).toBe('/usr/bin/foo');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-detection.test.ts -t "third-party"`
|
||
Expected: FAIL — registry not wired; `manifestLoader` dep unknown.
|
||
|
||
- [ ] **Step 4: Implement** in `packages/agent/src/tool-detection.ts`:
|
||
|
||
(a) Imports — add `getToolRegistry` + the manifest type, and `BUILTIN_TOOL_MANIFESTS` is already imported via shared (it imports `SUPPORTED_TOOLS`/`TOOL_DISPLAY_NAMES`):
|
||
|
||
```ts
|
||
import { getToolRegistry } from './tool-registry.js';
|
||
import type { ManifestLoaderDeps } from './tool-manifest-loader.js';
|
||
import type { ToolManifest } from '@waggle/shared';
|
||
```
|
||
|
||
(b) A candidate-resolver map keyed by built-in id (the escape hatch — the 3 desktop tools). Place it near the candidate-path helpers:
|
||
|
||
```ts
|
||
const CANDIDATE_RESOLVERS: Record<string, (deps: ResolvedDeps) => string[]> = {
|
||
'cursor': cursorCandidatePaths,
|
||
'claude-desktop': claudeDesktopCandidatePaths,
|
||
'codex-desktop': codexDesktopCandidatePaths,
|
||
};
|
||
```
|
||
|
||
(c) A manifest-driven single detector replacing the per-tool wrappers:
|
||
|
||
```ts
|
||
async function detectFromManifest(m: ToolManifest, deps: ResolvedDeps): Promise<DetectedTool> {
|
||
if (m.detect.kind === 'path') {
|
||
return detectByPath(m.id as ToolId, m.detect.binaryName, deps, m.hookPointer, m.displayName);
|
||
}
|
||
const resolver = CANDIDATE_RESOLVERS[m.id];
|
||
const candidates = resolver ? resolver(deps) : [];
|
||
return detectByCandidates(m.id as ToolId, candidates, deps, /* withVersion */ false, m.hookPointer, m.displayName);
|
||
}
|
||
```
|
||
|
||
(d) Generalize `detectByPath` / `detectByCandidates` / `probeHooks` to take `hookPointer` + `displayName` as params instead of reading `TOOL_DISPLAY_NAMES[id]` / `HOOK_POINTER_BY_TOOL[id]` (so third-party ids work). Change their signatures to accept `hookPointer: string, displayName: string`, and have `probeHooks(hookPointer, deps)` use the passed pointer. Keep `HOOK_POINTER_BY_TOOL` as a derived export for back-compat:
|
||
|
||
```ts
|
||
// derived from the manifests (was a hand-authored Record)
|
||
export const HOOK_POINTER_BY_TOOL = Object.fromEntries(
|
||
BUILTIN_TOOL_MANIFESTS.map((m) => [m.id, m.hookPointer]),
|
||
) as Record<ToolId, string>;
|
||
```
|
||
|
||
(Import `BUILTIN_TOOL_MANIFESTS` from `@waggle/shared` for this.)
|
||
|
||
(e) Replace `detectInstalledTools`'s body:
|
||
|
||
```ts
|
||
export async function detectInstalledTools(
|
||
opts: ToolDetectionDeps & { manifestLoader?: ManifestLoaderDeps } = {},
|
||
): Promise<ToolDetectionResult> {
|
||
const deps = resolveDeps(opts);
|
||
const registry = getToolRegistry(opts.manifestLoader);
|
||
const tools = await Promise.all(registry.map((m) => detectFromManifest(m, deps)));
|
||
return { platform: deps.platform, detectedAt: new Date().toISOString(), tools };
|
||
}
|
||
```
|
||
|
||
(f) Delete `detectClaudeCode`, `detectCursor`, `detectClaudeDesktop`, `detectCodex`, `detectHermes`, `detectOpenClaw`, `detectCodexDesktop`, the old `detectorsById`, and the old hand-authored `HOOK_POINTER_BY_TOOL` block. Keep `detectByPath`, `detectByCandidates`, `probeHooks`, and the 3 `*CandidatePaths` helpers.
|
||
|
||
> Note: `detectByPath` currently hardcodes `pathFromEnv('claude')` only in the inline claude-code path — the generic `detectByPath` already takes `binaryName`, so claude-code now flows through it with `binaryName: 'claude'` from its manifest. Drop the special-case inline detector.
|
||
|
||
- [ ] **Step 5: Run the FULL existing suite + new case**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-detection.test.ts`
|
||
Expected: PASS — all existing 7-tool detection assertions + the new third-party case.
|
||
|
||
- [ ] **Step 6: Typecheck + commit**
|
||
|
||
```bash
|
||
npx tsc --noEmit -p packages/agent/tsconfig.json
|
||
git add packages/agent/src/tool-detection.ts packages/agent/tests/tool-detection.test.ts
|
||
git commit -m "feat(agent): registry-driven detectInstalledTools (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `agent` — derive launcher cohorts from the registry
|
||
|
||
**Files:**
|
||
- Modify: `packages/agent/src/tool-launcher.ts` (`HOOKS_COHORT`, `hookPackageFor`, the `launchTool` cohort guard)
|
||
- Test: `packages/agent/tests/tool-launcher.test.ts` (existing — stays green; add a manifest-derivation assertion)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `BUILTIN_TOOL_MANIFESTS` (Task 1).
|
||
|
||
- [ ] **Step 1: Write the failing test** — append to `tool-launcher.test.ts`:
|
||
|
||
```ts
|
||
import { BUILTIN_TOOL_MANIFESTS } from '@waggle/shared';
|
||
it('HOOKS_COHORT equals the hook-capable manifests (claude-desktop excluded)', () => {
|
||
const expected = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id).sort();
|
||
expect([...HOOKS_COHORT].sort()).toEqual(expected);
|
||
expect(HOOKS_COHORT).not.toContain('claude-desktop');
|
||
});
|
||
```
|
||
|
||
(`HOOKS_COHORT` is already imported/exported from `tool-launcher.ts`; add the import if missing.)
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts -t "HOOKS_COHORT equals"`
|
||
Expected: FAIL — if the literal differs OR (more likely) passes already; if it passes, still proceed to make the derivation real so it can't drift.
|
||
|
||
- [ ] **Step 3: Implement** in `packages/agent/src/tool-launcher.ts` — replace the literal `HOOKS_COHORT` with a derived one and let `hookPackageFor` honor a manifest override:
|
||
|
||
```ts
|
||
import { BUILTIN_TOOL_MANIFESTS, type ToolId } from '@waggle/shared';
|
||
|
||
export const HOOKS_COHORT: readonly ToolId[] =
|
||
BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id as ToolId);
|
||
```
|
||
|
||
(Keep `LAUNCH_COHORT`'s import from shared as-is — it's already derived in Task 1. The `launchTool` guard `if (!LAUNCH_COHORT.includes(opts.id))` is unchanged.)
|
||
|
||
- [ ] **Step 4: Run the full launcher suite**
|
||
|
||
Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts`
|
||
Expected: PASS (existing 39 + new).
|
||
|
||
- [ ] **Step 5: Typecheck + commit**
|
||
|
||
```bash
|
||
npx tsc --noEmit -p packages/agent/tsconfig.json
|
||
git add packages/agent/src/tool-launcher.ts packages/agent/tests/tool-launcher.test.ts
|
||
git commit -m "feat(agent): derive HOOKS_COHORT from the manifest registry (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `web` — derive LauncherApp cohorts from shared manifests
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/components/os/apps/LauncherApp.tsx` (the local `LAUNCH_COHORT` / `HOOKS_COHORT` copies, ~lines 41–59)
|
||
- Test: `apps/web/src/components/os/apps/LauncherApp.test.tsx` (existing — stays green)
|
||
|
||
- [ ] **Step 1: Implement** — replace the hand-maintained local arrays with derivations from the shared manifests (kills the "kept local to avoid a runtime import" duplication the comments call out):
|
||
|
||
```ts
|
||
import { BUILTIN_TOOL_MANIFESTS } from '@waggle/shared';
|
||
|
||
const LAUNCH_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.launchable).map((m) => m.id);
|
||
const HOOKS_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id);
|
||
```
|
||
|
||
(Delete the two local literal arrays + their explanatory comments.)
|
||
|
||
- [ ] **Step 2: Run the LauncherApp suite + web typecheck**
|
||
|
||
Run: `cd apps/web && npx vitest run -c vitest.config.ts src/components/os/apps/LauncherApp.test.tsx && npx tsc --noEmit`
|
||
Expected: PASS (6 tests) + tsc 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/components/os/apps/LauncherApp.tsx
|
||
git commit -m "feat(web): derive LauncherApp cohorts from shared manifests (#5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Final Gate
|
||
|
||
- [ ] **Build dists then full typecheck:** `npx tsc -b packages/shared packages/agent && npx tsc --noEmit -p packages/server/tsconfig.json && (cd apps/web && npx tsc --noEmit)` → 0.
|
||
- [ ] **Touched suites:** `npx vitest run packages/shared/tests/tool-manifests.test.ts packages/agent/tests/tool-manifest-loader.test.ts packages/agent/tests/tool-registry.test.ts packages/agent/tests/tool-detection.test.ts packages/agent/tests/tool-launcher.test.ts` + `(cd apps/web && npx vitest run -c vitest.config.ts src/components/os/apps/LauncherApp.test.tsx)`.
|
||
- [ ] **Server route sanity:** `npx vitest run packages/server/tests/tools-routes.test.ts packages/server/tests/tools-routes-launch.test.ts` (they consume detect/launch — must stay green).
|
||
- [ ] **Lint** touched files.
|
||
|
||
## Fast-follow (documented, NOT silently dropped)
|
||
|
||
**Third-party `promptArgTemplate` application.** The field is captured + validated in v1 but unwired:
|
||
the inline-prompt arg logic lives web-side (`apps/web/src/lib/launcher-prompt-args.ts`) and the dock
|
||
computes args before POSTing. Wiring a loaded manifest's `promptArgTemplate` means surfacing the
|
||
registry (or its prompt templates) to the web bundle — additive, deferred. Built-in prompt-args are
|
||
unchanged.
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:** §3 Unit A → Task 1 · Unit B (registry) → Task 3 · Unit B (detection) → Task 4 · Unit B (launcher) → Task 5 · Unit C (loader) → Task 2 · web derivation (§7 file list) → Task 6 · §5 security → Task 2 tests. The `promptArgTemplate` application is logged as a fast-follow (field captured in Task 1).
|
||
|
||
**Placeholder scan:** Task 4 Step 1 + Task 6 are read/edit steps with concrete code; no TBD/TODO. Every code step ships real code.
|
||
|
||
**Type consistency:** `ToolManifest`/`ToolDetectSpec` (Task 1) consumed unchanged in Tasks 2–6. `loadThirdPartyManifests(deps)` (Task 2) → `getToolRegistry(deps)` (Task 3) → `detectInstalledTools({manifestLoader})` (Task 4). `getToolRegistry` returns `ToolManifest[]`. `detectFromManifest`/`CANDIDATE_RESOLVERS` names consistent within Task 4. `HOOKS_COHORT`/`LAUNCH_COHORT` derived identically in Tasks 1/5/6.
|