Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Then restart CodeArts. The skill will be available in your next session.

### skill-installer

Single entry point to install, update, delete, or check the status of any supported CodeArts skill/tool. Consolidates four installers behind one skill — **superpowers**, **office-mcp**, **playwright-cli**, and **openspec** — as internal adapter modules. Specify the target via `--target <name>` (or positionally).
Single entry point to install, update, delete, or check the status of any supported CodeArts skill/tool. Consolidates five installers behind one skill — **superpowers**, **office-mcp**, **playwright-cli**, **openspec**, and **spec-kit** — as internal adapter modules. Specify the target via `--target <name>` (or positionally).

**Installation:**

Expand Down Expand Up @@ -59,6 +59,7 @@ Or just tell CodeArts: *"install openspec globally"* / *"安装 openspec 到用
| `office-mcp` | project | init, update, delete, status | [claude-office-skills/skills](https://github.com/claude-office-skills/skills/tree/main/mcp-servers/office-mcp) | MCP server (39 Word/Excel/PPT/PDF/OCR tools) + skill + MCP config |
| `playwright-cli` | project, user | init, update, delete, status | [microsoft/playwright-cli](https://github.com/microsoft/playwright-cli) | playwright-cli skill + `@playwright/cli` + chromium |
| `openspec` | project, user | init, update, delete, status | [Fission-AI/OpenSpec](https://github.com/Fission-AI/OpenSpec) | OpenSpec SDD skills (propose/explore/apply/sync/archive) |
| `spec-kit` | project, user | init, update, delete, status | [github/spec-kit](https://github.com/github/spec-kit) | GitHub Spec Kit SDD skills (/speckit.constitution/specify/plan/tasks/implement) |

Capability gating rejects unsupported combos up front — e.g. `init --target office-mcp --user` errors (project scope only); `status --target superpowers` errors (no status command).

Expand All @@ -72,7 +73,7 @@ Capability gating rejects unsupported combos up front — e.g. `init --target of

**ALSO, YOU CAN USE NATURAL LANGUAGE TO LET THIS SKILL INSTALL/UPDATE/DELETE any supported target for you** — just name the target (e.g. "install openspec", "更新 office-mcp", "uninstall superpowers").

**Requirements:** Node.js (≥ 20.19.0 for openspec; ≥ 18 otherwise), npm, and git — all already required by CodeArts. Individual targets may install global packages (openspec CLI, `@playwright/cli`) or download browsers automatically. Works on Windows, Linux, and macOS.
**Requirements:** Node.js (≥ 20.19.0 for openspec; ≥ 18 otherwise), npm, and git — all already required by CodeArts. Individual targets may install global packages (openspec CLI, `@playwright/cli`) or download browsers automatically. `spec-kit` additionally requires [uv](https://docs.astral.sh/uv/) (auto-installed if missing) and provisions Python 3.12 via uv. Works on Windows, Linux, and macOS.

**Verify:** after install + restart, ask CodeArts something target-specific — e.g. "Propose a new feature using OpenSpec", "Read the Excel file at ./data.xlsx", "Tell me about your superpowers", or "Open https://example.com with playwright-cli".

Expand Down
10 changes: 6 additions & 4 deletions skills/skill-installer/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
name: skill-installer
description: "Single entry point to install, update, delete, or check the status of any supported CodeArts skill/tool installer. Consolidates four installers behind one skill: superpowers, office-mcp, playwright-cli, and openspec. OpenSpec and other tools don't natively support CodeArts; this meta-installer bridges them. Specify the target via --target <name> (or positionally). Use whenever the user wants to install/update/delete any of these targets, or to list what's supported. Triggers on: install superpowers, install office-mcp, install playwright-cli, install openspec, 一键安装技能, 安装技能, skill installer, update skills, uninstall skills, 安装openspec, 安装office文档工具, 安装浏览器自动化工具."
description: "Single entry point to install, update, delete, or check the status of any supported CodeArts skill/tool installer. Consolidates five installers behind one skill: superpowers, office-mcp, playwright-cli, openspec, and spec-kit. OpenSpec, Spec Kit and other tools don't natively support CodeArts; this meta-installer bridges them. Specify the target via --target <name> (or positionally). Use whenever the user wants to install/update/delete any of these targets, or to list what's supported. Triggers on: install superpowers, install office-mcp, install playwright-cli, install openspec, install spec-kit, 一键安装技能, 安装技能, skill installer, update skills, uninstall skills, 安装openspec, 安装spec-kit, 安装office文档工具, 安装浏览器自动化工具."
---

# Skill Installer

One entry point for all CodeArts skill/tool installers. Pass the target name via `--target <name>` (or positionally) and the command (`init`/`update`/`delete`/`status`). The dispatcher validates each target's supported scopes and commands before running, then delegates to the target's adapter.

This is the **only** installer skill exposed to CodeArts. The four individual installers (`superpowers-installer`, `office-mcp-installer`, `playwright-cli-installer`, `openspec-installer`) are consolidated as internal adapter modules under `scripts/targets/`.
This is the **only** installer skill exposed to CodeArts. The five individual installers (`superpowers-installer`, `office-mcp-installer`, `playwright-cli-installer`, `openspec-installer`, `spec-kit-installer`) are consolidated as internal adapter modules under `scripts/targets/`.

## Quick Start

Expand Down Expand Up @@ -39,6 +39,7 @@ Positional shorthand: `init openspec` ≡ `init --target openspec`.
| `office-mcp` | project | init, update, delete, status | MCP server (39 Word/Excel/PPT/PDF/OCR tools) + skill + MCP config |
| `playwright-cli` | project, user | init, update, delete, status | playwright-cli skill + `@playwright/cli` + chromium browser |
| `openspec` | project, user | init, update, delete, status | OpenSpec SDD skills (propose/explore/apply/sync/archive) |
| `spec-kit` | project, user | init, update, delete, status | GitHub Spec Kit SDD skills (/speckit.constitution/specify/plan/tasks/implement) |

Capability gating rejects unsupported combos up front — e.g. `init --target office-mcp --user` errors ("office-mcp supports project scope only"); `status --target superpowers` errors ("superpowers has no status command").

Expand All @@ -54,7 +55,7 @@ Capability gating rejects unsupported combos up front — e.g. `init --target of

## Requirements

Node.js (≥ 20.19.0 for openspec; ≥ 18 otherwise), npm, and git — all already required by CodeArts. Individual targets may install global packages (openspec CLI, @playwright/cli) or download browsers automatically. Works on Windows, Linux, and macOS.
Node.js (≥ 20.19.0 for openspec; ≥ 18 otherwise), npm, and git — all already required by CodeArts. Individual targets may install global packages (openspec CLI, @playwright/cli) or download browsers automatically. `spec-kit` additionally requires uv (auto-installed if missing) and provisions Python 3.12 via uv. Works on Windows, Linux, and macOS.

## Layout

Expand All @@ -69,6 +70,7 @@ skill-installer/
│ ├── superpowers.js
│ ├── office-mcp.js
│ ├── playwright-cli.js
│ └── openspec.js
│ ├── openspec.js
│ └── spec-kit.js
└── assets/manifests/ # runtime manifests (gitignored)
```
1 change: 1 addition & 0 deletions skills/skill-installer/scripts/targets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ module.exports = [
require('./office-mcp'),
require('./playwright-cli'),
require('./openspec'),
require('./spec-kit'),
];
298 changes: 298 additions & 0 deletions skills/skill-installer/scripts/targets/spec-kit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { run, runCapture, rmDir, cpDir, isWindows } = require('../lib/exec');
const { enableSkills, disableSkills, readEnabledSkills } = require('../lib/status-file');
const { manifestFileFor, collectFiles, writeManifest, readManifest, removeManifest } = require('../lib/manifest');

const SPECIFY_PKG = 'specify-cli';
const PYTHON_TARGET = '3.12';
const DONOR_INTEGRATION = 'copilot';
const DONOR_OPTIONS = '--skills';
const SKILL_PREFIX = 'speckit-';

function q(p) {
return `"${p}"`;
}

function exe(name) {
return isWindows() ? `${name}.exe` : name;
}

function uvPathCandidates() {
const bin = exe('uv');
return [
path.join(os.homedir(), '.local', 'bin', bin),
path.join(os.homedir(), '.cargo', 'bin', bin)
];
}

function resolveUv() {
if (runCapture('uv --version')) return 'uv';
for (const c of uvPathCandidates()) {
if (fs.existsSync(c)) return q(c);
}
return null;
}

function detectUv() {
const cmd = resolveUv();
return cmd ? runCapture(`${cmd} --version`) : null;
}

function ensureUv() {
let cmd = resolveUv();
if (cmd) return cmd;
console.log('==> uv not found. Attempting install...');
const pip = runCapture('pip3 --version') ? 'pip3' : (runCapture('pip --version') ? 'pip' : null);
if (pip) {
try { run(`${pip} install --user uv`); } catch (e) { /* fall through */ }
}
cmd = resolveUv();
if (cmd) { console.log(` uv installed: ${runCapture(`${cmd} --version`)}`); return cmd; }
if (isWindows()) {
try { run('powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'); } catch (e) { /* fall through */ }
} else {
try { run('curl -LsSf https://astral.sh/uv/install.sh | sh'); } catch (e) { /* fall through */ }
}
cmd = resolveUv();
if (!cmd) {
console.error('Error: uv still not found after install attempts.');
console.error('Install manually: https://docs.astral.sh/uv/ (e.g. `curl -LsSf https://astral.sh/uv/install.sh | sh`).');
process.exit(1);
}
console.log(` uv installed: ${runCapture(`${cmd} --version`)}`);
return cmd;
}

function resolveSpecify() {
if (runCapture('specify --version')) return 'specify';
const uv = resolveUv();
if (uv) {
const binDir = runCapture(`${uv} tool dir --bin`);
if (binDir) {
const cand = path.join(binDir, exe('specify'));
if (fs.existsSync(cand)) return q(cand);
}
}
const fallback = path.join(os.homedir(), '.local', 'bin', exe('specify'));
if (fs.existsSync(fallback)) return q(fallback);
return null;
}

function detectSpecify() {
const cmd = resolveSpecify();
return cmd ? runCapture(`${cmd} --version`) : null;
}

function ensureCli(force) {
let cmd = resolveSpecify();
if (cmd && !force) {
const v = runCapture(`${cmd} --version`);
if (v) return { cmd, version: v };
}
const uv = ensureUv();
console.log(`==> ${force ? 'Reinstalling' : 'Installing'} specify-cli via uv (Python ${PYTHON_TARGET})...`);
run(`${uv} tool install ${force ? '--force ' : ''}--python ${PYTHON_TARGET} ${SPECIFY_PKG}`);
cmd = resolveSpecify();
if (!cmd) {
console.error('Error: specify CLI not found after `uv tool install`.');
console.error(`Try manually: uv tool install --python ${PYTHON_TARGET} ${SPECIFY_PKG}`);
process.exit(1);
}
const version = runCapture(`${cmd} --version`) || 'unknown';
console.log(` specify CLI ready: ${version}`);
return { cmd, version };
}

function findSpeckitSkills(rootDir) {
const found = [];
function walk(dir) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(SKILL_PREFIX) && fs.existsSync(path.join(full, 'SKILL.md'))) {
found.push(full);
} else {
walk(full);
}
}
}
walk(rootDir);
return found.sort();
}

