diff --git a/README.md b/README.md index 52d75bf..ce04ca2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,21 @@ git clone https://github.com/KalebCole/partiful-cli && cd partiful-cli npm install && npm link ``` +### Install the Partiful agent skill + +The package includes one Partiful skill. Install it globally for whichever agent you use: + +```bash +partiful skill install hermes +partiful skill install openclaw +partiful skill install copilot +partiful skill install claude +``` + +Remove an installer-owned copy with `partiful skill uninstall `. For OpenClaw, uninstall also cleans legacy `partiful-*` links created by older releases; pass `--workspace ` when the old workspace was not at `~/.openclaw/workspace`. + +Use `--dry-run` to preview filesystem changes. During installation, existing or locally modified skill directories are preserved unless `--force` is supplied. Uninstall never removes an unowned destination; an installer-owned copy with local edits also requires `--force`. + ## Features - 🎉 **Events** — create, list, get, update, cancel diff --git a/docs/plans/2026-07-28-unified-agent-skill-installer-design.md b/docs/plans/2026-07-28-unified-agent-skill-installer-design.md new file mode 100644 index 0000000..8db3710 --- /dev/null +++ b/docs/plans/2026-07-28-unified-agent-skill-installer-design.md @@ -0,0 +1,58 @@ +# Unified Agent Skill Installer Design + +**Date:** 2026-07-28 + +## Goal + +Replace the removed OpenClaw-only setup command with one agent-neutral interface for installing the bundled Partiful skill: + +```bash +partiful skill install +partiful skill uninstall +``` + +This change is stacked on `feat/singular-partiful-skill` and must not depend on RSVP questionnaire work. + +## Decisions + +- Use singular `skill`, because the npm package now ships one `partiful` skill. +- Normalize agent names to lowercase. +- Install at each agent's user-level skill root by default: + - Hermes: `$HERMES_HOME/skills/partiful`, falling back to `~/.hermes/skills/partiful` + - OpenClaw: `~/.openclaw/skills/partiful` + - Copilot: `~/.copilot/skills/partiful` + - Claude Code: `~/.claude/skills/partiful` +- Copy the bundled skill rather than symlink it. This works on Windows without elevated symlink privileges and prevents npm installation paths from becoming runtime dependencies. +- Add a private provenance marker to copied installations. Uninstall refuses to remove an unowned directory. +- Preserve global `--dry-run` and `--force` behavior. +- For OpenClaw uninstall, also clean legacy `partiful-*` symlinks created by `partiful setup openclaw`. Support `--workspace ` so custom legacy workspaces can be cleaned. +- Do not add `status`, project-local installation, or auto-detection in this PR. + +## Command behavior + +### Install + +1. Validate agent. +2. Resolve bundled source and agent destination. +3. If destination is absent, recursively copy the skill and marker. +4. If an owned installation already matches, report it as already installed. +5. Refuse to overwrite any existing destination unless `--force` is passed. +6. `--dry-run` returns the intended action without filesystem mutation. + +### Uninstall + +1. Remove the destination only when it is an installer-owned copy or a symlink to the bundled Partiful skill. +2. Refuse to delete an unowned directory. +3. For OpenClaw, additionally inspect the selected legacy workspace and remove only legacy `partiful-*` symlinks whose targets resolve under this package's `skills/` directory. +4. `--dry-run` reports removals without mutation. + +## Output and errors + +All responses use the CLI JSON envelope. Unsupported agents, unreadable source directories, unsafe overwrite attempts, and unowned uninstall targets return structured errors with existing exit-code conventions. + +## Verification + +- Unit tests use temporary homes and never touch real agent directories. +- Test every target's resolved path. +- Test install, idempotence, force overwrite, dry-run, safe uninstall, refusal to delete unowned content, and OpenClaw legacy cleanup. +- Run full tests, typecheck, package dry-run, and CLI smoke tests. diff --git a/docs/plans/2026-07-28-unified-agent-skill-installer-plan.md b/docs/plans/2026-07-28-unified-agent-skill-installer-plan.md new file mode 100644 index 0000000..0cbca71 --- /dev/null +++ b/docs/plans/2026-07-28-unified-agent-skill-installer-plan.md @@ -0,0 +1,82 @@ +# Unified Agent Skill Installer Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add a safe, cross-agent `partiful skill install|uninstall ` interface for the singular bundled Partiful skill. + +**Architecture:** A new command module owns target resolution, copy provenance, safe removal, and OpenClaw legacy cleanup. Commander registration remains thin in `src/cli.ts`; filesystem behavior is exercised through temporary-directory integration tests. + +**Tech Stack:** TypeScript, Commander.js, Node filesystem APIs, Vitest. + +--- + +### Task 1: Lock command contract with failing tests + +**Objective:** Specify supported agents, destination paths, dry-run behavior, and structured output. + +**Files:** +- Create: `tests/skill-installer.test.js` +- Modify: `tests/skill-structure.test.js` + +**Steps:** +1. Add tests invoking the CLI with isolated `HOME` and `HERMES_HOME` values. +2. Assert `skill install` exists for `hermes`, `openclaw`, `copilot`, and `claude`. +3. Assert `--dry-run` reports target paths and makes no changes. +4. Replace the consolidation test asserting `setup` is absent with assertions for the new command contract. +5. Run `npm test -- --run tests/skill-installer.test.js tests/skill-structure.test.js`; expect failure because `skill` is not registered. + +### Task 2: Implement installation + +**Objective:** Copy the bundled singular skill safely into each agent's user-level skill root. + +**Files:** +- Create: `src/commands/skill.ts` +- Modify: `src/cli.ts` +- Test: `tests/skill-installer.test.js` + +**Steps:** +1. Add typed target metadata and path resolution. +2. Register `skill install `. +3. Recursively copy `skills/partiful/` and write a provenance marker. +4. Implement idempotence, `--force`, and global `--dry-run`. +5. Verify the focused tests pass. + +### Task 3: Implement safe uninstall and OpenClaw migration + +**Objective:** Remove only installer-owned copies and safely clean legacy OpenClaw links. + +**Files:** +- Modify: `src/commands/skill.ts` +- Test: `tests/skill-installer.test.js` + +**Steps:** +1. Add failing tests for owned removal, unowned-directory refusal, symlink removal, dry-run, and custom `--workspace` cleanup. +2. Implement `skill uninstall `. +3. Detect old OpenClaw `partiful-*` links without following dangling symlinks. +4. Restrict migration cleanup to symlinks targeting this package's `skills/` directory. +5. Run focused tests and verify all pass. + +### Task 4: Document and verify the public interface + +**Objective:** Make installation discoverable and prove package behavior. + +**Files:** +- Modify: `README.md` +- Modify: `package.json` only if discoverability metadata requires it. + +**Steps:** +1. Document all four install commands and uninstall/migration behavior. +2. Run CLI help smoke tests. +3. Run `npm test`, `npm run typecheck`, and `npm pack --dry-run --json`. +4. Confirm the tarball contains exactly `skills/partiful/SKILL.md` plus its references. +5. Run `git diff --check` and an adversarial review. + +### Task 5: Publish stacked PR + +**Objective:** Push a PR that depends only on the consolidation branch. + +**Steps:** +1. Commit implementation and verification changes. +2. Push `feat/unified-skill-installer`. +3. Open PR with base `feat/singular-partiful-skill`, not `main` and not the RSVP branch. +4. Verify local HEAD equals remote HEAD and inspect PR checks. diff --git a/src/cli.ts b/src/cli.ts index 25b91f2..98fb78c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { registerDoctorCommands } from './commands/doctor.js'; import { registerTemplateCommands } from './commands/templates.js'; import { registerBulkCommands } from './commands/bulk.js'; import { registerRsvpCommands } from './commands/rsvp.js'; +import { registerSkillCommands } from './commands/skill.js'; import { jsonOutput } from './lib/output.js'; // Single source of truth for the version — read from package.json so the @@ -52,6 +53,7 @@ export function run(): void { registerDoctorCommands(program); registerTemplateCommands(program); registerBulkCommands(program); + registerSkillCommands(program); // RSVP / interest verbs, shared across the canonical `events` group and the // `explore` alias group. Look up the `events` command created above; create // the `explore` group here. diff --git a/src/commands/skill.ts b/src/commands/skill.ts new file mode 100644 index 0000000..3459748 --- /dev/null +++ b/src/commands/skill.ts @@ -0,0 +1,252 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Command } from 'commander'; +import { jsonError, jsonOutput } from '../lib/output.js'; + +const AGENTS = ['hermes', 'openclaw', 'copilot', 'claude'] as const; +type Agent = (typeof AGENTS)[number]; + +const MARKER_FILE = '.partiful-cli-install.json'; +const LEGACY_OPENCLAW_SKILLS = [ + 'partiful-events', + 'partiful-guests', + 'partiful-posters', + 'partiful-blasts', + 'partiful-shared', +] as const; + +interface InstallMarker { + installer: 'partiful-cli'; + skill: 'partiful'; + sourceHash: string; +} + +interface GlobalOptions { + dryRun?: boolean; + force?: boolean; + output?: string; + [key: string]: unknown; +} + +function packageSkillsDir(): string { + const thisFile = fileURLToPath(import.meta.url); + return path.resolve(path.dirname(thisFile), '..', '..', 'skills'); +} + +function sourceSkillDir(): string { + return path.join(packageSkillsDir(), 'partiful'); +} + +function homeDir(): string { + return process.env['HOME'] || process.env['USERPROFILE'] || ''; +} + +function parseAgent(raw: string): Agent { + const agent = raw.toLowerCase(); + if (!AGENTS.includes(agent as Agent)) { + jsonError(`Unsupported agent "${raw}". Supported agents: ${AGENTS.join(', ')}.`, 3, 'validation_error'); + } + return agent as Agent; +} + +function destinationFor(agent: Agent): string { + const home = homeDir(); + if (!home) { + jsonError('Cannot resolve user home directory. Set HOME (or USERPROFILE on Windows).', 3, 'validation_error'); + } + + const roots: Record = { + hermes: path.join(process.env['HERMES_HOME'] || path.join(home, '.hermes'), 'skills'), + openclaw: path.join(home, '.openclaw', 'skills'), + copilot: path.join(home, '.copilot', 'skills'), + claude: path.join(home, '.claude', 'skills'), + }; + return path.join(roots[agent], 'partiful'); +} + +function lstat(target: string): fs.Stats | null { + try { + return fs.lstatSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +function hashDirectory(root: string, ignored = new Set()): string { + const hash = crypto.createHash('sha256'); + + function visit(current: string, relative = ''): void { + const entries = fs.readdirSync(current, { withFileTypes: true }) + .filter((entry) => !ignored.has(path.join(relative, entry.name))) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const childRelative = path.join(relative, entry.name); + const child = path.join(current, entry.name); + hash.update(`${entry.isDirectory() ? 'd' : 'f'}:${childRelative}\0`); + if (entry.isDirectory()) visit(child, childRelative); + else hash.update(fs.readFileSync(child)); + } + } + + visit(root); + return hash.digest('hex'); +} + +function readMarker(destination: string): InstallMarker | null { + try { + const marker = JSON.parse(fs.readFileSync(path.join(destination, MARKER_FILE), 'utf8')) as Partial; + if (marker.installer === 'partiful-cli' && marker.skill === 'partiful' && typeof marker.sourceHash === 'string') { + return marker as InstallMarker; + } + } catch { + // Missing or malformed provenance is intentionally treated as unowned. + } + return null; +} + +function copySkill(source: string, destination: string, sourceHash: string): void { + const parent = path.dirname(destination); + fs.mkdirSync(parent, { recursive: true }); + const temporary = path.join(parent, `.partiful.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`); + try { + fs.cpSync(source, temporary, { recursive: true }); + const marker: InstallMarker = { installer: 'partiful-cli', skill: 'partiful', sourceHash }; + fs.writeFileSync(path.join(temporary, MARKER_FILE), `${JSON.stringify(marker, null, 2)}\n`); + if (lstat(destination)) fs.rmSync(destination, { recursive: true, force: true }); + fs.renameSync(temporary, destination); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +function install(agentRaw: string, cmd: Command): void { + const agent = parseAgent(agentRaw); + const globalOpts = cmd.optsWithGlobals(); + const dryRun = Boolean(globalOpts.dryRun); + const force = Boolean(globalOpts.force); + const source = sourceSkillDir(); + const destination = destinationFor(agent); + + if (!fs.existsSync(path.join(source, 'SKILL.md'))) { + jsonError(`Bundled Partiful skill not found: ${source}`, 5, 'internal_error'); + } + + const sourceHash = hashDirectory(source); + const destinationStat = lstat(destination); + let state = dryRun ? 'would_install' : 'installed'; + + if (destinationStat) { + const marker = destinationStat.isDirectory() ? readMarker(destination) : null; + if (marker) { + const destinationHash = hashDirectory(destination, new Set([MARKER_FILE])); + if (destinationHash === sourceHash) { + state = 'already_installed'; + jsonOutput({ action: 'install', agent, dryRun, state, source, destination }, {}, globalOpts); + return; + } + if (!force && destinationHash !== marker.sourceHash) { + jsonError( + `Skill at ${destination} was modified after installation. Re-run with --force to replace it.`, + 3, + 'validation_error', + ); + } + state = dryRun ? 'would_update' : 'updated'; + } else if (!force) { + jsonError(`Destination already exists and is not owned by partiful-cli: ${destination}. Re-run with --force to replace it.`, 3, 'validation_error'); + } + } + + if (!dryRun) copySkill(source, destination, sourceHash); + jsonOutput({ action: 'install', agent, dryRun, state, source, destination }, {}, globalOpts); +} + +function legacyWorkspace(opts: Record): string { + const explicit = opts['workspace']; + if (typeof explicit === 'string' && explicit) return explicit; + if (process.env['OPENCLAW_WORKSPACE']) return process.env['OPENCLAW_WORKSPACE']; + return path.join(homeDir(), '.openclaw', 'workspace'); +} + +function cleanupLegacyOpenClaw(workspace: string, dryRun: boolean, force: boolean): string[] { + const removed: string[] = []; + const workspaceSkills = path.join(workspace, 'skills'); + const currentPackageSkills = packageSkillsDir(); + for (const skill of LEGACY_OPENCLAW_SKILLS) { + const link = path.join(workspaceSkills, skill); + const stat = lstat(link); + if (!stat?.isSymbolicLink()) continue; + const target = path.resolve(path.dirname(link), fs.readlinkSync(link)); + const targetParent = path.dirname(target); + const packageDir = path.dirname(targetParent); + const packageOwned = path.basename(target) === skill + && path.basename(targetParent) === 'skills' + && ( + targetParent === currentPackageSkills + || (path.basename(packageDir) === 'partiful-cli' && path.basename(path.dirname(packageDir)) === 'node_modules') + ); + if (!packageOwned && !force) continue; + if (!dryRun) fs.unlinkSync(link); + removed.push(link); + } + return removed; +} + +function uninstall(agentRaw: string, opts: Record, cmd: Command): void { + const agent = parseAgent(agentRaw); + const globalOpts = cmd.optsWithGlobals(); + const dryRun = Boolean(globalOpts.dryRun); + const force = Boolean(globalOpts.force); + const source = sourceSkillDir(); + const destination = destinationFor(agent); + const destinationStat = lstat(destination); + let state = 'not_installed'; + + if (destinationStat) { + const marker = destinationStat.isDirectory() ? readMarker(destination) : null; + const ownedCopy = Boolean(marker); + const ownedLink = destinationStat.isSymbolicLink() + && path.resolve(path.dirname(destination), fs.readlinkSync(destination)) === source; + if (!ownedCopy && !ownedLink) { + jsonError(`Skill at ${destination} was not installed by partiful-cli; refusing to remove it.`, 3, 'validation_error'); + } + if (marker && !force) { + const destinationHash = hashDirectory(destination, new Set([MARKER_FILE])); + if (destinationHash !== marker.sourceHash) { + jsonError( + `Skill at ${destination} was modified after installation. Re-run with --force to remove it.`, + 3, + 'validation_error', + ); + } + } + state = dryRun ? 'would_remove' : 'removed'; + if (!dryRun) fs.rmSync(destination, { recursive: true, force: true }); + } + + const workspace = agent === 'openclaw' ? legacyWorkspace(opts) : null; + const legacyRemoved = workspace ? cleanupLegacyOpenClaw(workspace, dryRun, force) : []; + jsonOutput( + { action: 'uninstall', agent, dryRun, state, destination, legacyWorkspace: workspace, legacyRemoved }, + {}, + globalOpts, + ); +} + +export function registerSkillCommands(program: Command): void { + const skill = program.command('skill').description('Install the bundled Partiful skill for an AI agent'); + + skill + .command('install ') + .description(`Install for one of: ${AGENTS.join(', ')}`) + .action((agent: string, _opts: unknown, cmd: Command) => install(agent, cmd)); + + skill + .command('uninstall ') + .description(`Uninstall from one of: ${AGENTS.join(', ')}`) + .option('--workspace ', 'Legacy OpenClaw workspace to clean') + .action((agent: string, opts: Record, cmd: Command) => uninstall(agent, opts, cmd)); +} diff --git a/tests/skill-installer.test.js b/tests/skill-installer.test.js new file mode 100644 index 0000000..45de955 --- /dev/null +++ b/tests/skill-installer.test.js @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { run, runRaw } from './helpers.js'; + +const marker = '.partiful-cli-install.json'; + +let home; + +function env(extra = {}) { + return { HOME: home, HERMES_HOME: '', OPENCLAW_WORKSPACE: '', ...extra }; +} + +function destination(agent) { + const roots = { + hermes: path.join(home, '.hermes', 'skills'), + openclaw: path.join(home, '.openclaw', 'skills'), + copilot: path.join(home, '.copilot', 'skills'), + claude: path.join(home, '.claude', 'skills'), + }; + return path.join(roots[agent], 'partiful'); +} + +describe('skill installer', () => { + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'partiful-skill-install-')); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it.each(['hermes', 'openclaw', 'copilot', 'claude'])('dry-runs installation for %s without touching disk', (agent) => { + const result = run(['skill', 'install', agent, '--dry-run'], { env: env() }); + + expect(result.data).toMatchObject({ action: 'install', agent, dryRun: true, destination: destination(agent) }); + expect(fs.existsSync(destination(agent))).toBe(false); + }); + + it('honors HERMES_HOME for Hermes', () => { + const hermesHome = path.join(home, 'custom-hermes'); + const result = run(['skill', 'install', 'HERMES', '--dry-run'], { env: env({ HERMES_HOME: hermesHome }) }); + + expect(result.data.destination).toBe(path.join(hermesHome, 'skills', 'partiful')); + }); + + it('copies the bundled skill and records provenance', () => { + const result = run(['skill', 'install', 'hermes'], { env: env() }); + const target = destination('hermes'); + + expect(result.data.state).toBe('installed'); + expect(fs.lstatSync(target).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8')).toContain('name: partiful'); + expect(JSON.parse(fs.readFileSync(path.join(target, marker), 'utf8'))).toMatchObject({ installer: 'partiful-cli', skill: 'partiful' }); + }); + + it('is idempotent for an installer-owned copy', () => { + run(['skill', 'install', 'hermes'], { env: env() }); + const result = run(['skill', 'install', 'hermes'], { env: env() }); + expect(result.data.state).toBe('already_installed'); + }); + + it('preserves a locally modified owned copy unless forced', () => { + run(['skill', 'install', 'hermes'], { env: env() }); + const skillFile = path.join(destination('hermes'), 'SKILL.md'); + fs.appendFileSync(skillFile, '\nlocal edit\n'); + + const refused = runRaw(['skill', 'install', 'hermes'], { env: env() }); + expect(refused.exitCode).toBe(3); + expect(JSON.parse(refused.stdout).error.message).toMatch(/modified after installation/i); + expect(fs.readFileSync(skillFile, 'utf8')).toContain('local edit'); + + const preview = run(['skill', 'install', 'hermes', '--force', '--dry-run'], { env: env() }); + expect(preview.data.state).toBe('would_update'); + expect(fs.readFileSync(skillFile, 'utf8')).toContain('local edit'); + + const replaced = run(['skill', 'install', 'hermes', '--force'], { env: env() }); + expect(replaced.data.state).toBe('updated'); + expect(fs.readFileSync(skillFile, 'utf8')).not.toContain('local edit'); + }); + + it('refuses an existing unowned destination unless forced', () => { + const target = destination('claude'); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, 'mine.txt'), 'keep'); + + const rejected = runRaw(['skill', 'install', 'claude'], { env: env() }); + expect(rejected.exitCode).toBe(3); + expect(JSON.parse(rejected.stdout).error.message).toMatch(/already exists/i); + expect(fs.readFileSync(path.join(target, 'mine.txt'), 'utf8')).toBe('keep'); + + const forced = run(['skill', 'install', 'claude', '--force'], { env: env() }); + expect(forced.data.state).toBe('installed'); + expect(fs.existsSync(path.join(target, 'mine.txt'))).toBe(false); + expect(fs.existsSync(path.join(target, marker))).toBe(true); + }); + + it('uninstalls an owned copy but refuses unowned content', () => { + run(['skill', 'install', 'copilot'], { env: env() }); + const removed = run(['skill', 'uninstall', 'copilot'], { env: env() }); + expect(removed.data.state).toBe('removed'); + expect(fs.existsSync(destination('copilot'))).toBe(false); + + const target = destination('copilot'); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, 'SKILL.md'), 'user content'); + const rejected = runRaw(['skill', 'uninstall', 'copilot'], { env: env() }); + expect(rejected.exitCode).toBe(3); + expect(JSON.parse(rejected.stdout).error.message).toMatch(/not installed by partiful-cli/i); + expect(fs.existsSync(target)).toBe(true); + + const forced = runRaw(['skill', 'uninstall', 'copilot', '--force'], { env: env() }); + expect(forced.exitCode).toBe(3); + expect(fs.existsSync(target)).toBe(true); + }); + + it('preserves a locally modified owned copy during uninstall unless forced', () => { + run(['skill', 'install', 'hermes'], { env: env() }); + const target = destination('hermes'); + const skillFile = path.join(target, 'SKILL.md'); + fs.appendFileSync(skillFile, '\nlocal edit\n'); + + const rejected = runRaw(['skill', 'uninstall', 'hermes'], { env: env() }); + expect(rejected.exitCode).toBe(3); + expect(JSON.parse(rejected.stdout).error.message).toMatch(/modified after installation/i); + expect(fs.existsSync(target)).toBe(true); + + const removed = run(['skill', 'uninstall', 'hermes', '--force'], { env: env() }); + expect(removed.data.state).toBe('removed'); + expect(fs.existsSync(target)).toBe(false); + }); + + it('returns not_installed when no owned copy exists', () => { + const result = run(['skill', 'uninstall', 'claude'], { env: env() }); + expect(result.data).toMatchObject({ action: 'uninstall', agent: 'claude', state: 'not_installed' }); + }); + + it('dry-runs uninstall without removing an owned copy', () => { + run(['skill', 'install', 'hermes'], { env: env() }); + const result = run(['skill', 'uninstall', 'hermes', '--dry-run'], { env: env() }); + + expect(result.data).toMatchObject({ action: 'uninstall', state: 'would_remove', dryRun: true }); + expect(fs.existsSync(destination('hermes'))).toBe(true); + }); + + it('cleans only package-owned legacy OpenClaw symlinks', () => { + const workspace = path.join(home, 'legacy-workspace'); + const workspaceSkills = path.join(workspace, 'skills'); + fs.mkdirSync(workspaceSkills, { recursive: true }); + const legacy = path.join(workspaceSkills, 'partiful-events'); + const sameNameUnowned = path.join(workspaceSkills, 'partiful-guests'); + const checkoutUnowned = path.join(workspaceSkills, 'partiful-posters'); + const unrelated = path.join(workspaceSkills, 'partiful-personal'); + const oldPackageSkills = path.join(home, 'old-node', 'lib', 'node_modules', 'partiful-cli', 'skills'); + fs.symlinkSync(path.join(oldPackageSkills, 'partiful-events'), legacy); + fs.symlinkSync(path.join(home, 'my-skills', 'partiful-guests'), sameNameUnowned); + fs.symlinkSync(path.join(home, 'projects', 'partiful-cli', 'skills', 'partiful-posters'), checkoutUnowned); + fs.symlinkSync(path.join(home, 'my-skills', 'partiful-personal'), unrelated); + + const result = run(['skill', 'uninstall', 'openclaw', '--workspace', workspace], { env: env() }); + + expect(result.data.legacyRemoved).toEqual([legacy]); + expect(fs.existsSync(legacy)).toBe(false); + expect(fs.lstatSync(sameNameUnowned).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(checkoutUnowned).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(unrelated).isSymbolicLink()).toBe(true); + }); + + it('dry-runs legacy cleanup and force-removes ambiguous legacy symlinks', () => { + const workspace = path.join(home, 'legacy-workspace'); + const workspaceSkills = path.join(workspace, 'skills'); + fs.mkdirSync(workspaceSkills, { recursive: true }); + const ambiguous = path.join(workspaceSkills, 'partiful-guests'); + fs.symlinkSync(path.join(home, 'renamed-checkout', 'skills', 'partiful-guests'), ambiguous); + + const preview = run(['skill', 'uninstall', 'openclaw', '--workspace', workspace, '--force', '--dry-run'], { env: env() }); + expect(preview.data.legacyRemoved).toEqual([ambiguous]); + expect(fs.lstatSync(ambiguous).isSymbolicLink()).toBe(true); + + const removed = run(['skill', 'uninstall', 'openclaw', '--workspace', workspace, '--force'], { env: env() }); + expect(removed.data.legacyRemoved).toEqual([ambiguous]); + expect(fs.existsSync(ambiguous)).toBe(false); + }); + + it('returns a structured validation error for unsupported agents', () => { + const result = runRaw(['skill', 'install', 'cursor'], { env: env() }); + + expect(result.exitCode).toBe(3); + expect(JSON.parse(result.stdout).error).toMatchObject({ type: 'validation_error' }); + }); +}); diff --git a/tests/skill-structure.test.js b/tests/skill-structure.test.js index 3c51aba..c165090 100644 --- a/tests/skill-structure.test.js +++ b/tests/skill-structure.test.js @@ -119,14 +119,24 @@ describe('bundled Partiful skill', () => { } }); - it('removes the obsolete OpenClaw setup command', () => { + it('exposes the agent-neutral skill installer and retires setup openclaw', () => { expect(fs.existsSync(path.join(repoRoot, 'src', 'commands', 'setup.ts'))).toBe(false); - const packageJson = fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'); const cli = fs.readFileSync(path.join(repoRoot, 'src', 'cli.ts'), 'utf8'); - expect(packageJson).not.toMatch(/openclaw/i); + expect(cli).toMatch(/registerSkillCommands/); expect(cli).not.toMatch(/registerSetupCommands|commands\/setup/); - const { stdout, exitCode } = runRaw(['setup', 'openclaw']); - expect(exitCode).not.toBe(0); - expect(stdout).not.toContain('"status":"success"'); + + for (const command of [ + ['skill', '--help'], + ['skill', 'install', '--help'], + ['skill', 'uninstall', '--help'], + ]) { + const { stdout, exitCode } = runRaw(command); + expect(exitCode, `${command.join(' ')} should resolve`).toBe(0); + expect(stdout).toContain('Usage: partiful skill'); + } + + const legacy = runRaw(['setup', 'openclaw']); + expect(legacy.exitCode).not.toBe(0); + expect(legacy.stdout).not.toContain('"status":"success"'); }); });