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).
+
diff --git a/package-lock.json b/package-lock.json
index d07e27aac9..33a2e8ab88 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"
@@ -7529,7 +7903,6 @@
},
"node_modules/chardet": {
"version": "2.1.1",
- "dev": true,
"license": "MIT"
},
"node_modules/cheerio": {
@@ -10330,6 +10703,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,
@@ -10345,6 +10733,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,
@@ -18599,7 +18996,6 @@
},
"node_modules/safer-buffer": {
"version": "2.1.2",
- "dev": true,
"license": "MIT"
},
"node_modules/schema-utils": {
@@ -20898,7 +21294,7 @@
},
"node_modules/undici-types": {
"version": "6.21.0",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -22036,6 +22432,7 @@
"version": "7.0.1",
"license": "MIT",
"dependencies": {
+ "@inquirer/prompts": "^8.4.1",
"@markbind/core": "7.0.1",
"@markbind/core-web": "7.0.1",
"chalk": "^3.0.0",
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
index 9e9b7f70ee..94b2f34848 100755
--- a/packages/cli/index.ts
+++ b/packages/cli/index.ts
@@ -3,11 +3,13 @@
// 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';
import { init } from './src/cmd/init.js';
import { serve } from './src/cmd/serve.js';
+import { install as installSkills } from './src/cmd/skills.js';
import { preFlightChecks } from './src/util/preFlightChecks.js';
import packageJson from './package.json' with { type: 'json' };
@@ -72,14 +74,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(
@@ -126,4 +128,86 @@ 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');
+
+const agentChoices
+ = [
+ { name: 'Augment', value: '.augment' },
+ { name: 'IBM Bob', value: '.bob' },
+ { name: 'Claude Code', value: '.claude' },
+ { name: 'OpenClaw', value: '.openclaw' },
+ { 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 .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((options) => {
+ 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,
+ }).then(agent =>
+ installSkills({ ...options, agents: agent }),
+ );
+ });
+
+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 d02288b5f9..1389482175 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -3,6 +3,7 @@
"version": "7.0.1",
"type": "module",
"description": "Command line interface for MarkBind",
+ "aiSkillsVersion": "0.1.0",
"keywords": [
"mark",
"markdown",
@@ -33,6 +34,7 @@
"dev": "tsc --watch"
},
"dependencies": {
+ "@inquirer/prompts": "^8.4.1",
"@markbind/core": "7.0.1",
"@markbind/core-web": "7.0.1",
"chalk": "^3.0.0",
diff --git a/packages/cli/src/cmd/skills.ts b/packages/cli/src/cmd/skills.ts
new file mode 100644
index 0000000000..cdeb8833f6
--- /dev/null
+++ b/packages/cli/src/cmd/skills.ts
@@ -0,0 +1,218 @@
+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' };
+import { findRootFolder } from '../util/cliUtil.js';
+
+const execFileAsync = promisify(execFile);
+
+const METADATA_FILE = '.markbind-skills.json';
+const SKILLS_REPO = 'https://github.com/MarkBind/skills.git';
+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 {
+ 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)
+ .sort((a, b) => a.localeCompare(b));
+}
+
+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 (e) {
+ throw new Error(`Failed to read metadata file: ${(e as Error).message}`);
+ }
+ } else {
+ throw new Error('Metadata file not found');
+ }
+}
+
+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) {
+ 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(rootFolder, 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).catch(
+ (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.');
+ 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-'));
+
+ 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}/`);
+
+ if (options.agents) {
+ await Promise.all(options.agents.map(async (agent) => {
+ 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}`
+ + ` 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;
+ 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, 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..d8827c3776
--- /dev/null
+++ b/packages/cli/test/unit/skills.test.ts
@@ -0,0 +1,570 @@
+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 * as cliUtil from '../../src/util/cliUtil.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(),
+}));
+
+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';
+
+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);
+ mockedCliUtil.findRootFolder.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('throws when metadata file is missing', async () => {
+ await expect(readMetadata('/missing')).rejects.toThrow('Metadata file not found');
+ });
+
+ test('throws when metadata file is corrupted', async () => {
+ vol.fromJSON({
+ '/skills/.markbind-skills.json': '{not-json',
+ }, '/');
+
+ await expect(readMetadata('/skills')).rejects.toThrow('Failed to read metadata file:');
+ });
+});
+
+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') {
+ 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 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') {
+ 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.resolve(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.',
+ );
+ });
+});
]