function ensureFrontmatter(skillDir, skillName) {
const file = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(file)) return;
const content = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
const m = /^---\n([\s\S]*?)\n---\n?/.exec(content);
const fm = m ? m[1] : '';
const body = m ? content.slice(m[0].length) : content;
const lines = fm.split('\n').filter(l => l.trim().length);
const has = (k) => lines.some(l => new RegExp(`^${k}\\s*:`).test(l));
if (!has('name')) lines.unshift(`name: ${skillName}`);
if (!has('description')) {
const h = /^#\s+(.+)$/m.exec(body);
const desc = h ? h[1].trim() : `Spec Kit SDD skill (${skillName}).`;
lines.push(`description: ${JSON.stringify(desc)}`);
}
fs.writeFileSync(file, `---\n${lines.join('\n')}\n---\n${body}`);
}

function generateDonorSkills(tmpDir, specifyCmd) {
console.log(`==> Generating Spec Kit skills (donor: ${DONOR_INTEGRATION} ${DONOR_OPTIONS})...`);
rmDir(tmpDir);
fs.mkdirSync(tmpDir, { recursive: true });
run(`${specifyCmd} init proj --integration ${DONOR_INTEGRATION} --integration-options=${q(DONOR_OPTIONS)}`, {
cwd: tmpDir,
env: { ...process.env, CI: '1' }
});
const projectDir = path.join(tmpDir, 'proj');
const skillDirs = findSpeckitSkills(projectDir);
if (skillDirs.length === 0) {
console.error('Error: specify init did not generate any speckit-* skills.');
console.error('The spec-kit CLI layout may have changed.');
process.exit(1);
}
const skillNames = skillDirs.map(d => path.basename(d));
console.log(` Generated ${skillNames.length} skill(s): ${skillNames.join(', ')}`);
return { skillDirs, skillNames };
}

