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
16 changes: 16 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ import {
cmdInitMapCodebase,
cmdInitExisting,
cmdInitProgress,
cmdSkillList,
cmdSkillInstall,
cmdSkillUpdate,
} from './core/index.js';

// ─── Arg parsing utilities ───────────────────────────────────────────────────
Expand Down Expand Up @@ -278,6 +281,18 @@ const handleValidate: Handler = (args, cwd, raw) => {
error('Unknown validate subcommand. Available: consistency, health');
};

const handleSkill: Handler = (args, cwd, raw) => {
const sub = args[1];
const handlers: Record<string, () => void> = {
'list': () => cmdSkillList(cwd, raw),
'install': () => cmdSkillInstall(cwd, args[2], raw),
'update': () => cmdSkillUpdate(cwd, raw),
};
const handler = sub ? handlers[sub] : undefined;
if (handler) return handler();
error('Unknown skill subcommand. Available: list, install, update');
};
Comment on lines +284 to +294
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

New skill subcommands are registered here, but there are no E2E coverage additions. The repo already has CLI E2E tests that execute the built maxsim-tools.cjs (e.g. tests/e2e/tools.test.ts). Adding tests for skill list/install/update would help catch regressions in path resolution (local vs global config) and bundled asset lookups, and should run with an isolated HOME / temp config dir to avoid touching the developer's real ~/.claude.

Copilot uses AI. Check for mistakes.

