From 0ef3a2d0742ef3ff662d5d4c12da9646218767b9 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 14:44:23 +0800 Subject: [PATCH 01/11] Add skills commands --- packages/cli/index.ts | 35 ++++++- packages/cli/package.json | 1 + packages/cli/src/cmd/skills.ts | 178 +++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/cmd/skills.ts diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 4a594a3325..d8df324949 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,6 +8,7 @@ import { build } from './src/cmd/build.js'; import { deploy } from './src/cmd/deploy.js'; import { init } from './src/cmd/init.js'; import { serve } from './src/cmd/serve.js'; +import { install as installSkills } from './src/cmd/skills.js'; import packageJson from './package.json' with { type: 'json' }; const CLI_VERSION = packageJson.version; @@ -68,14 +69,14 @@ program .addOption( program.createOption('-o, --one-page [file]', 'build and serve only a single page in the site initially, ' - + 'building more pages when they are navigated to. Also lazily rebuilds only ' - + 'the page being viewed when there are changes to the source files (if needed), ' - + 'building others when navigated to')) + + 'building more pages when they are navigated to. Also lazily rebuilds only ' + + 'the page being viewed when there are changes to the source files (if needed), ' + + 'building others when navigated to')) .addOption( program.createOption('-b, --background-build', 'when --one-page is specified, enhances one-page serve by building ' - + 'remaining pages in the background')) + + 'remaining pages in the background')) .optionsGroup('Server Options') .addOption( @@ -122,4 +123,30 @@ program deploy(userSpecifiedRoot, options); }); +const skillsCmd = program + .commandsGroup('Setup Commands') + .command('skills') + .summary('Manage AI coding skills for this project') + .description('Download and manage AI coding skills from the MarkBind skills repository'); + +skillsCmd + .command('install') + .option('--ref ', 'specify a git ref (tag or branch) instead of auto-resolving from MarkBind version') + .option('--force', 'overwrite existing skills') + .summary('Install AI coding skills into .claude/skills/') + .description('Download skills from MarkBind/markbind-skills and install into .claude/skills/') + .action((options) => { + installSkills(options); + }); + +skillsCmd + .command('update') + .option('--ref ', 'specify a git ref (tag or branch) instead of auto-resolving from MarkBind version') + .summary('Update installed skills to match current MarkBind version') + .description('Re-download skills matching the current MarkBind CLI version,' + + 'overwriting any existing installation') + .action((options) => { + installSkills({ ...options, force: true }); + }); + program.parse(process.argv); diff --git a/packages/cli/package.json b/packages/cli/package.json index 398fdc1d89..a034ff9b96 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,6 +3,7 @@ "version": "7.0.0", "type": "module", "description": "Command line interface for MarkBind", + "aiSkillsVersion": "0.1.0", "keywords": [ "mark", "markdown", diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts new file mode 100644 index 0000000000..c35654259d --- /dev/null +++ b/packages/cli/src/cmd/skills.ts @@ -0,0 +1,178 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import _ from 'lodash'; + +import * as logger from '../util/logger.js'; +import packageJson from '../../package.json' with { type: 'json' }; + +const execFileAsync = promisify(execFile); + +const METADATA_FILE = '.markbind-skills.json'; +const SKILLS_REPO = 'https://github.com/MarkBind/skills.git'; +// TODO: Make this configurable to allow installing to other locations +// also scan the list of locations for skills to ensure we can update them +// seamlessly +const SKILLS_TARGET = path.join('.claude', 'skills'); +const SKILL_MARKER = 'SKILL.md'; +const CLONE_TIMEOUT_MS = 30000; + +interface SkillsInstallOptions { + ref?: string; + force?: boolean; +} + +interface SkillsMetadata { + ref: string; + skills: string[]; + installedAt: string; +} + +async function findSkillDirs(baseDir: string): Promise { + const entries = await fs.readdir(baseDir, { withFileTypes: true }); + const results = await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory()) return null; + const skillMdPath = path.join(baseDir, entry.name, SKILL_MARKER); + if (await fs.pathExists(skillMdPath)) return entry.name; + return null; + }), + ); + return results.filter((name): name is string => name !== null); +} + +async function writeMetadata(targetDir: string, skillsRef: string, skillNames: string[]) { + const metadata: SkillsMetadata = { + ref: skillsRef, + skills: skillNames, + installedAt: new Date().toISOString(), + }; + const metadataPath = path.join(targetDir, METADATA_FILE); + await fs.writeJson(metadataPath, metadata, { spaces: 2 }); +} + +async function readMetadata(targetDir: string): Promise { + const metadataPath = path.join(targetDir, METADATA_FILE); + if (await fs.pathExists(metadataPath)) { + try { + return await fs.readJson(metadataPath); + } catch { + logger.warn('Failed to read metadata file. It may be corrupted. Will attempt to proceed without it.'); + return null; + } + } else { + logger.info('Metadata file does not exist. Will attempt to proceed without it.'); + return null; + } +} + +function isSemverTag(ref: string): boolean { + return /^v\d+\.\d+\.\d+$/.test(ref); +} + +// Expects versions in format vX.Y.Z or X.Y.Z, compares them numerically +function compareSemver(v1: string, v2: string): number { + const parse = (v: string) => v.replace('v', '') + .split('.') + .map(num => parseInt(num, 10)); + const v1Parsed = parse(v1); + const v2Parsed = parse(v2); + for (let i = 0; i < Math.max(v1Parsed.length, v2Parsed.length); i += 1) { + const num1 = v1Parsed[i] || 0; + const num2 = v2Parsed[i] || 0; + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + return 0; +} + +async function install(options: SkillsInstallOptions) { + const ref = options.ref || `v${packageJson.aiSkillsVersion}`; + const targetDir = path.resolve(process.cwd(), SKILLS_TARGET); + + // Check git is available + try { + await execFileAsync('git', ['--version']); + } catch { + logger.error('Git is required but was not found on your PATH. Please install git and try again.'); + process.exitCode = 1; + return; + } + + // Check if already installed + if (await fs.pathExists(targetDir) && !options.force) { + const metadata = await readMetadata(targetDir); + if (metadata) { + // If the existing ref is not a semver tag (e.g. master) we require force + // flag to update, otherwise we allow updating if the new ref is a semver + // tag that is newer than the existing one + if (!isSemverTag(metadata.ref) || (isSemverTag(ref) && compareSemver(metadata.ref, ref) >= 0)) { + logger.info(`Skills already installed (ref ${metadata.ref}). Use --force to reinstall.`); + return; + } + logger.info(`Upgrading skills from version ${metadata.ref} to ${ref}`); + } + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'markbind-skills-')); + + try { + logger.info(`Downloading skills (${ref})...`); + + await execFileAsync( + 'git', + ['clone', '--depth', '1', '--branch', ref, SKILLS_REPO, tempDir], + { timeout: CLONE_TIMEOUT_MS }, + ); + + // Skills may be at repo root or in a skills/ subdirectory + const skillsSubdir = path.join(tempDir, 'skills'); + const searchDir = await fs.pathExists(skillsSubdir) ? skillsSubdir : tempDir; + + const skillNames = await findSkillDirs(searchDir); + + if (skillNames.length === 0) { + logger.error('No skills found in the downloaded repository.'); + process.exitCode = 1; + return; + } + + // Clear existing skills + await fs.rm(targetDir, { recursive: true, force: true }); + await fs.ensureDir(targetDir); + + const copyPromises = skillNames.map((name) => { + logger.info(`Installing skill: ${name}...`); + return fs.copy(path.join(searchDir, name), path.join(targetDir, name)); + }); + + await Promise.all(copyPromises); + + await writeMetadata(targetDir, ref, skillNames); + + logger.info(`Installed ${skillNames.length} skill(s) to ${SKILLS_TARGET}/`); + } catch (error) { + if (_.isError(error)) { + const msg = error.message; + if ((msg.includes('Remote branch') && msg.includes('not found')) + || msg.includes('not found in upstream') + || msg.includes('does not exist')) { + logger.error(`Skills ref '${ref}' was not found in the repository.`); + logger.error('Use --ref to specify a branch or tag (e.g., --ref main).'); + } else if (msg.includes('timed out') || msg.includes('block timeout')) { + logger.error('Download timed out. Check your network connection and try again.'); + } else { + logger.error(`Failed to install skills: ${msg}`); + } + } else { + logger.error(`Failed to install skills: ${error}`); + } + process.exitCode = 1; + } finally { + await fs.remove(tempDir).catch(() => { }); + } +} + +export { install }; From 4e739550cf125fbf4cc1d910dcfacf85aa2967c2 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 16:20:40 +0800 Subject: [PATCH 02/11] Add symlink logic for each agent --- package-lock.json | 405 ++++++++++++++++++++++++++++++++- packages/cli/index.ts | 61 ++++- packages/cli/package.json | 1 + packages/cli/src/cmd/skills.ts | 20 +- 4 files changed, 476 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index b997167212..a706ca71cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2556,6 +2556,195 @@ "node": ">=6.9.0" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.3.tgz", + "integrity": "sha512-+G7I8CT+EHv/hasNfUl3P37DVoMoZfpA+2FXmM54dA8MxYle1YqucxbacxHalw1iAFSdKNEDTGNV7F+j1Ldqcg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.11.tgz", + "integrity": "sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.8.tgz", + "integrity": "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.0.tgz", + "integrity": "sha512-6wlkYl65Qfayy48gPCfU4D7li6KCAGN79mLXa/tYHZH99OfZ820yY+HA+DgE88r8YwwgeuY6PQgNqMeK6LuMmw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/external-editor": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.12.tgz", + "integrity": "sha512-vOfrB33b7YIZfDauXS8vNNz2Z86FozTZLIt7e+7/dCaPJ1RXZsHCuI9TlcERzEUq57vkM+UdnBgxP0rFd23JYQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "dev": true, @@ -2591,6 +2780,191 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.11.tgz", + "integrity": "sha512-twUWidn4ocPO8qi6fRM7tNWt7W1FOnOZqQ+/+PsfLUacMR5rFLDPK9ql0nBPwxi0oELbo8T5NhRs8B2+qQEqFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.11.tgz", + "integrity": "sha512-Vscmim9TCksQsfjPtka/JwPUcbLhqWYrgfPf1cHrCm24X/F2joFwnageD50yMKsaX14oNGOyKf/RNXAFkNjWpA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.11.tgz", + "integrity": "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.1.tgz", + "integrity": "sha512-AH5xPQ997K7e0F0vulPlteIHke2awMkFi8F0dBemrDfmvtPmHJo82mdHbONC4F/t8d1NHwrbI5cGVI+RbLWdoQ==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.3", + "@inquirer/confirm": "^6.0.11", + "@inquirer/editor": "^5.1.0", + "@inquirer/expand": "^5.0.12", + "@inquirer/input": "^5.0.11", + "@inquirer/number": "^4.0.11", + "@inquirer/password": "^5.0.11", + "@inquirer/rawlist": "^5.2.7", + "@inquirer/search": "^4.1.7", + "@inquirer/select": "^5.1.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.7.tgz", + "integrity": "sha512-AqRMiD9+uE1lskDPrdqHwrV/EUmxKEBLX44SR7uxK3vD2413AmVfE5EQaPeNzYf5Pq5SitHJDYUFVF0poIr09w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.7.tgz", + "integrity": "sha512-1y7+0N65AWk5RdlXH/Kn13txf3IjIQ7OEfhCEkDTU+h5wKMLq8DUF3P6z+/kLSxDGDtQT1dRBWEUC3o/VvImsQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.3.tgz", + "integrity": "sha512-zYyqWgGQi3NhBcNq4Isc5rB3oEdQEh1Q/EcAnOW0FK4MpnXWkvSBYgA4cYrTM4A9UB573omouZbnL9JJ74Mq3A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -5306,7 +5680,7 @@ }, "node_modules/@types/node": { "version": "22.19.11", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7527,7 +7901,6 @@ }, "node_modules/chardet": { "version": "2.1.1", - "dev": true, "license": "MIT" }, "node_modules/cheerio": { @@ -10328,6 +10701,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.0.6", "dev": true, @@ -10343,6 +10731,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, @@ -18597,7 +18994,6 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/schema-utils": { @@ -20895,7 +21291,7 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -22033,6 +22429,7 @@ "version": "7.0.0", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.4.1", "@markbind/core": "7.0.0", "@markbind/core-web": "7.0.0", "chalk": "^3.0.0", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index d8df324949..af16c1ac5a 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -10,6 +10,7 @@ import { init } from './src/cmd/init.js'; import { serve } from './src/cmd/serve.js'; import { install as installSkills } from './src/cmd/skills.js'; import packageJson from './package.json' with { type: 'json' }; +import { checkbox } from '@inquirer/prompts'; const CLI_VERSION = packageJson.version; @@ -129,14 +130,68 @@ const skillsCmd = program .summary('Manage AI coding skills for this project') .description('Download and manage AI coding skills from the MarkBind skills repository'); +const agentChoices = + [ + { name: "Augment", value: ".augment" }, + { name: "IBM Bob", value: ".bob" }, + { name: "Claude Code", value: ".claude" }, + { name: "OpenClaw", value: "." }, + { name: "CodeBuddy", value: ".codebuddy" }, + { name: "Command Code", value: ".commandcode" }, + { name: "Continue", value: ".continue" }, + { name: "Cortex Code", value: ".cortex" }, + { name: "Crush", value: ".crush" }, + { name: "Droid", value: ".factory" }, + { name: "Goose", value: ".goose" }, + { name: "Junie", value: ".junie" }, + { name: "iFlow CLI", value: ".iflow" }, + { name: "Kilo Code", value: ".kilocode" }, + { name: "Kiro CLI", value: ".kiro" }, + { name: "Kode", value: ".kode" }, + { name: "MCPJam", value: ".mcpjam" }, + { name: "Mistral Vibe", value: ".vibe" }, + { name: "Mux", value: ".mux" }, + { name: "OpenHands", value: ".openhands" }, + { name: "Pi", value: ".pi" }, + { name: "Qoder", value: ".qoder" }, + { name: "Qwen Code", value: ".qwen" }, + { name: "Roo Code", value: ".roo" }, + { name: "Trae", value: ".trae" }, + { name: "Trae CN", value: ".trae" }, + { name: "Windsurf", value: ".windsurf" }, + { name: "Zencoder", value: ".zencoder" }, + { name: "Neovate", value: ".neovate" }, + { name: "Pochi", value: ".pochi" }, + { name: "AdaL", value: ".adal" } + ]; + skillsCmd .command('install') .option('--ref ', 'specify a git ref (tag or branch) instead of auto-resolving from MarkBind version') .option('--force', 'overwrite existing skills') .summary('Install AI coding skills into .claude/skills/') .description('Download skills from MarkBind/markbind-skills and install into .claude/skills/') - .action((options) => { - installSkills(options); + .action(async (options) => { + const agent = await checkbox({ + message: ` +── Universal (.agents/skills) ── always included ──────────── + • Amp + • Antigravity + • Cline + • Codex + • Cursor + • Deep Agents + • Firebender + • Gemini CLI + • GitHub Copilot + • Kimi Code CLI + • OpenCode + • Warp + +── Additional agents ─────────────────────────────`, + choices: agentChoices, + }) + installSkills({ ...options, agents: agent }); }); skillsCmd @@ -145,7 +200,7 @@ skillsCmd .summary('Update installed skills to match current MarkBind version') .description('Re-download skills matching the current MarkBind CLI version,' + 'overwriting any existing installation') - .action((options) => { + .action(async (options) => { installSkills({ ...options, force: true }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index a034ff9b96..e76a6f02eb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "dev": "tsc --watch" }, "dependencies": { + "@inquirer/prompts": "^8.4.1", "@markbind/core": "7.0.0", "@markbind/core-web": "7.0.0", "chalk": "^3.0.0", diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts index c35654259d..739f3e94f4 100644 --- a/packages/cli/src/cmd/skills.ts +++ b/packages/cli/src/cmd/skills.ts @@ -12,16 +12,14 @@ const execFileAsync = promisify(execFile); const METADATA_FILE = '.markbind-skills.json'; const SKILLS_REPO = 'https://github.com/MarkBind/skills.git'; -// TODO: Make this configurable to allow installing to other locations -// also scan the list of locations for skills to ensure we can update them -// seamlessly -const SKILLS_TARGET = path.join('.claude', 'skills'); +const SKILLS_TARGET = path.join('.agents', 'skills'); const SKILL_MARKER = 'SKILL.md'; const CLONE_TIMEOUT_MS = 30000; interface SkillsInstallOptions { ref?: string; force?: boolean; + agents?: string[]; } interface SkillsMetadata { @@ -153,6 +151,20 @@ async function install(options: SkillsInstallOptions) { await writeMetadata(targetDir, ref, skillNames); logger.info(`Installed ${skillNames.length} skill(s) to ${SKILLS_TARGET}/`); + + if (options.agents) { + Promise.all(options.agents.map(async (agent) => { + const agentSkillsDir = path.join(process.cwd(), agent, "skills"); + if (await fs.pathExists(agentSkillsDir)) { + logger.warn('Agent skills directory already exist. Skipping symlink creation.'); + logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir} if you want to use the skills with ${options.agents}.`); + } else { + await fs.ensureSymlink(targetDir, agentSkillsDir, 'dir') + logger.info(`Symlinked skills to ${agent}/skills/`); + } + })); + } + } catch (error) { if (_.isError(error)) { const msg = error.message; From 07cbcf4c1fed04c8d31b06ab49536af8fda04812 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 19:28:16 +0800 Subject: [PATCH 03/11] Add tests --- packages/cli/index.ts | 70 ++-- packages/cli/src/cmd/skills.ts | 12 +- packages/cli/test/unit/skills.test.ts | 522 ++++++++++++++++++++++++++ 3 files changed, 564 insertions(+), 40 deletions(-) create mode 100644 packages/cli/test/unit/skills.test.ts diff --git a/packages/cli/index.ts b/packages/cli/index.ts index af16c1ac5a..789ee3c256 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -3,6 +3,7 @@ // Entry file for MarkBind project import { program, Option } from 'commander'; import chalk from 'chalk'; +import { checkbox } from '@inquirer/prompts'; import * as logger from './src/util/logger.js'; import { build } from './src/cmd/build.js'; import { deploy } from './src/cmd/deploy.js'; @@ -10,7 +11,6 @@ import { init } from './src/cmd/init.js'; import { serve } from './src/cmd/serve.js'; import { install as installSkills } from './src/cmd/skills.js'; import packageJson from './package.json' with { type: 'json' }; -import { checkbox } from '@inquirer/prompts'; const CLI_VERSION = packageJson.version; @@ -130,39 +130,39 @@ const skillsCmd = program .summary('Manage AI coding skills for this project') .description('Download and manage AI coding skills from the MarkBind skills repository'); -const agentChoices = - [ - { name: "Augment", value: ".augment" }, - { name: "IBM Bob", value: ".bob" }, - { name: "Claude Code", value: ".claude" }, - { name: "OpenClaw", value: "." }, - { name: "CodeBuddy", value: ".codebuddy" }, - { name: "Command Code", value: ".commandcode" }, - { name: "Continue", value: ".continue" }, - { name: "Cortex Code", value: ".cortex" }, - { name: "Crush", value: ".crush" }, - { name: "Droid", value: ".factory" }, - { name: "Goose", value: ".goose" }, - { name: "Junie", value: ".junie" }, - { name: "iFlow CLI", value: ".iflow" }, - { name: "Kilo Code", value: ".kilocode" }, - { name: "Kiro CLI", value: ".kiro" }, - { name: "Kode", value: ".kode" }, - { name: "MCPJam", value: ".mcpjam" }, - { name: "Mistral Vibe", value: ".vibe" }, - { name: "Mux", value: ".mux" }, - { name: "OpenHands", value: ".openhands" }, - { name: "Pi", value: ".pi" }, - { name: "Qoder", value: ".qoder" }, - { name: "Qwen Code", value: ".qwen" }, - { name: "Roo Code", value: ".roo" }, - { name: "Trae", value: ".trae" }, - { name: "Trae CN", value: ".trae" }, - { name: "Windsurf", value: ".windsurf" }, - { name: "Zencoder", value: ".zencoder" }, - { name: "Neovate", value: ".neovate" }, - { name: "Pochi", value: ".pochi" }, - { name: "AdaL", value: ".adal" } +const agentChoices + = [ + { name: 'Augment', value: '.augment' }, + { name: 'IBM Bob', value: '.bob' }, + { name: 'Claude Code', value: '.claude' }, + { name: 'OpenClaw', value: '.' }, + { name: 'CodeBuddy', value: '.codebuddy' }, + { name: 'Command Code', value: '.commandcode' }, + { name: 'Continue', value: '.continue' }, + { name: 'Cortex Code', value: '.cortex' }, + { name: 'Crush', value: '.crush' }, + { name: 'Droid', value: '.factory' }, + { name: 'Goose', value: '.goose' }, + { name: 'Junie', value: '.junie' }, + { name: 'iFlow CLI', value: '.iflow' }, + { name: 'Kilo Code', value: '.kilocode' }, + { name: 'Kiro CLI', value: '.kiro' }, + { name: 'Kode', value: '.kode' }, + { name: 'MCPJam', value: '.mcpjam' }, + { name: 'Mistral Vibe', value: '.vibe' }, + { name: 'Mux', value: '.mux' }, + { name: 'OpenHands', value: '.openhands' }, + { name: 'Pi', value: '.pi' }, + { name: 'Qoder', value: '.qoder' }, + { name: 'Qwen Code', value: '.qwen' }, + { name: 'Roo Code', value: '.roo' }, + { name: 'Trae', value: '.trae' }, + { name: 'Trae CN', value: '.trae' }, + { name: 'Windsurf', value: '.windsurf' }, + { name: 'Zencoder', value: '.zencoder' }, + { name: 'Neovate', value: '.neovate' }, + { name: 'Pochi', value: '.pochi' }, + { name: 'AdaL', value: '.adal' }, ]; skillsCmd @@ -190,7 +190,7 @@ skillsCmd ── Additional agents ─────────────────────────────`, choices: agentChoices, - }) + }); installSkills({ ...options, agents: agent }); }); diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts index 739f3e94f4..ef1335dd4d 100644 --- a/packages/cli/src/cmd/skills.ts +++ b/packages/cli/src/cmd/skills.ts @@ -154,17 +154,17 @@ async function install(options: SkillsInstallOptions) { if (options.agents) { Promise.all(options.agents.map(async (agent) => { - const agentSkillsDir = path.join(process.cwd(), agent, "skills"); + const agentSkillsDir = path.join(process.cwd(), agent, 'skills'); if (await fs.pathExists(agentSkillsDir)) { logger.warn('Agent skills directory already exist. Skipping symlink creation.'); - logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir} if you want to use the skills with ${options.agents}.`); + logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir}" + + " if you want to use the skills with ${options.agents}.`); } else { - await fs.ensureSymlink(targetDir, agentSkillsDir, 'dir') + await fs.ensureSymlink(targetDir, agentSkillsDir, 'dir'); logger.info(`Symlinked skills to ${agent}/skills/`); } })); } - } catch (error) { if (_.isError(error)) { const msg = error.message; @@ -187,4 +187,6 @@ async function install(options: SkillsInstallOptions) { } } -export { install }; +export { + install, findSkillDirs, writeMetadata, readMetadata, isSemverTag, compareSemver, +}; diff --git a/packages/cli/test/unit/skills.test.ts b/packages/cli/test/unit/skills.test.ts new file mode 100644 index 0000000000..53b0f17cd9 --- /dev/null +++ b/packages/cli/test/unit/skills.test.ts @@ -0,0 +1,522 @@ +import path from 'path'; +import os from 'os'; +import { vol, fs as memfs } from 'memfs'; + +import { execFile } from 'child_process'; +import _ from 'lodash'; +import * as logger from '../../src/util/logger.js'; +import { + install, + findSkillDirs, + writeMetadata, + readMetadata, + isSemverTag, + compareSemver, +} from '../../src/cmd/skills.js'; + +jest.mock('fs-extra', () => { + const pathModule = jest.requireActual('path'); + const { fs } = jest.requireActual('memfs'); + + const copyRecursive = async (src: string, dest: string) => { + const stats = await fs.promises.lstat(src); + if (stats.isDirectory()) { + await fs.promises.mkdir(dest, { recursive: true }); + const entries = await fs.promises.readdir(src); + await Promise.all(entries.map((entry: string) => copyRecursive( + pathModule.join(src, entry), + pathModule.join(dest, entry), + ))); + return; + } + + await fs.promises.mkdir(pathModule.dirname(dest), { recursive: true }); + await fs.promises.copyFile(src, dest); + }; + + return { + __esModule: true, + default: { + readdir: (dir: string, options?: unknown) => fs.promises.readdir(dir, options as never), + pathExists: async (filePath: string) => fs.existsSync(filePath), + writeJson: async (filePath: string, data: unknown, options?: { spaces?: number }) => { + await fs.promises.mkdir(pathModule.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify(data, null, options?.spaces ?? 0)); + }, + readJson: async (filePath: string) => JSON.parse(await fs.promises.readFile(filePath, 'utf8')), + mkdtemp: (prefix: string) => fs.promises.mkdtemp(prefix), + rm: (filePath: string, options?: unknown) => fs.promises.rm(filePath, options as never), + ensureDir: (dir: string) => fs.promises.mkdir(dir, { recursive: true }), + copy: copyRecursive, + remove: (filePath: string) => fs.promises.rm(filePath, { recursive: true, force: true }), + ensureSymlink: async (target: string, filePath: string, type: unknown) => { + await fs.promises.mkdir(pathModule.dirname(filePath), { recursive: true }); + await fs.promises.symlink(target, filePath, type as never); + }, + }, + }; +}); + +jest.mock('child_process', () => ({ + execFile: jest.fn(), +})); + +jest.mock('../../src/util/logger.js', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), +})); + +type ExecCallback = (error: Error | null, stdout?: string, stderr?: string) => void; + +const mockExecFile = execFile as unknown as jest.Mock; + +const mockedLogger = logger as jest.Mocked; + +const WORKDIR = '/workspace/project'; +const TMPDIR = '/tmp'; + +const flushPromises = () => new Promise(process.nextTick); + +function setExecFileMock( + impl: (file: string, args: string[], cb: ExecCallback, options?: { timeout?: number }) => void, +) { + mockExecFile.mockImplementation( + (file: string, args: string[], optionsOrCb: unknown, cbMaybe?: ExecCallback) => { + const cb = _.isFunction(optionsOrCb) + ? optionsOrCb as ExecCallback + : cbMaybe as ExecCallback; + const options = _.isFunction(optionsOrCb) ? undefined : optionsOrCb as { timeout?: number }; + impl(file, args, cb, options); + }); +} + +function seedClonedSkills(tempDir: string, skillNames: string[], underSkillsSubdir = false) { + const root = underSkillsSubdir ? path.join(tempDir, 'skills') : tempDir; + const files = Object.fromEntries(skillNames.map(name => [path.join(root, name, 'SKILL.md'), `# ${name}`])); + vol.fromJSON(files, '/'); +} + +function listTmpSkillCloneDirs(): string[] { + if (!memfs.existsSync(TMPDIR)) { + return []; + } + const tmpEntries = memfs.readdirSync(TMPDIR, 'utf8') as string[]; + return tmpEntries.filter(name => name.startsWith('markbind-skills-')); +} + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ + [path.join(TMPDIR, '.keep')]: '', + [path.join(WORKDIR, '.keep')]: '', + }, '/'); + jest.resetAllMocks(); + jest.spyOn(os, 'tmpdir').mockReturnValue(TMPDIR); + jest.spyOn(process, 'cwd').mockReturnValue(WORKDIR); + process.exitCode = undefined; +}); + +afterEach(() => { + vol.reset(); + jest.restoreAllMocks(); + process.exitCode = undefined; +}); + +describe('isSemverTag', () => { + test.each([ + ['v1.0.0', true], + ['v12.34.56', true], + ['1.0.0', false], + ['v1.0', false], + ['main', false], + ['v1.0.0-beta', false], + ['', false], + ])('returns %p for %p', (tag, expected) => { + expect(isSemverTag(tag)).toBe(expected); + }); +}); + +describe('compareSemver', () => { + test('returns 0 for equal versions', () => { + expect(compareSemver('v1.2.3', 'v1.2.3')).toBe(0); + }); + + test('compares major versions correctly', () => { + expect(compareSemver('v2.0.0', 'v1.9.9')).toBe(1); + expect(compareSemver('v1.0.0', 'v2.0.0')).toBe(-1); + }); + + test('compares minor versions correctly', () => { + expect(compareSemver('v1.4.0', 'v1.3.9')).toBe(1); + expect(compareSemver('v1.2.0', 'v1.3.0')).toBe(-1); + }); + + test('compares patch versions correctly', () => { + expect(compareSemver('v1.2.4', 'v1.2.3')).toBe(1); + expect(compareSemver('v1.2.3', 'v1.2.4')).toBe(-1); + }); + + test('handles missing and mixed v prefixes', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + expect(compareSemver('v1.2.3', '1.2.3')).toBe(0); + }); + + test('compares multi-digit parts numerically', () => { + expect(compareSemver('v1.10.0', 'v1.9.0')).toBe(1); + }); +}); + +describe('findSkillDirs', () => { + test('finds only directories containing SKILL.md', async () => { + vol.fromJSON({ + '/skills/alpha/SKILL.md': '# alpha', + '/skills/beta/README.md': '# beta', + '/skills/gamma/SKILL.md': '# gamma', + '/skills/not-a-dir.md': 'file', + }, '/'); + + await expect(findSkillDirs('/skills')).resolves.toEqual(['alpha', 'gamma']); + }); + + test('returns empty array when directory has no skill folders', async () => { + vol.fromJSON({ + '/skills/README.md': 'root file', + }, '/'); + + await expect(findSkillDirs('/skills')).resolves.toEqual([]); + }); +}); + +describe('writeMetadata and readMetadata', () => { + test('round-trips metadata', async () => { + const target = '/skills-target'; + + await writeMetadata(target, 'v1.2.3', ['one', 'two']); + const metadata = await readMetadata(target); + + expect(metadata).toEqual({ + ref: 'v1.2.3', + skills: ['one', 'two'], + installedAt: expect.any(String), + }); + expect(new Date(metadata!.installedAt).toISOString()).toBe(metadata!.installedAt); + }); + + test('returns null and logs info when metadata file is missing', async () => { + await expect(readMetadata('/missing')).resolves.toBeNull(); + expect(mockedLogger.info).toHaveBeenCalledWith( + 'Metadata file does not exist. Will attempt to proceed without it.', + ); + }); + + test('returns null and logs warn for corrupted metadata', async () => { + vol.fromJSON({ + '/skills/.markbind-skills.json': '{not-json', + }, '/'); + + await expect(readMetadata('/skills')).resolves.toBeNull(); + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Failed to read metadata file. It may be corrupted. Will attempt to proceed without it.', + ); + }); +}); + +describe('install', () => { + test('installs with default ref from packageJson aiSkillsVersion', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, 'git version 2.43.0', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['skill-a', 'skill-b']); + cb(null, '', ''); + }); + + await install({}); + + const cloneCall = mockExecFile.mock.calls.find(([, args]) => args[0] === 'clone'); + expect(cloneCall).toBeDefined(); + expect(cloneCall![1]).toEqual(expect.arrayContaining(['--branch', 'v0.1.0'])); + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/skill-a/SKILL.md'))).toBe(true); + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/skill-b/SKILL.md'))).toBe(true); + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/.markbind-skills.json'))).toBe(true); + expect(process.exitCode).toBeUndefined(); + }); + + test('installs with custom ref', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['skill-a']); + cb(null, '', ''); + }); + + await install({ ref: 'main' }); + + const cloneCall = mockExecFile.mock.calls.find(([, args]) => args[0] === 'clone'); + expect(cloneCall![1]).toEqual(expect.arrayContaining(['--branch', 'main'])); + }); + + test('sets exitCode when git is not found', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(new Error('not found')); + } + }); + + await install({}); + + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Git is required but was not found on your PATH. Please install git and try again.', + ); + expect(process.exitCode).toBe(1); + }); + + test('skips install when same version is already installed', async () => { + vol.fromJSON({ + [path.join(WORKDIR, '.agents/skills/.markbind-skills.json')]: JSON.stringify({ + ref: 'v0.1.0', + skills: ['existing'], + installedAt: new Date().toISOString(), + }), + }, '/'); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + } + }); + + await install({}); + + expect(mockedLogger.info).toHaveBeenCalledWith( + 'Skills already installed (ref v0.1.0). Use --force to reinstall.', + ); + expect(mockExecFile.mock.calls.some(([, args]) => args[0] === 'clone')).toBe(false); + }); + + test('reinstalls with --force', async () => { + vol.fromJSON({ + [path.join(WORKDIR, '.agents/skills/.markbind-skills.json')]: JSON.stringify({ + ref: 'v0.1.0', + skills: ['old-skill'], + installedAt: new Date().toISOString(), + }), + [path.join(WORKDIR, '.agents/skills/old-skill/SKILL.md')]: '# old', + }, '/'); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['new-skill']); + cb(null, '', ''); + }); + + await install({ force: true }); + + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/new-skill/SKILL.md'))).toBe(true); + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/old-skill/SKILL.md'))).toBe(false); + }); + + test('upgrades when new semver ref is newer', async () => { + vol.fromJSON({ + [path.join(WORKDIR, '.agents/skills/.markbind-skills.json')]: JSON.stringify({ + ref: 'v0.0.9', + skills: ['existing'], + installedAt: new Date().toISOString(), + }), + }, '/'); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['upgraded']); + cb(null, '', ''); + }); + + await install({}); + + expect(mockedLogger.info).toHaveBeenCalledWith('Upgrading skills from version v0.0.9 to v0.1.0'); + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/upgraded/SKILL.md'))).toBe(true); + }); + + test('skips when existing ref is non-semver without force', async () => { + vol.fromJSON({ + [path.join(WORKDIR, '.agents/skills/.markbind-skills.json')]: JSON.stringify({ + ref: 'main', + skills: ['existing'], + installedAt: new Date().toISOString(), + }), + }, '/'); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + } + }); + + await install({}); + + expect(mockedLogger.info).toHaveBeenCalledWith( + 'Skills already installed (ref main). Use --force to reinstall.', + ); + expect(mockExecFile.mock.calls.some(([, args]) => args[0] === 'clone')).toBe(false); + }); + + test('sets exitCode when no skills are found', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + vol.fromJSON({ [path.join(args[args.length - 1], 'README.md')]: '# repo' }, '/'); + cb(null, '', ''); + }); + + await install({}); + + expect(mockedLogger.error).toHaveBeenCalledWith('No skills found in the downloaded repository.'); + expect(process.exitCode).toBe(1); + }); + + test('finds skills in skills/ subdirectory', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['nested-skill'], true); + cb(null, '', ''); + }); + + await install({}); + + expect(memfs.existsSync(path.join(WORKDIR, '.agents/skills/nested-skill/SKILL.md'))).toBe(true); + }); + + test('handles ref-not-found errors', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + cb(new Error('Remote branch no-such-branch not found in upstream origin')); + }); + + await install({ ref: 'no-such-branch' }); + + expect(mockedLogger.error).toHaveBeenCalledWith( + "Skills ref 'no-such-branch' was not found in the repository.", + ); + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Use --ref to specify a branch or tag (e.g., --ref main).', + ); + expect(process.exitCode).toBe(1); + }); + + test('handles timeout errors', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + cb(new Error('command timed out after 30000 milliseconds')); + }); + + await install({}); + + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Download timed out. Check your network connection and try again.', + ); + expect(process.exitCode).toBe(1); + }); + + test('handles generic errors', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + cb(new Error('boom')); + }); + + await install({}); + + expect(mockedLogger.error).toHaveBeenCalledWith('Failed to install skills: boom'); + expect(process.exitCode).toBe(1); + }); + + test('cleans up temporary clone directory on success and failure', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['cleanup']); + cb(null, '', ''); + }); + + await install({}); + expect(listTmpSkillCloneDirs()).toEqual([]); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + cb(new Error('boom')); + }); + + await install({ force: true }); + expect(listTmpSkillCloneDirs()).toEqual([]); + }); + + test('creates symlinks for specified agents', async () => { + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['skill-a']); + cb(null, '', ''); + }); + + await install({ agents: ['.claude', '.cursor'] }); + await flushPromises(); + + const claudeLink = path.join(WORKDIR, '.claude/skills'); + const cursorLink = path.join(WORKDIR, '.cursor/skills'); + expect(memfs.lstatSync(claudeLink).isSymbolicLink()).toBe(true); + expect(memfs.lstatSync(cursorLink).isSymbolicLink()).toBe(true); + expect(memfs.readlinkSync(claudeLink).toString()).toBe(path.join(WORKDIR, '.agents/skills')); + }); + + test('warns when agent skills directory already exists', async () => { + vol.fromJSON({ + [path.join(WORKDIR, '.claude/skills/existing.txt')]: 'x', + }, '/'); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, '', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['skill-a']); + cb(null, '', ''); + }); + + await install({ agents: ['.claude'] }); + await flushPromises(); + + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Agent skills directory already exist. Skipping symlink creation.', + ); + }); +}); From e690a9089094a182d90cec751a00e86c492060d3 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 22:12:27 +0800 Subject: [PATCH 04/11] Improve error handling --- packages/cli/src/cmd/skills.ts | 36 ++++++++++++++++----------- packages/cli/test/unit/skills.test.ts | 14 +++-------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts index ef1335dd4d..d18fe83c64 100644 --- a/packages/cli/src/cmd/skills.ts +++ b/packages/cli/src/cmd/skills.ts @@ -51,18 +51,16 @@ async function writeMetadata(targetDir: string, skillsRef: string, skillNames: s await fs.writeJson(metadataPath, metadata, { spaces: 2 }); } -async function readMetadata(targetDir: string): Promise { +async function readMetadata(targetDir: string): Promise { const metadataPath = path.join(targetDir, METADATA_FILE); if (await fs.pathExists(metadataPath)) { try { return await fs.readJson(metadataPath); - } catch { - logger.warn('Failed to read metadata file. It may be corrupted. Will attempt to proceed without it.'); - return null; + } catch (e) { + throw new Error(`Failed to read metadata file: ${(e as Error).message}`); } } else { - logger.info('Metadata file does not exist. Will attempt to proceed without it.'); - return null; + throw new Error('Metadata file not found'); } } @@ -101,17 +99,25 @@ async function install(options: SkillsInstallOptions) { // Check if already installed if (await fs.pathExists(targetDir) && !options.force) { - const metadata = await readMetadata(targetDir); - if (metadata) { - // If the existing ref is not a semver tag (e.g. master) we require force - // flag to update, otherwise we allow updating if the new ref is a semver - // tag that is newer than the existing one - if (!isSemverTag(metadata.ref) || (isSemverTag(ref) && compareSemver(metadata.ref, ref) >= 0)) { - logger.info(`Skills already installed (ref ${metadata.ref}). Use --force to reinstall.`); - return; + const metadata = await readMetadata(targetDir).catch( + (e) => { + logger.warn(`Failed to read existing skills metadata: ${(e as Error).message}`); + return null; } - logger.info(`Upgrading skills from version ${metadata.ref} to ${ref}`); + ); + if (!metadata) { + logger.info('Skills already installed. Use --force to reinstall.'); + return; + } + + // If the existing ref is not a semver tag (e.g. master) we require force + // flag to update, otherwise we allow updating if the new ref is a semver + // tag that is newer than the existing one + if (!isSemverTag(metadata.ref) || (isSemverTag(ref) && compareSemver(metadata.ref, ref) >= 0)) { + logger.info(`Skills already installed (ref ${metadata.ref}). Use --force to reinstall.`); + return; } + logger.info(`Upgrading skills from version ${metadata.ref} to ${ref}`); } const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'markbind-skills-')); diff --git a/packages/cli/test/unit/skills.test.ts b/packages/cli/test/unit/skills.test.ts index 53b0f17cd9..d9815bdd97 100644 --- a/packages/cli/test/unit/skills.test.ts +++ b/packages/cli/test/unit/skills.test.ts @@ -203,22 +203,16 @@ describe('writeMetadata and readMetadata', () => { expect(new Date(metadata!.installedAt).toISOString()).toBe(metadata!.installedAt); }); - test('returns null and logs info when metadata file is missing', async () => { - await expect(readMetadata('/missing')).resolves.toBeNull(); - expect(mockedLogger.info).toHaveBeenCalledWith( - 'Metadata file does not exist. Will attempt to proceed without it.', - ); + test('throws when metadata file is missing', async () => { + await expect(readMetadata('/missing')).rejects.toThrow('Metadata file not found'); }); - test('returns null and logs warn for corrupted metadata', async () => { + test('throws when metadata file is corrupted', async () => { vol.fromJSON({ '/skills/.markbind-skills.json': '{not-json', }, '/'); - await expect(readMetadata('/skills')).resolves.toBeNull(); - expect(mockedLogger.warn).toHaveBeenCalledWith( - 'Failed to read metadata file. It may be corrupted. Will attempt to proceed without it.', - ); + await expect(readMetadata('/skills')).rejects.toThrow('Failed to read metadata file:'); }); }); From 043d5c607a4b4d4be2cd0cff0335b4248cab09c7 Mon Sep 17 00:00:00 2001 From: Hon Yi Hao <165232024+yihao03@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:37:52 +0800 Subject: [PATCH 05/11] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 789ee3c256..8f5c437b9a 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -135,7 +135,7 @@ const agentChoices { name: 'Augment', value: '.augment' }, { name: 'IBM Bob', value: '.bob' }, { name: 'Claude Code', value: '.claude' }, - { name: 'OpenClaw', value: '.' }, + { name: 'OpenClaw', value: '.openclaw' }, { name: 'CodeBuddy', value: '.codebuddy' }, { name: 'Command Code', value: '.commandcode' }, { name: 'Continue', value: '.continue' }, @@ -169,8 +169,8 @@ skillsCmd .command('install') .option('--ref ', 'specify a git ref (tag or branch) instead of auto-resolving from MarkBind version') .option('--force', 'overwrite existing skills') - .summary('Install AI coding skills into .claude/skills/') - .description('Download skills from MarkBind/markbind-skills and install into .claude/skills/') + .summary('Install AI coding skills into .agents/skills with optional agent symlinks') + .description('Download skills from https://github.com/MarkBind/skills.git, install them into .agents/skills, and optionally create symlinks for selected additional agents') .action(async (options) => { const agent = await checkbox({ message: ` From 9221834a12d666d9e9d1bc16aebc6b197246a9b7 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 22:38:06 +0800 Subject: [PATCH 06/11] Fix bugs --- packages/cli/src/cmd/skills.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts index d18fe83c64..1127309b5c 100644 --- a/packages/cli/src/cmd/skills.ts +++ b/packages/cli/src/cmd/skills.ts @@ -38,7 +38,9 @@ async function findSkillDirs(baseDir: string): Promise { return null; }), ); - return results.filter((name): name is string => name !== null); + return results + .filter((name): name is string => name !== null) + .sort((a, b) => a.localeCompare(b)); } async function writeMetadata(targetDir: string, skillsRef: string, skillNames: string[]) { @@ -103,7 +105,7 @@ async function install(options: SkillsInstallOptions) { (e) => { logger.warn(`Failed to read existing skills metadata: ${(e as Error).message}`); return null; - } + }, ); if (!metadata) { logger.info('Skills already installed. Use --force to reinstall.'); @@ -159,12 +161,12 @@ async function install(options: SkillsInstallOptions) { logger.info(`Installed ${skillNames.length} skill(s) to ${SKILLS_TARGET}/`); if (options.agents) { - Promise.all(options.agents.map(async (agent) => { + await Promise.all(options.agents.map(async (agent) => { const agentSkillsDir = path.join(process.cwd(), agent, 'skills'); if (await fs.pathExists(agentSkillsDir)) { logger.warn('Agent skills directory already exist. Skipping symlink creation.'); - logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir}" + - " if you want to use the skills with ${options.agents}.`); + logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir}` + + ` if you want to use the skills with ${options.agents}.`); } else { await fs.ensureSymlink(targetDir, agentSkillsDir, 'dir'); logger.info(`Symlinked skills to ${agent}/skills/`); From 8d1302cc704dc324e57b806396dc1bc38943a905 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 22:45:00 +0800 Subject: [PATCH 07/11] Lint copilot suggestion --- packages/cli/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 8f5c437b9a..785b0a9657 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -170,9 +170,10 @@ skillsCmd .option('--ref ', 'specify a git ref (tag or branch) instead of auto-resolving from MarkBind version') .option('--force', 'overwrite existing skills') .summary('Install AI coding skills into .agents/skills with optional agent symlinks') - .description('Download skills from https://github.com/MarkBind/skills.git, install them into .agents/skills, and optionally create symlinks for selected additional agents') - .action(async (options) => { - const agent = await checkbox({ + .description('Download skills from https://github.com/MarkBind/skills.git,' + + ' install them into .agents/skills, and optionally create symlinks for selected additional agents') + .action((options) => { + checkbox({ message: ` ── Universal (.agents/skills) ── always included ──────────── • Amp @@ -190,8 +191,9 @@ skillsCmd ── Additional agents ─────────────────────────────`, choices: agentChoices, - }); - installSkills({ ...options, agents: agent }); + }).then(agent => + installSkills({ ...options, agents: agent }), + ); }); skillsCmd @@ -200,7 +202,7 @@ skillsCmd .summary('Update installed skills to match current MarkBind version') .description('Re-download skills matching the current MarkBind CLI version,' + 'overwriting any existing installation') - .action(async (options) => { + .action((options) => { installSkills({ ...options, force: true }); }); From 2e0fab43b7b9c58c5f944a7baa3c877f19339f43 Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 22:52:04 +0800 Subject: [PATCH 08/11] Fix windows bug --- packages/cli/test/unit/skills.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/unit/skills.test.ts b/packages/cli/test/unit/skills.test.ts index d9815bdd97..cd860bc3b6 100644 --- a/packages/cli/test/unit/skills.test.ts +++ b/packages/cli/test/unit/skills.test.ts @@ -489,7 +489,7 @@ describe('install', () => { const cursorLink = path.join(WORKDIR, '.cursor/skills'); expect(memfs.lstatSync(claudeLink).isSymbolicLink()).toBe(true); expect(memfs.lstatSync(cursorLink).isSymbolicLink()).toBe(true); - expect(memfs.readlinkSync(claudeLink).toString()).toBe(path.join(WORKDIR, '.agents/skills')); + expect(memfs.readlinkSync(claudeLink).toString()).toBe(path.resolve(WORKDIR, '.agents/skills')); }); test('warns when agent skills directory already exists', async () => { From d87d06cf44921f8a0c7d5d867b8b4f024ef7b39e Mon Sep 17 00:00:00 2001 From: yihao Date: Mon, 13 Apr 2026 23:13:31 +0800 Subject: [PATCH 09/11] Update docs --- docs/userGuide/cliCommands.md | 45 ++++++++++++++++++++++++++++++++ docs/userGuide/gettingStarted.md | 6 +++++ 2 files changed, 51 insertions(+) diff --git a/docs/userGuide/cliCommands.md b/docs/userGuide/cliCommands.md index fee910eb03..0757892264 100644 --- a/docs/userGuide/cliCommands.md +++ b/docs/userGuide/cliCommands.md @@ -28,6 +28,7 @@ Options: Setup Commands init|i [options] [root] init a markbind site + skills Manage AI coding skills for this project Site Commands serve|s [options] [root] Build then serve a website from a directory @@ -73,6 +74,50 @@ Commands:
+
+ +### `skills` Command +
+ +**Format:** `markbind skills [command] [options]` + +**Description:** Manages AI coding skills for the current project. + +Use `markbind skills --help` to view all available subcommands. + + + +**Subcommands** :fas-cogs: + +* `install`
+ Downloads skills from the MarkBind skills repository and installs them into `.agents/skills`. + During installation, MarkBind prompts you to choose additional agent directories for optional symlinks. + + * **Format:** `markbind skills install [options]` + * **Options:** + * `--ref `: Uses a specific git tag or branch instead of the MarkBind-version-matched ref. + * `--force`: Overwrites existing installed skills. + * **{{ icon_examples }}** + * `markbind skills install` : Installs skills using the default ref for your MarkBind version. + * `markbind skills install --ref v7.0.0` : Installs skills from the `v7.0.0` ref. + * `markbind skills install --force` : Reinstalls skills and overwrites existing installed skills. + +* `update`
+ Re-downloads skills for the current MarkBind version and overwrites the existing installation. + + * **Format:** `markbind skills update [options]` + * **Options:** + * `--ref `: Uses a specific git tag or branch instead of the MarkBind-version-matched ref. + * **{{ icon_examples }}** + * `markbind skills update` : Updates installed skills using the default ref for your MarkBind version. + * `markbind skills update --ref v7.0.0` : Updates installed skills from the `v7.0.0` ref. + +
+ +
+ +
+ ### `serve` Command
diff --git a/docs/userGuide/gettingStarted.md b/docs/userGuide/gettingStarted.md index a5f317ae64..c76f90f48b 100644 --- a/docs/userGuide/gettingStarted.md +++ b/docs/userGuide/gettingStarted.md @@ -98,6 +98,12 @@ You can add the `--help` flag to any command to show the help screen.
The `init` command populates the project with the [default project template](https://markbind-init-typical.netlify.app/). Refer to [templates](templates.html) section to learn how to use a different template. + + + + +If you use AI coding assistants, you can install project-level skills using `markbind skills install`. See [CLI Commands: `skills`](cliCommands.html#markbind-skills). + From 66d07f543f0b205da0fb4f1e321675a281f4fe62 Mon Sep 17 00:00:00 2001 From: yihao Date: Thu, 16 Apr 2026 18:20:19 +0800 Subject: [PATCH 10/11] Add markbind repo check --- packages/cli/src/cmd/skills.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts index 1127309b5c..cdeb8833f6 100644 --- a/packages/cli/src/cmd/skills.ts +++ b/packages/cli/src/cmd/skills.ts @@ -7,6 +7,7 @@ import _ from 'lodash'; import * as logger from '../util/logger.js'; import packageJson from '../../package.json' with { type: 'json' }; +import { findRootFolder } from '../util/cliUtil.js'; const execFileAsync = promisify(execFile); @@ -87,8 +88,25 @@ function compareSemver(v1: string, v2: string): number { } async function install(options: SkillsInstallOptions) { + let rootFolder; + try { + rootFolder = findRootFolder(''); + } catch (error) { + if (_.isError(error)) { + logger.error(error.message); + logger.error('This directory does not appear to contain a valid MarkBind site. ' + + 'Check that you are running the command in the correct directory!\n' + + '\n' + + 'To create a new MarkBind site, run:\n' + + ' markbind init'); + } else { + logger.error(`Unknown error occurred: ${error}`); + } + process.exitCode = 1; + process.exit(); + } const ref = options.ref || `v${packageJson.aiSkillsVersion}`; - const targetDir = path.resolve(process.cwd(), SKILLS_TARGET); + const targetDir = path.resolve(rootFolder, SKILLS_TARGET); // Check git is available try { @@ -162,7 +180,7 @@ async function install(options: SkillsInstallOptions) { if (options.agents) { await Promise.all(options.agents.map(async (agent) => { - const agentSkillsDir = path.join(process.cwd(), agent, 'skills'); + const agentSkillsDir = path.join(rootFolder, agent, 'skills'); if (await fs.pathExists(agentSkillsDir)) { logger.warn('Agent skills directory already exist. Skipping symlink creation.'); logger.warn(`Please manually symlink ${targetDir} to ${agentSkillsDir}` From d5d1e7a6f7073b1f6e461e559b6bb79e47a6992b Mon Sep 17 00:00:00 2001 From: yihao Date: Thu, 16 Apr 2026 18:23:30 +0800 Subject: [PATCH 11/11] Update tests --- packages/cli/test/unit/skills.test.ts | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/cli/test/unit/skills.test.ts b/packages/cli/test/unit/skills.test.ts index cd860bc3b6..d8827c3776 100644 --- a/packages/cli/test/unit/skills.test.ts +++ b/packages/cli/test/unit/skills.test.ts @@ -5,6 +5,7 @@ import { vol, fs as memfs } from 'memfs'; import { execFile } from 'child_process'; import _ from 'lodash'; import * as logger from '../../src/util/logger.js'; +import * as cliUtil from '../../src/util/cliUtil.js'; import { install, findSkillDirs, @@ -67,11 +68,16 @@ jest.mock('../../src/util/logger.js', () => ({ info: jest.fn(), })); +jest.mock('../../src/util/cliUtil.js', () => ({ + findRootFolder: jest.fn(), +})); + type ExecCallback = (error: Error | null, stdout?: string, stderr?: string) => void; const mockExecFile = execFile as unknown as jest.Mock; const mockedLogger = logger as jest.Mocked; +const mockedCliUtil = cliUtil as jest.Mocked; const WORKDIR = '/workspace/project'; const TMPDIR = '/tmp'; @@ -114,6 +120,7 @@ beforeEach(() => { jest.resetAllMocks(); jest.spyOn(os, 'tmpdir').mockReturnValue(TMPDIR); jest.spyOn(process, 'cwd').mockReturnValue(WORKDIR); + mockedCliUtil.findRootFolder.mockReturnValue(WORKDIR); process.exitCode = undefined; }); @@ -217,6 +224,32 @@ describe('writeMetadata and readMetadata', () => { }); describe('install', () => { + test('fails when current directory is not inside a MarkBind site', async () => { + mockedCliUtil.findRootFolder.mockImplementation(() => { + throw new Error(`No config file found in parent directories of ${WORKDIR}`); + }); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + await expect(install({})).rejects.toThrow('process.exit called'); + + expect(mockedLogger.error).toHaveBeenCalledWith( + `No config file found in parent directories of ${WORKDIR}`, + ); + expect(mockedLogger.error).toHaveBeenCalledWith( + 'This directory does not appear to contain a valid MarkBind site. ' + + 'Check that you are running the command in the correct directory!\n' + + '\n' + + 'To create a new MarkBind site, run:\n' + + ' markbind init', + ); + expect(process.exitCode).toBe(1); + expect(exitSpy).toHaveBeenCalled(); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + test('installs with default ref from packageJson aiSkillsVersion', async () => { setExecFileMock((file, args, cb) => { if (args[0] === '--version') { @@ -238,6 +271,27 @@ describe('install', () => { expect(process.exitCode).toBeUndefined(); }); + test('installs into detected root folder when cwd is a nested directory', async () => { + const rootFolder = '/workspace/site-root'; + const nestedCwd = path.join(rootFolder, 'docs', 'chapter-1'); + mockedCliUtil.findRootFolder.mockReturnValue(rootFolder); + jest.spyOn(process, 'cwd').mockReturnValue(nestedCwd); + + setExecFileMock((file, args, cb) => { + if (args[0] === '--version') { + cb(null, 'git version 2.43.0', ''); + return; + } + seedClonedSkills(args[args.length - 1], ['nested-root-skill']); + cb(null, '', ''); + }); + + await install({}); + + expect(memfs.existsSync(path.join(rootFolder, '.agents/skills/nested-root-skill/SKILL.md'))).toBe(true); + expect(memfs.existsSync(path.join(nestedCwd, '.agents/skills/nested-root-skill/SKILL.md'))).toBe(false); + }); + test('installs with custom ref', async () => { setExecFileMock((file, args, cb) => { if (args[0] === '--version') {