function installSkills(ctx, action, specifyVersion) {
const { skillsDir, statusFile } = ctx;
const tmpDir = path.join(os.tmpdir(), `spec-kit-installer-${action}-${process.pid}`);
const { cmd } = ensureCli(action === 'update');
const { skillDirs, skillNames } = generateDonorSkills(tmpDir, cmd);

if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });

console.log(`\n==> ${action === 'update' ? 'Overwriting' : 'Installing'} skills...`);
for (const dir of skillDirs) {
const name = path.basename(dir);
const dest = path.join(skillsDir, name);
cpDir(dir, dest);
ensureFrontmatter(dest, name);
console.log(` ${name}`);
}

console.log('\n==> Registering skills...');
enableSkills(statusFile, skillNames);

console.log('\n==> Writing manifest...');
const manifestFile = manifestFileFor(skillsDir);
const files = [];
for (const name of skillNames) {
const dir = path.join(skillsDir, name);
if (fs.existsSync(dir)) collectFiles(dir, files);
}
writeManifest(manifestFile, {
installedAt: new Date().toISOString(),
target: 'spec-kit',
specifyVersion,
skillsDir,
skillNames,
files
});
console.log(` Manifest saved: ${files.length} files tracked.`);

rmDir(tmpDir);
console.log(`\n==> Done! Spec Kit skills ${action === 'update' ? 'updated' : 'installed'}. Restart CodeArts to apply.`);
console.log(' Use the /speckit.* commands (constitution, specify, plan, tasks, implement) in your agent.');
}
Comment on lines +165 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