const handleInit: Handler = (args, cwd, raw) => {
const workflow = args[1];
const handlers: Record<string, () => void> = {
Expand Down Expand Up @@ -346,6 +361,7 @@ const COMMANDS: Record<string, Handler> = {
const f = getFlags(args, 'phase', 'name');
cmdScaffold(cwd, args[1], { phase: f.phase, name: f.name ? args.slice(args.indexOf('--name') + 1).join(' ') : null }, raw);
},
'skill': handleSkill,
'init': handleInit,
'phase-plan-index': (args, cwd, raw) => cmdPhasePlanIndex(cwd, args[1], raw),
'state-snapshot': (_args, cwd, raw) => cmdStateSnapshot(cwd, raw),
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,13 @@ export type {
SpawnDashboardOptions,
} from './dashboard-launcher.js';

// Skills exports
export {
cmdSkillList,
cmdSkillInstall,
cmdSkillUpdate,
} from './skills.js';

// Init exports
export type {
WorkflowType,
Expand Down
197 changes: 197 additions & 0 deletions packages/cli/src/core/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* Skills — List, install, and update CLI skills
*/

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

import { output, error, rethrowCliSignals } from './core.js';
import { extractFrontmatter } from './frontmatter.js';

// ─── Constants ───────────────────────────────────────────────────────────────

/** Skills installed by MAXSIM (not user-created). */
const BUILT_IN_SKILLS = [
'tdd',
'systematic-debugging',
'verification-before-completion',
'code-review',
'simplify',
'memory-management',
'using-maxsim',
'batch-execution',
'subagent-driven-development',
'writing-plans',
] as const;
Comment on lines +14 to +26
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

BUILT_IN_SKILLS introduces a second source of truth for “built-in skills”. There is already a separate built-in skills list in the installer logic (packages/cli/src/install/index.ts, used for cleanup before copying). Keeping these in sync manually is error-prone; consider centralizing the list (or deriving it from the bundled skills directory) so skill update and install/uninstall behavior stay consistent over time.

Copilot uses AI. Check for mistakes.

/** Installed skills directory under the Claude config. */
function skillsDir(): string {
return path.join(os.homedir(), '.claude', 'agents', 'skills');
}
Comment on lines +28 to +31
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

skillsDir() hard-codes ~/.claude/agents/skills, so skill list/install/update will ignore a local .claude install in the provided cwd (the CLI already supports --cwd, and other code prefers local-first, e.g. dashboard server resolution). This will make the new commands behave unexpectedly for local installs and also makes E2E testing unsafe (would modify the developer's real home). Consider resolving skills dir as path.join(cwd, '.claude', 'agents', 'skills') when it exists (or when cwd/.claude exists), and falling back to os.homedir() only if no local config is present.

Copilot uses AI. Check for mistakes.

/** Bundled skills directory inside the npm package. */
function bundledSkillsDir(): string {
return path.resolve(__dirname, 'assets', 'templates', 'skills');
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

bundledSkillsDir() resolves path.resolve(__dirname, 'assets', 'templates', 'skills'). When this module is executed from dist/core/skills.js, __dirname will be dist/core, but the packaged assets live under dist/assets/..., so this path will not exist in the published build. Adjust the resolution to point at the package-level dist/assets/templates/skills (e.g., resolve relative to ..).

Suggested change
return path.resolve(__dirname, 'assets', 'templates', 'skills');
return path.resolve(__dirname, '..', 'assets', 'templates', 'skills');

Copilot uses AI. Check for mistakes.
}

// ─── skill list ──────────────────────────────────────────────────────────────

interface SkillInfo {
name: string;
description: string;
path: string;
builtIn: boolean;
}

export function cmdSkillList(_cwd: string, raw: boolean): void {
const dir = skillsDir();

if (!fs.existsSync(dir)) {
output({ count: 0, skills: [] }, raw, '0 skills installed');
return;
}

const skills: SkillInfo[] = [];

try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(dir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;

try {
const content = fs.readFileSync(skillMd, 'utf-8');
const fm = extractFrontmatter(content);
skills.push({
name: (fm.name as string) || entry.name,
description: (fm.description as string) || '',
path: path.join(dir, entry.name),
builtIn: (BUILT_IN_SKILLS as readonly string[]).includes(entry.name),
});
} catch {
// Skill exists but SKILL.md is malformed — still list it
skills.push({
name: entry.name,
description: '',
path: path.join(dir, entry.name),
builtIn: (BUILT_IN_SKILLS as readonly string[]).includes(entry.name),
});
}
}
} catch (e: unknown) {
rethrowCliSignals(e);
error('Failed to list skills: ' + (e as Error).message);
}

skills.sort((a, b) => a.name.localeCompare(b.name));

const summary = skills.map(s => `${s.name}: ${s.description}`).join('\n');
output({ count: skills.length, skills }, raw, `${skills.length} skills installed\n${summary}`);
}

// ─── skill install ───────────────────────────────────────────────────────────

export function cmdSkillInstall(_cwd: string, skillName: string | undefined, raw: boolean): void {
if (!skillName) {
error('skill name required for skill install');
}

const bundled = bundledSkillsDir();
const srcDir = path.join(bundled, skillName);
const srcFile = path.join(srcDir, 'SKILL.md');

if (!fs.existsSync(srcFile)) {
const available = listBundledSkillNames(bundled);
error(`Skill '${skillName}' not found in bundle. Available: ${available.join(', ') || 'none'}`);
}

const destDir = path.join(skillsDir(), skillName);
installSkillFromBundle(srcDir, destDir);

Comment on lines +101 to +112
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

skillName is used directly in path.join() for both the bundle source and destination paths. Because path.join() will accept .. segments and absolute paths, a crafted skillName could read from / write to unintended filesystem locations (path traversal / arbitrary write), especially since destDir is computed from user input. Validate skillName (e.g., allow only [a-z0-9-]+), reject path separators/absolute paths, and/or verify path.resolve(...) stays within the expected base directories before copying.

Copilot uses AI. Check for mistakes.
output(
{ installed: true, skill: skillName, path: destDir },
raw,
`Installed skill '${skillName}' to ${destDir}`,
);
}

// ─── skill update ────────────────────────────────────────────────────────────

export function cmdSkillUpdate(_cwd: string, raw: boolean): void {
const bundled = bundledSkillsDir();

if (!fs.existsSync(bundled)) {
error('Bundled skills directory not found. Is MAXSIM installed correctly?');
}

const dest = skillsDir();
fs.mkdirSync(dest, { recursive: true });

let updated = 0;
const updatedNames: string[] = [];

for (const skillName of BUILT_IN_SKILLS) {
const srcDir = path.join(bundled, skillName);
if (!fs.existsSync(srcDir)) continue;

const destSkillDir = path.join(dest, skillName);

// Remove old version if present
if (fs.existsSync(destSkillDir)) {
fs.rmSync(destSkillDir, { recursive: true });
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

fs.rmSync(destSkillDir, { recursive: true }) may fail on Windows or with read-only files (and elsewhere in the codebase rmSync calls often include force: true). Consider adding force: true (or using the existing safeRmDir helper pattern) so skill update is resilient and doesn't leave a partially-updated install.

Suggested change
fs.rmSync(destSkillDir, { recursive: true });
fs.rmSync(destSkillDir, { recursive: true, force: true });

Copilot uses AI. Check for mistakes.
}

installSkillFromBundle(srcDir, destSkillDir);
updated++;
updatedNames.push(skillName);
}

output(
{ updated, skills: updatedNames },
raw,
`Updated ${updated} built-in skills`,
);
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Copy a bundled skill to the destination and process path replacements. */
function installSkillFromBundle(srcDir: string, destDir: string): void {
fs.mkdirSync(destDir, { recursive: true });
copyDirRecursive(srcDir, destDir);

// Expand ~/.claude/ to absolute home path for consistency
const destFile = path.join(destDir, 'SKILL.md');
let content = fs.readFileSync(destFile, 'utf-8');
const homePrefix = path.join(os.homedir(), '.claude') + '/';
content = content.replace(/~\/\.claude\//g, homePrefix.replace(/\\/g, '/'));
fs.writeFileSync(destFile, content, 'utf-8');
}

/** Recursively copy a directory. */
function copyDirRecursive(src: string, dest: string): void {
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(destPath, { recursive: true });
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}

/** List skill names available in a bundled skills directory. */
function listBundledSkillNames(bundledDir: string): string[] {
try {
return fs.readdirSync(bundledDir, { withFileTypes: true })
.filter(e => e.isDirectory() && fs.existsSync(path.join(bundledDir, e.name, 'SKILL.md')))
.map(e => e.name);
} catch {
return [];
}
}