update() never cleans up skills removed/renamed by a newer spec-kit version — causes residue.

installSkills copies the freshly generated skillNames, enables them, and overwrites the manifest with only that new list. If a future specify-cli release renames or drops a speckit-* skill, the old skill's directory is never removed, its name is never disabled in the status file (only delete() calls disableSkills), and the new manifest no longer tracks it — so a later delete won't clean it up either. This orphans files/status entries permanently, contradicting the "reversible ... without residue" goal stated in the PR objectives.

🧹 Reconcile against the previous manifest before overwriting
 function installSkills(ctx, action, specifyVersion) {
   const { skillsDir, statusFile } = ctx;
   const tmpDir = path.join(os.tmpdir(), `spec-kit-installer-${action}-${process.pid}`);
   const { cmd } = ensureCli(action === 'update');
   const { skillDirs, skillNames } = generateDonorSkills(tmpDir, cmd);

   if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });

+  const manifestFile = manifestFileFor(skillsDir);
+  const previousManifest = readManifest(manifestFile);
+  const staleNames = (previousManifest && Array.isArray(previousManifest.skillNames))
+    ? previousManifest.skillNames.filter(n => !skillNames.includes(n))
+    : [];
+  if (staleNames.length) {
+    console.log(`\n==> Removing skills no longer generated: ${staleNames.join(', ')}`);
+    for (const name of staleNames) {
+      const dir = path.join(skillsDir, name);
+      if (fs.existsSync(dir)) rmDir(dir);
+    }
+    disableSkills(statusFile, staleNames);
+  }
+
   console.log(`\n==> ${action === 'update' ? 'Overwriting' : 'Installing'} skills...`);
   for (const dir of skillDirs) {
     ...
   }

   console.log('\n==> Registering skills...');
   enableSkills(statusFile, skillNames);

   console.log('\n==> Writing manifest...');
-  const manifestFile = manifestFileFor(skillsDir);
   const files = [];

Also applies to: 219-222

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/skill-installer/scripts/targets/spec-kit.js` around lines 165 - 205,
Update installSkills to read the existing manifest before replacing it, identify
previously tracked skill names absent from the newly generated skillNames,
remove their directories from skillsDir, and disable those names in statusFile.
Perform this reconciliation before writing the new manifest, while preserving
the current installation/update flow for retained and newly generated skills.


module.exports = {
name: 'spec-kit',
displayName: 'Spec Kit',
description: 'GitHub Spec-Driven Development (SDD) skills — /speckit.constitution/specify/plan/tasks/implement — via the specify CLI.',
scopes: ['project', 'user'],
commands: ['init', 'update', 'delete', 'status'],

init(ctx) {
const { version } = ensureCli(false);
installSkills(ctx, 'init', version);
},

update(ctx) {
const { version } = ensureCli(true);
installSkills(ctx, 'update', version);
},

delete(ctx) {
const { skillsDir, statusFile } = ctx;
const manifestFile = manifestFileFor(skillsDir);
let skillNames = [];
const manifest = readManifest(manifestFile);
if (manifest && Array.isArray(manifest.skillNames)) {
skillNames = manifest.skillNames;
console.log('==> Using manifest to identify installed skills.');
} else {
console.log('==> No manifest found — scanning skills dir for speckit-* skills (best-effort).');
if (fs.existsSync(skillsDir)) {
skillNames = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith(SKILL_PREFIX))
.map(e => e.name);
}
}

if (skillNames.length === 0) {
console.log(' No speckit-* skills found — nothing to remove.');
} else {
console.log('\n==> Removing skills...');
for (const name of skillNames) {
const dir = path.join(skillsDir, name);
if (fs.existsSync(dir)) {
rmDir(dir);
console.log(` Removed ${name}`);
} else {
console.log(` ${name} not found — skipping.`);
}
}
console.log('\n==> Unregistering skills...');
disableSkills(statusFile, skillNames);
}

removeManifest(manifestFile);
console.log('\n==> Removed manifest.');
console.log('\n==> Done! Spec Kit skills uninstalled. (spec-kit spec data left intact.)');
},

status(ctx) {
const { skillsDir, statusFile } = ctx;
const manifestFile = manifestFileFor(skillsDir);

const uvVersion = detectUv();
console.log(` uv : ${uvVersion || 'NOT installed'}`);

const specifyVersion = detectSpecify();
console.log(` specify CLI : ${specifyVersion || 'NOT installed'}`);

const present = [];
if (fs.existsSync(skillsDir)) {
present.push(...fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith(SKILL_PREFIX))
.map(e => e.name)
.sort());
}
console.log(` Skills present : ${present.length ? present.join(', ') : '(none)'}`);

const enabled = readEnabledSkills(statusFile).filter(n => n.startsWith(SKILL_PREFIX)).sort();
console.log(` Status file : ${fs.existsSync(statusFile) ? statusFile : '(missing)'}`);
console.log(` Skills enabled : ${enabled.length ? enabled.join(', ') : '(none)'}`);

const manifest = readManifest(manifestFile);
console.log(` Manifest : ${manifest ? `specify ${manifest.specifyVersion || '?'}, ${manifest.skillNames ? manifest.skillNames.length : 0} skills, ${manifest.installedAt}` : 'NOT found'}`);

const expected = (manifest && Array.isArray(manifest.skillNames)) ? manifest.skillNames : present;
const healthy = !!specifyVersion
&& expected.length > 0
&& expected.every(n => present.includes(n))
&& expected.every(n => enabled.includes(n))
&& !!manifest;
console.log(`\n Overall : ${healthy ? 'HEALTHY' : 'INCOMPLETE'}`);
return healthy ? 0 : 1;
}
};