From 696a348a09349c0e1a43dfde01cd9bc5a2d79b11 Mon Sep 17 00:00:00 2001 From: Lukas Knoch-Girstmair Date: Fri, 22 May 2026 12:46:41 +0200 Subject: [PATCH 1/3] Add AI assistant skill installation --- README.md | 22 +++++ package-lock.json | 134 ++++++++++++++++++++++------- package.json | 10 ++- skills/bitmovin-cli/SKILL.md | 146 +++++++++++++++++++++++++++++++ src/commands/init.ts | 20 +++++ src/commands/skill.ts | 154 ++------------------------------- src/commands/skills/add.ts | 33 +++++++ src/commands/skills/find.ts | 18 ++++ src/commands/skills/list.ts | 23 +++++ src/commands/skills/remove.ts | 22 +++++ src/lib/skills/agents.ts | 68 +++++++++++++++ src/lib/skills/archive.ts | 81 ++++++++++++++++++ src/lib/skills/catalog.ts | 67 +++++++++++++++ src/lib/skills/commands.ts | 18 ++++ src/lib/skills/install.ts | 126 +++++++++++++++++++++++++++ src/lib/skills/local.ts | 14 +++ src/lib/skills/source.ts | 48 +++++++++++ test/lib/skills.test.ts | 156 ++++++++++++++++++++++++++++++++++ 18 files changed, 978 insertions(+), 182 deletions(-) create mode 100644 skills/bitmovin-cli/SKILL.md create mode 100644 src/commands/init.ts create mode 100644 src/commands/skills/add.ts create mode 100644 src/commands/skills/find.ts create mode 100644 src/commands/skills/list.ts create mode 100644 src/commands/skills/remove.ts create mode 100644 src/lib/skills/agents.ts create mode 100644 src/lib/skills/archive.ts create mode 100644 src/lib/skills/catalog.ts create mode 100644 src/lib/skills/commands.ts create mode 100644 src/lib/skills/install.ts create mode 100644 src/lib/skills/local.ts create mode 100644 src/lib/skills/source.ts create mode 100644 test/lib/skills.test.ts diff --git a/README.md b/README.md index 4dcaacc..ecbcafa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,28 @@ bitmovin encoding templates start ./my-encoding.yaml --watch ## Commands +### AI Assistant Skills + +Install a local Bitmovin CLI skill for AI assistants: + +```bash +bitmovin init # Install the local bitmovin-cli skill into detected agents +bitmovin init --agent pi # Install for a specific agent +bitmovin skill # Print the local CLI skill markdown +``` + +Manage additional Bitmovin skills sourced from [github.com/bitmovin/skills](https://github.com/bitmovin/skills): + +```bash +bitmovin skills list # List available remote skills +bitmovin skills find android # Search remote skills +bitmovin skills add --skill bitmovin # Install a remote skill +bitmovin skills add --all # Install all remote skills +bitmovin skills remove --skill bitmovin +``` + +Supported agents are `pi`, `claude`, `codex`, and `gemini`. If `--agent` is omitted, the CLI installs into detected existing agent skill directories. + ### Config ```bash diff --git a/package-lock.json b/package-lock.json index 3c20ba5..c9efab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "js-yaml": "^4.1.0", - "ora": "^8.1.1" + "ora": "^8.1.1", + "tar": "^7.5.15" }, "bin": { "bitmovin": "bin/run.js" @@ -29,6 +30,7 @@ "@types/cli-progress": "^3.11.6", "@types/js-yaml": "^4.0.9", "@types/node": "^22", + "@types/tar": "^6.1.13", "eslint": "^10.2.0", "oclif": "^4", "typescript": "^5.7", @@ -36,7 +38,7 @@ "vitest": "^4.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-crypto/crc32": { @@ -1013,39 +1015,13 @@ "node": ">=0.1.90" } }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2212,6 +2188,18 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2243,6 +2231,7 @@ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.3.tgz", "integrity": "sha512-0mD8vcrrX5uRsxzvI8tbWmSVGngvZA/Qo6O0ZGvLPAWEauSf5GFniwgirhY0SkszuHwu0S1J1ivj/jHmqtIDuA==", "license": "MIT", + "peer": true, "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", @@ -3581,6 +3570,27 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", @@ -3633,6 +3643,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3937,6 +3948,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4193,6 +4205,15 @@ "dev": true, "license": "MIT" }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -4550,6 +4571,7 @@ "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5875,6 +5897,27 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7879,6 +7922,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9328,6 +9372,22 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tiny-jsonc": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tiny-jsonc/-/tiny-jsonc-1.0.2.tgz", @@ -9390,6 +9450,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9490,6 +9551,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9602,6 +9664,7 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -9882,6 +9945,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yarn": { "version": "1.22.22", "resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz", diff --git a/package.json b/package.json index c407219..6ff60c6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "types": "dist/index.d.ts", "files": [ "bin", - "dist" + "dist", + "skills" ], "scripts": { "prepare": "[ -d dist ] || tsc -b", @@ -55,7 +56,8 @@ "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "js-yaml": "^4.1.0", - "ora": "^8.1.1" + "ora": "^8.1.1", + "tar": "^7.5.15" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -64,6 +66,7 @@ "@types/cli-progress": "^3.11.6", "@types/js-yaml": "^4.0.9", "@types/node": "^22", + "@types/tar": "^6.1.13", "eslint": "^10.2.0", "oclif": "^4", "typescript": "^5.7", @@ -92,6 +95,9 @@ }, "topicSeparator": " ", "topics": { + "skills": { + "description": "Install and manage Bitmovin AI assistant skills" + }, "config": { "description": "Configure API key and defaults" }, diff --git a/skills/bitmovin-cli/SKILL.md b/skills/bitmovin-cli/SKILL.md new file mode 100644 index 0000000..9edac68 --- /dev/null +++ b/skills/bitmovin-cli/SKILL.md @@ -0,0 +1,146 @@ +# Bitmovin CLI + +Command-line interface for Bitmovin. Use `bitmovin` to manage encodings, player licenses, and analytics. + +## Setup + +```bash +# Set API key (get from https://dashboard.bitmovin.com/account) +bitmovin config set api-key +# Or use env var: BITMOVIN_API_KEY= + +# List and select an organization +bitmovin config list organizations +bitmovin config set organization +``` + +## Encoding + +### Templates (recommended workflow) +```bash +bitmovin encoding templates start ./template.yaml --watch # Start encoding from YAML template +bitmovin encoding templates list # List stored templates +bitmovin encoding templates get # Get template details +bitmovin encoding templates create ./file.yaml --name "X" # Store template +bitmovin encoding templates delete +bitmovin encoding templates validate ./template.yaml # Validate YAML against schema +``` + +### Jobs +```bash +bitmovin encoding jobs list [--status RUNNING|FINISHED|ERROR] [--limit 25] [--offset 0] +bitmovin encoding jobs get +bitmovin encoding jobs status [--watch] # --watch polls with progress bar +bitmovin encoding jobs start [--watch] +bitmovin encoding jobs stop +bitmovin encoding jobs delete +bitmovin encoding jobs live # Encoder IP, stream keys, SRT inputs +``` + +### Inputs +```bash +bitmovin encoding inputs list [--type s3|gcs|http|https|azure] +bitmovin encoding inputs get +bitmovin encoding inputs create s3 --name "X" --bucket --access-key --secret-key +bitmovin encoding inputs create gcs --name "X" --bucket --access-key --secret-key +bitmovin encoding inputs create https --name "X" --host +bitmovin encoding inputs delete +``` + +### Outputs +```bash +bitmovin encoding outputs list [--type s3|gcs|azure] +bitmovin encoding outputs get +bitmovin encoding outputs create s3 --name "X" --bucket --access-key --secret-key +bitmovin encoding outputs create gcs --name "X" --bucket --access-key --secret-key +bitmovin encoding outputs delete +``` + +### Codecs +```bash +bitmovin encoding codecs list [--type video|audio] [--codec h264|h265|av1|aac|opus] +bitmovin encoding codecs get # auto-detects codec type +bitmovin encoding codecs create h264 --name "X" --bitrate [--height 1080] [--profile HIGH] +bitmovin encoding codecs create h265 --name "X" --bitrate [--height 2160] +bitmovin encoding codecs create aac --name "X" --bitrate [--sample-rate 48000] +bitmovin encoding codecs delete # auto-detects codec type +``` + +### Manifests +```bash +bitmovin encoding manifests list [--type dash|hls|smooth] +bitmovin encoding manifests get --type dash +bitmovin encoding manifests delete --type dash +``` + +### Stats +```bash +bitmovin encoding stats # Overall statistics +bitmovin encoding stats --from 2024-01-01 --to 2024-03-31 # Date range +``` + +## Player + +```bash +bitmovin player licenses list +bitmovin player licenses get +bitmovin player licenses create --name "X" +bitmovin player licenses update --name "X" + +# Domains accept license ID, license key, or name +bitmovin player domains list +bitmovin player domains add --url https://example.com +bitmovin player domains remove + +bitmovin player analytics activate --analytics-key +bitmovin player analytics deactivate +``` + +## Analytics + +```bash +bitmovin analytics licenses list +bitmovin analytics licenses get +bitmovin analytics licenses create --name "X" [--timezone Europe/Vienna] +bitmovin analytics licenses update [--name "X"] [--ignore-dnt] [--timezone UTC] + +# Domains accept license ID, license key, or name +bitmovin analytics domains list +bitmovin analytics domains add --url https://example.com +bitmovin analytics domains remove +``` + +## Account + +```bash +bitmovin account info +``` + +## Output Flags + +All commands support these flags: + +| Flag | Description | +|------|-------------| +| `--json` / `-j` | Output JSON to stdout | +| `--fields f1,f2` | Select JSON fields (implies --json) | +| `--jq ` | Filter with jq expression (implies --json) | +| `--format table` | Force table output in non-TTY | +| `--api-key ` | Override API key | +| `--quiet` / `-q` | Suppress status messages | + +## Scripting Examples + +```bash +# Get all failed encoding IDs +bitmovin encoding jobs list --jq '[.[] | select(.status == "ERROR")] | .[].id' + +# Get encoding status as JSON +bitmovin encoding jobs status --json + +# List player license keys +bitmovin player licenses list --fields name,licenseKey + +# Pipe-friendly: TSV output when not a TTY +bitmovin encoding jobs list | grep FINISHED | awk '{print $1}' +``` diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..2af35ab --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,20 @@ +import {Command, Flags} from '@oclif/core'; +import {SUPPORTED_AGENTS} from '../lib/skills/agents.js'; +import {resolveRequiredTargets, formatSkillResult} from '../lib/skills/commands.js'; +import {installPayload} from '../lib/skills/install.js'; +import {loadLocalCliSkill} from '../lib/skills/local.js'; + +export default class Init extends Command { + static override description = 'Install the local Bitmovin CLI AI assistant skill'; + static override flags = { + agent: Flags.string({char: 'a', description: `Comma-separated agents (${SUPPORTED_AGENTS.join(', ')})`}), + 'dry-run': Flags.boolean({description: 'Show what would be installed without writing files'}), + }; + + async run(): Promise { + const {flags} = await this.parse(Init); + const targets = resolveRequiredTargets(flags.agent, 'bitmovin init --agent pi'); + const installed = await installPayload(await loadLocalCliSkill(), targets, flags['dry-run']); + for (const item of installed) this.log(formatSkillResult('Installed', flags['dry-run'], item)); + } +} diff --git a/src/commands/skill.ts b/src/commands/skill.ts index a68d2ce..0e97a7d 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -1,158 +1,14 @@ import {Command} from '@oclif/core'; - -const SKILL = `# Bitmovin CLI - -Command-line interface for Bitmovin. Use \`bitmovin\` to manage encodings, player licenses, and analytics. - -## Setup - -\`\`\`bash -# Set API key (get from https://dashboard.bitmovin.com/account) -bitmovin config set api-key -# Or use env var: BITMOVIN_API_KEY= - -# List and select an organization -bitmovin config list organizations -bitmovin config set organization -\`\`\` - -## Encoding - -### Templates (recommended workflow) -\`\`\`bash -bitmovin encoding templates start ./template.yaml --watch # Start encoding from YAML template -bitmovin encoding templates list # List stored templates -bitmovin encoding templates get # Get template details -bitmovin encoding templates create ./file.yaml --name "X" # Store template -bitmovin encoding templates delete -bitmovin encoding templates validate ./template.yaml # Validate YAML against schema -\`\`\` - -### Jobs -\`\`\`bash -bitmovin encoding jobs list [--status RUNNING|FINISHED|ERROR] [--limit 25] [--offset 0] -bitmovin encoding jobs get -bitmovin encoding jobs status [--watch] # --watch polls with progress bar -bitmovin encoding jobs start [--watch] -bitmovin encoding jobs stop -bitmovin encoding jobs delete -bitmovin encoding jobs live # Encoder IP, stream keys, SRT inputs -\`\`\` - -### Inputs -\`\`\`bash -bitmovin encoding inputs list [--type s3|gcs|http|https|azure] -bitmovin encoding inputs get -bitmovin encoding inputs create s3 --name "X" --bucket --access-key --secret-key -bitmovin encoding inputs create gcs --name "X" --bucket --access-key --secret-key -bitmovin encoding inputs create https --name "X" --host -bitmovin encoding inputs delete -\`\`\` - -### Outputs -\`\`\`bash -bitmovin encoding outputs list [--type s3|gcs|azure] -bitmovin encoding outputs get -bitmovin encoding outputs create s3 --name "X" --bucket --access-key --secret-key -bitmovin encoding outputs create gcs --name "X" --bucket --access-key --secret-key -bitmovin encoding outputs delete -\`\`\` - -### Codecs -\`\`\`bash -bitmovin encoding codecs list [--type video|audio] [--codec h264|h265|av1|aac|opus] -bitmovin encoding codecs get # auto-detects codec type -bitmovin encoding codecs create h264 --name "X" --bitrate [--height 1080] [--profile HIGH] -bitmovin encoding codecs create h265 --name "X" --bitrate [--height 2160] -bitmovin encoding codecs create aac --name "X" --bitrate [--sample-rate 48000] -bitmovin encoding codecs delete # auto-detects codec type -\`\`\` - -### Manifests -\`\`\`bash -bitmovin encoding manifests list [--type dash|hls|smooth] -bitmovin encoding manifests get --type dash -bitmovin encoding manifests delete --type dash -\`\`\` - -### Stats -\`\`\`bash -bitmovin encoding stats # Overall statistics -bitmovin encoding stats --from 2024-01-01 --to 2024-03-31 # Date range -\`\`\` - -## Player - -\`\`\`bash -bitmovin player licenses list -bitmovin player licenses get -bitmovin player licenses create --name "X" -bitmovin player licenses update --name "X" - -# Domains accept license ID, license key, or name -bitmovin player domains list -bitmovin player domains add --url https://example.com -bitmovin player domains remove - -bitmovin player analytics activate --analytics-key -bitmovin player analytics deactivate -\`\`\` - -## Analytics - -\`\`\`bash -bitmovin analytics licenses list -bitmovin analytics licenses get -bitmovin analytics licenses create --name "X" [--timezone Europe/Vienna] -bitmovin analytics licenses update [--name "X"] [--ignore-dnt] [--timezone UTC] - -# Domains accept license ID, license key, or name -bitmovin analytics domains list -bitmovin analytics domains add --url https://example.com -bitmovin analytics domains remove -\`\`\` - -## Account - -\`\`\`bash -bitmovin account info -\`\`\` - -## Output Flags - -All commands support these flags: - -| Flag | Description | -|------|-------------| -| \`--json\` / \`-j\` | Output JSON to stdout | -| \`--fields f1,f2\` | Select JSON fields (implies --json) | -| \`--jq \` | Filter with jq expression (implies --json) | -| \`--format table\` | Force table output in non-TTY | -| \`--api-key \` | Override API key | -| \`--quiet\` / \`-q\` | Suppress status messages | - -## Scripting Examples - -\`\`\`bash -# Get all failed encoding IDs -bitmovin encoding jobs list --jq '[.[] | select(.status == "ERROR")] | .[].id' - -# Get encoding status as JSON -bitmovin encoding jobs status --json - -# List player license keys -bitmovin player licenses list --fields name,licenseKey - -# Pipe-friendly: TSV output when not a TTY -bitmovin encoding jobs list | grep FINISHED | awk '{print $1}' -\`\`\` -`; +import {loadLocalCliSkill} from '../lib/skills/local.js'; export default class Skill extends Command { static override description = 'Output CLI reference as markdown (for AI assistants)'; static override hidden = true; async run(): Promise { - process.stdout.write(SKILL); + const skill = await loadLocalCliSkill(); + const skillFile = skill.files.find(file => file.path === 'SKILL.md'); + if (!skillFile) throw new Error('Local CLI skill is missing SKILL.md'); + process.stdout.write(skillFile.content); } } diff --git a/src/commands/skills/add.ts b/src/commands/skills/add.ts new file mode 100644 index 0000000..918f797 --- /dev/null +++ b/src/commands/skills/add.ts @@ -0,0 +1,33 @@ +import {Command, Flags} from '@oclif/core'; +import {withSkillsArchive} from '../../lib/skills/archive.js'; +import {discoverSkills, findSkill} from '../../lib/skills/catalog.js'; +import {SUPPORTED_AGENTS} from '../../lib/skills/agents.js'; +import {formatSkillResult, resolveRequiredTargets} from '../../lib/skills/commands.js'; +import {installPayload, loadSkillPayloadsFromArchive} from '../../lib/skills/install.js'; +import {getRef} from '../../lib/skills/source.js'; + +export default class SkillsAdd extends Command { + static override description = 'Install Bitmovin AI assistant skills'; + static override flags = { + skill: Flags.string({char: 's', description: 'Skill to install', default: 'bitmovin'}), + all: Flags.boolean({description: 'Install all available skills'}), + agent: Flags.string({char: 'a', description: `Comma-separated agents (${SUPPORTED_AGENTS.join(', ')})`}), + 'dry-run': Flags.boolean({description: 'Show what would be installed without writing files'}), + ref: Flags.string({description: 'Git ref to read skills from', hidden: true}), + }; + + async run(): Promise { + const {flags} = await this.parse(SkillsAdd); + const targets = resolveRequiredTargets(flags.agent, `bitmovin skills add --agent pi --skill ${flags.skill}`); + + await withSkillsArchive(getRef(flags.ref), async archiveRoot => { + const skills = await discoverSkills(archiveRoot); + const selected = flags.all ? skills : [findSkill(skills, flags.skill)]; + const payloads = await loadSkillPayloadsFromArchive(archiveRoot, selected); + for (const payload of payloads) { + const installed = await installPayload(payload, targets, flags['dry-run']); + for (const item of installed) this.log(formatSkillResult('Installed', flags['dry-run'], item)); + } + }); + } +} diff --git a/src/commands/skills/find.ts b/src/commands/skills/find.ts new file mode 100644 index 0000000..af98aa9 --- /dev/null +++ b/src/commands/skills/find.ts @@ -0,0 +1,18 @@ +import {Command, Args, Flags} from '@oclif/core'; +import {loadSkills, searchSkills} from '../../lib/skills/catalog.js'; + +export default class SkillsFind extends Command { + static override description = 'Search available Bitmovin AI assistant skills'; + static override args = { + query: Args.string({required: true, description: 'Search query'}), + }; + static override flags = { + ref: Flags.string({description: 'Git ref to read skills from', hidden: true}), + }; + + async run(): Promise { + const {args, flags} = await this.parse(SkillsFind); + const skills = searchSkills(await loadSkills(flags.ref), args.query); + for (const skill of skills) this.log(`${skill.name}\t${skill.description}`); + } +} diff --git a/src/commands/skills/list.ts b/src/commands/skills/list.ts new file mode 100644 index 0000000..2ecc7de --- /dev/null +++ b/src/commands/skills/list.ts @@ -0,0 +1,23 @@ +import {Command, Flags} from '@oclif/core'; +import {loadSkills} from '../../lib/skills/catalog.js'; + +export default class SkillsList extends Command { + static override description = 'List available Bitmovin AI assistant skills'; + static override flags = { + long: Flags.boolean({char: 'l', description: 'Show descriptions and tags'}), + ref: Flags.string({description: 'Git ref to read skills from', hidden: true}), + }; + + async run(): Promise { + const {flags} = await this.parse(SkillsList); + const skills = await loadSkills(flags.ref); + + for (const skill of skills) { + if (flags.long) { + this.log(`${skill.name}\n ${skill.description}${skill.tags?.length ? `\n Tags: ${skill.tags.join(', ')}` : ''}`); + } else { + this.log(`${skill.name}\t${skill.description}`); + } + } + } +} diff --git a/src/commands/skills/remove.ts b/src/commands/skills/remove.ts new file mode 100644 index 0000000..6d17918 --- /dev/null +++ b/src/commands/skills/remove.ts @@ -0,0 +1,22 @@ +import {Command, Flags} from '@oclif/core'; +import {SUPPORTED_AGENTS} from '../../lib/skills/agents.js'; +import {formatSkillResult, resolveRequiredTargets} from '../../lib/skills/commands.js'; +import {removeSkill} from '../../lib/skills/install.js'; +import {validateSkillName} from '../../lib/skills/source.js'; + +export default class SkillsRemove extends Command { + static override description = 'Remove installed Bitmovin AI assistant skills'; + static override flags = { + skill: Flags.string({char: 's', description: 'Skill to remove', required: true}), + agent: Flags.string({char: 'a', description: `Comma-separated agents (${SUPPORTED_AGENTS.join(', ')})`}), + 'dry-run': Flags.boolean({description: 'Show what would be removed without deleting files'}), + }; + + async run(): Promise { + const {flags} = await this.parse(SkillsRemove); + validateSkillName(flags.skill); + const targets = resolveRequiredTargets(flags.agent, `bitmovin skills remove --agent pi --skill ${flags.skill}`); + const removed = await removeSkill(flags.skill, targets, flags['dry-run']); + for (const item of removed) this.log(formatSkillResult('Removed', flags['dry-run'], item)); + } +} diff --git a/src/lib/skills/agents.ts b/src/lib/skills/agents.ts new file mode 100644 index 0000000..ab9aaa2 --- /dev/null +++ b/src/lib/skills/agents.ts @@ -0,0 +1,68 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +export const SUPPORTED_AGENTS = ['pi', 'claude', 'codex', 'gemini'] as const; +export type Agent = typeof SUPPORTED_AGENTS[number]; + +const DEFAULT_SKILL_DIRS: Record = { + pi: '~/.pi/agent/skills', + claude: '~/.claude/skills', + codex: '~/.codex/skills', + gemini: '~/.gemini/skills', +}; + +export type SkillDirOverrides = Partial>; + +export type AgentTarget = { + agent: Agent; + skillsDir: string; +}; + +export function parseAgents(value?: string): Agent[] | undefined { + if (value === undefined) return undefined; + const agents = [...new Set(value.split(',').map(agent => agent.trim()).filter(Boolean))]; + if (agents.length === 0) { + throw new Error('No agents specified'); + } + + for (const agent of agents) { + if (!isAgent(agent)) { + throw new Error(`Unsupported agent: ${agent}. Supported agents: ${SUPPORTED_AGENTS.join(', ')}`); + } + } + + return agents as Agent[]; +} + +export function resolveTargets(agents?: Agent[], overrides: SkillDirOverrides = {}): AgentTarget[] { + if (agents?.length) { + return agents.map(agent => ({agent, skillsDir: getSkillsDir(agent, overrides)})); + } + + return SUPPORTED_AGENTS + .map(agent => ({agent, skillsDir: getSkillsDir(agent, overrides)})) + .filter(target => isDirectory(target.skillsDir)); +} + +export function getSkillsDir(agent: Agent, overrides: SkillDirOverrides = {}): string { + return overrides[agent] ? path.resolve(overrides[agent]) : expandHome(DEFAULT_SKILL_DIRS[agent]); +} + +function isDirectory(filePath: string): boolean { + try { + return fs.statSync(filePath).isDirectory(); + } catch { + return false; + } +} + +function isAgent(value: string): value is Agent { + return (SUPPORTED_AGENTS as readonly string[]).includes(value); +} + +function expandHome(value: string): string { + if (value === '~') return os.homedir(); + if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2)); + return value; +} diff --git a/src/lib/skills/archive.ts b/src/lib/skills/archive.ts new file mode 100644 index 0000000..114edc9 --- /dev/null +++ b/src/lib/skills/archive.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs/promises'; +import {createWriteStream} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; +import {extract} from 'tar'; +import {archiveUrl, validateRelativePath} from './source.js'; + +type TarEntry = { + path: string; + type?: string; +}; + +export async function withSkillsArchive(ref: string, callback: (archiveRoot: string) => Promise): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bitmovin-skills-archive-')); + try { + const archivePath = path.join(tempDir, 'archive.tar.gz'); + const extractDir = path.join(tempDir, 'extract'); + await downloadArchive(archiveUrl(ref), archivePath); + await extractArchive(archivePath, extractDir); + return callback(await findArchiveRoot(extractDir)); + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } +} + +async function downloadArchive(url: string, destination: string): Promise { + const response = await fetch(url); + if (!response.ok || !response.body) { + throw new Error(`Failed to fetch skills archive: ${response.status} ${response.statusText}`); + } + + await pipeline(Readable.fromWeb(response.body as Parameters[0]), createWriteStream(destination)); +} + +async function extractArchive(archivePath: string, extractDir: string): Promise { + await fs.mkdir(extractDir, {recursive: true}); + let invalidArchiveEntry: string | undefined; + await extract({ + file: archivePath, + cwd: extractDir, + preservePaths: false, + filter: (entryPath, entry) => { + const result = validateArchiveEntry(entryPath, entry as TarEntry); + invalidArchiveEntry ??= result.error; + return !invalidArchiveEntry && result.include; + }, + }); + + if (invalidArchiveEntry) throw new Error(invalidArchiveEntry); +} + +function validateArchiveEntry(entryPath: string, entry: TarEntry): {include: boolean; error?: string} { + try { + validateRelativePath(entryPath, 'archive entry'); + } catch (error) { + return {include: false, error: error instanceof Error ? error.message : `Invalid archive entry: ${entryPath}`}; + } + + const parts = entryPath.replace(/\\/g, '/').split('/').filter(Boolean); + const isArchiveRoot = parts.length === 1; + const isSkillsEntry = parts[1] === 'skills'; + if (!isArchiveRoot && !isSkillsEntry) return {include: false}; + + if (isSkillsEntry && (entry.type === 'SymbolicLink' || entry.type === 'Link')) { + return {include: false, error: `Invalid skills archive: links are not allowed (${entry.path})`}; + } + + return {include: true}; +} + +async function findArchiveRoot(extractDir: string): Promise { + const entries = await fs.readdir(extractDir, {withFileTypes: true}); + const directories = entries.filter(entry => entry.isDirectory()); + if (directories.length !== 1) { + throw new Error('Invalid skills archive: expected exactly one top-level directory'); + } + + return path.join(extractDir, directories[0].name); +} diff --git a/src/lib/skills/catalog.ts b/src/lib/skills/catalog.ts new file mode 100644 index 0000000..78b72cb --- /dev/null +++ b/src/lib/skills/catalog.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {withSkillsArchive} from './archive.js'; +import {getRef, validateSkillName, type SkillDefinition} from './source.js'; + +export async function loadSkills(ref?: string): Promise { + return withSkillsArchive(getRef(ref), async archiveRoot => discoverSkills(archiveRoot)); +} + +export function findSkill(skills: SkillDefinition[], name: string): SkillDefinition { + const skill = skills.find(candidate => candidate.name === name); + if (!skill) { + throw new Error(`Skill not found: ${name}`); + } + + return skill; +} + +export function searchSkills(skills: SkillDefinition[], query: string): SkillDefinition[] { + const normalized = query.toLowerCase(); + return skills.filter(skill => [ + skill.name, + skill.description, + ...(skill.tags ?? []), + ].some(value => value.toLowerCase().includes(normalized))); +} + +export async function discoverSkills(archiveRoot: string): Promise { + const skillsRoot = path.join(archiveRoot, 'skills'); + const entries = (await fs.readdir(skillsRoot, {withFileTypes: true})) + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + const skills: SkillDefinition[] = []; + + for (const entry of entries) { + validateSkillName(entry.name); + const skillPath = `skills/${entry.name}`; + const skillMarkdownPath = path.join(skillsRoot, entry.name, 'SKILL.md'); + try { + const markdown = await fs.readFile(skillMarkdownPath, 'utf8'); + skills.push({ + name: extractFrontmatterValue(markdown, 'name') ?? entry.name, + description: extractDescription(markdown) ?? 'Bitmovin AI assistant skill', + path: skillPath, + tags: inferTags(entry.name), + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + + return skills; +} + +function extractDescription(markdown: string): string | undefined { + return extractFrontmatterValue(markdown, 'description') + ?? markdown.match(/^#\s+(.+)$/m)?.[1]?.trim(); +} + +function extractFrontmatterValue(markdown: string, key: string): string | undefined { + return markdown.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim(); +} + +function inferTags(name: string): string[] | undefined { + const tags = name.split('-').filter(part => part !== 'bitmovin'); + return tags.length ? tags : undefined; +} diff --git a/src/lib/skills/commands.ts b/src/lib/skills/commands.ts new file mode 100644 index 0000000..a23c37e --- /dev/null +++ b/src/lib/skills/commands.ts @@ -0,0 +1,18 @@ +import type {AgentTarget} from './agents.js'; +import {parseAgents, resolveTargets} from './agents.js'; +import type {InstalledSkill} from './install.js'; + +export function resolveRequiredTargets(agentFlag: string | undefined, exampleCommand: string): AgentTarget[] { + const targets = resolveTargets(parseAgents(agentFlag)); + if (targets.length === 0) { + throw new Error(`No supported AI assistant skill directory found.\n\nUse --agent to choose one explicitly, for example:\n ${exampleCommand}`); + } + + return targets; +} + +export function formatSkillResult(action: string, dryRun: boolean, item: InstalledSkill): string { + const dryRunVerb = action === 'Installed' ? 'install' : action === 'Removed' ? 'remove' : action.toLowerCase(); + const verb = dryRun ? `Would ${dryRunVerb}` : action; + return `${verb} ${item.skill} for ${item.agent}: ${item.path}`; +} diff --git a/src/lib/skills/install.ts b/src/lib/skills/install.ts new file mode 100644 index 0000000..81bdd66 --- /dev/null +++ b/src/lib/skills/install.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type {AgentTarget} from './agents.js'; +import {withSkillsArchive} from './archive.js'; +import {getRef, validateRelativePath, validateSkillDefinition, validateSkillName, type SkillDefinition} from './source.js'; + +export type SkillPayload = { + name: string; + files: Array<{path: string; content: string}>; +}; + +export type InstalledSkill = { + skill: string; + agent: string; + path: string; +}; + +export async function loadRemoteSkillPayload(skill: SkillDefinition, ref?: string): Promise { + return (await loadRemoteSkillPayloads([skill], ref))[0]; +} + +export async function loadRemoteSkillPayloads(skills: SkillDefinition[], ref?: string): Promise { + for (const skill of skills) validateSkillDefinition(skill); + return withSkillsArchive(getRef(ref), async archiveRoot => loadSkillPayloadsFromArchive(archiveRoot, skills)); +} + +export async function loadSkillPayloadsFromArchive(archiveRoot: string, skills: SkillDefinition[]): Promise { + for (const skill of skills) validateSkillDefinition(skill); + const payloads: SkillPayload[] = []; + for (const skill of skills) payloads.push(await loadSkillPayloadFromArchive(archiveRoot, skill)); + return payloads; +} + +export async function installPayload(payload: SkillPayload, targets: AgentTarget[], dryRun = false): Promise { + validatePayload(payload); + const installed: InstalledSkill[] = []; + + for (const target of targets) { + const destination = path.join(target.skillsDir, payload.name); + if (!dryRun) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `bitmovin-skill-${payload.name}-`)); + try { + for (const file of payload.files) { + const outputPath = safeJoin(tempDir, file.path); + await fs.mkdir(path.dirname(outputPath), {recursive: true}); + await fs.writeFile(outputPath, file.content); + } + + await fs.mkdir(target.skillsDir, {recursive: true}); + await fs.rm(destination, {recursive: true, force: true}); + await fs.cp(tempDir, destination, {recursive: true}); + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } + } + + installed.push({skill: payload.name, agent: target.agent, path: destination}); + } + + return installed; +} + +export async function removeSkill(skillName: string, targets: AgentTarget[], dryRun = false): Promise { + validateSkillName(skillName); + const removed: InstalledSkill[] = []; + for (const target of targets) { + const destination = path.join(target.skillsDir, skillName); + if (!dryRun) await fs.rm(destination, {recursive: true, force: true}); + removed.push({skill: skillName, agent: target.agent, path: destination}); + } + + return removed; +} + +export function validatePayload(payload: SkillPayload): void { + validateSkillName(payload.name); + if (!Array.isArray(payload.files) || payload.files.length === 0) { + throw new Error(`Invalid skill payload: ${payload.name} has no files`); + } + + for (const file of payload.files) validateRelativePath(file.path, `${payload.name} file`); +} + +function safeJoin(root: string, relativePath: string): string { + validateRelativePath(relativePath, 'payload file'); + const resolvedRoot = path.resolve(root); + const resolvedPath = path.resolve(resolvedRoot, relativePath); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`Invalid payload file: ${relativePath}`); + } + + return resolvedPath; +} + +async function loadSkillPayloadFromArchive(archiveRoot: string, skill: SkillDefinition): Promise { + const skillDir = path.join(archiveRoot, skill.path); + await assertSkillDirectory(skillDir, skill.name); + return {name: skill.name, files: await readPayloadFiles(skillDir)}; +} + +async function assertSkillDirectory(skillDir: string, skillName: string): Promise { + try { + await fs.access(path.join(skillDir, 'SKILL.md')); + } catch { + throw new Error(`Invalid skills archive: missing skills/${skillName}/SKILL.md`); + } +} + +async function readPayloadFiles(skillDir: string, relativeDir = ''): Promise { + const entries = (await fs.readdir(path.join(skillDir, relativeDir), {withFileTypes: true})) + .sort((a, b) => a.name.localeCompare(b.name)); + const files: SkillPayload['files'] = []; + + for (const entry of entries) { + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + files.push(...await readPayloadFiles(skillDir, relativePath)); + } else if (entry.isFile()) { + validateRelativePath(relativePath, 'archive file'); + files.push({path: relativePath, content: await fs.readFile(safeJoin(skillDir, relativePath), 'utf8')}); + } + } + + return files; +} diff --git a/src/lib/skills/local.ts b/src/lib/skills/local.ts new file mode 100644 index 0000000..290ffbe --- /dev/null +++ b/src/lib/skills/local.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import type {SkillPayload} from './install.js'; + +export const LOCAL_CLI_SKILL_NAME = 'bitmovin-cli'; + +export async function loadLocalCliSkill(): Promise { + const skillPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../skills/bitmovin-cli/SKILL.md'); + return { + name: LOCAL_CLI_SKILL_NAME, + files: [{path: 'SKILL.md', content: await fs.readFile(skillPath, 'utf8')}], + }; +} diff --git a/src/lib/skills/source.ts b/src/lib/skills/source.ts new file mode 100644 index 0000000..abba6cb --- /dev/null +++ b/src/lib/skills/source.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; + +export type SkillDefinition = { + name: string; + description: string; + path: string; + tags?: string[]; +}; + +const SKILLS_OWNER = 'bitmovin'; +const SKILLS_REPO = 'skills'; +const SKILLS_REPOSITORY = `https://github.com/${SKILLS_OWNER}/${SKILLS_REPO}`; +const DEFAULT_REF = 'main'; + +export function getRef(ref?: string): string { + return ref ?? process.env.BITMOVIN_SKILLS_REF ?? DEFAULT_REF; +} + +export function archiveUrl(ref: string): string { + return `${SKILLS_REPOSITORY}/archive/${ref}.tar.gz`; +} + +export function validateSkillDefinition(skill: SkillDefinition): void { + validateSkillName(skill.name); + if (skill.path !== `skills/${skill.name}`) { + throw new Error(`Invalid skill path for ${skill.name}: ${skill.path}`); + } +} + +export function validateSkillName(name: string): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) { + throw new Error(`Invalid skill name: ${name}`); + } +} + +export function validateRelativePath(filePath: string, label: string): void { + const normalized = filePath.replace(/\\/g, '/'); + if ( + !normalized + || normalized.startsWith('/') + || normalized.startsWith('//') + || /^[a-zA-Z]:\//.test(normalized) + || path.isAbsolute(filePath) + || normalized.split('/').includes('..') + ) { + throw new Error(`Invalid ${label}: ${filePath}`); + } +} diff --git a/test/lib/skills.test.ts b/test/lib/skills.test.ts new file mode 100644 index 0000000..810f4be --- /dev/null +++ b/test/lib/skills.test.ts @@ -0,0 +1,156 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {create} from 'tar'; +import {parseAgents, resolveTargets, type SkillDirOverrides} from '../../src/lib/skills/agents.js'; +import {loadSkills, searchSkills} from '../../src/lib/skills/catalog.js'; +import {installPayload, loadRemoteSkillPayload, removeSkill} from '../../src/lib/skills/install.js'; +import {loadLocalCliSkill} from '../../src/lib/skills/local.js'; + +const originalFetch = globalThis.fetch; + +describe('skills library', () => { + let tempDir: string; + let skillDirs: SkillDirOverrides; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bitmovin-skills-test-')); + skillDirs = { + pi: path.join(tempDir, 'pi'), + claude: path.join(tempDir, 'claude'), + codex: path.join(tempDir, 'codex'), + gemini: path.join(tempDir, 'gemini'), + }; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + it('parses and validates supported agents', () => { + expect(parseAgents('pi,claude')).toEqual(['pi', 'claude']); + expect(parseAgents('pi,pi,claude')).toEqual(['pi', 'claude']); + expect(() => parseAgents('unknown')).toThrow('Unsupported agent'); + expect(() => parseAgents('')).toThrow('No agents specified'); + }); + + it('only auto-detects existing target directories', async () => { + await fs.mkdir(path.join(tempDir, 'pi'), {recursive: true}); + expect(resolveTargets(undefined, skillDirs).map(target => target.agent)).toEqual(['pi']); + }); + + it('loads the packaged local CLI skill', async () => { + const skill = await loadLocalCliSkill(); + expect(skill.name).toBe('bitmovin-cli'); + expect(skill.files).toHaveLength(1); + expect(skill.files[0].path).toBe('SKILL.md'); + expect(skill.files[0].content).toContain('Bitmovin CLI'); + }); + + it('installs payloads atomically and removes stale files', async () => { + const [target] = resolveTargets(parseAgents('pi'), skillDirs); + const staleFile = path.join(target.skillsDir, 'bitmovin-cli', 'old.txt'); + await fs.mkdir(path.dirname(staleFile), {recursive: true}); + await fs.writeFile(staleFile, 'stale'); + + await installPayload({name: 'bitmovin-cli', files: [{path: 'SKILL.md', content: '# CLI'}]}, [target]); + + await expect(fs.readFile(path.join(target.skillsDir, 'bitmovin-cli', 'SKILL.md'), 'utf8')).resolves.toBe('# CLI'); + await expect(fs.access(staleFile)).rejects.toThrow(); + }); + + it('rejects unsafe payload paths at the installer boundary', async () => { + const [target] = resolveTargets(parseAgents('pi'), skillDirs); + await expect(installPayload({ + name: 'bitmovin-cli', + files: [{path: '../escape', content: 'nope'}], + }, [target])).rejects.toThrow('Invalid bitmovin-cli file'); + await expect(installPayload({ + name: 'bitmovin-cli', + files: [{path: 'C:\\escape', content: 'nope'}], + }, [target])).rejects.toThrow('Invalid bitmovin-cli file'); + }); + + it('does not write during dry-run install or remove', async () => { + const [target] = resolveTargets(parseAgents('pi'), skillDirs); + await installPayload({name: 'bitmovin-cli', files: [{path: 'SKILL.md', content: '# CLI'}]}, [target], true); + await expect(fs.access(path.join(target.skillsDir, 'bitmovin-cli'))).rejects.toThrow(); + + await fs.mkdir(path.join(target.skillsDir, 'bitmovin-cli'), {recursive: true}); + await removeSkill('bitmovin-cli', [target], true); + await expect(fs.access(path.join(target.skillsDir, 'bitmovin-cli'))).resolves.toBeUndefined(); + }); + + it('discovers skills from repository archives', async () => { + const archive = await createSkillArchive(tempDir, { + 'skills/bitmovin/SKILL.md': '---\nname: bitmovin\ndescription: Hub skill\n---\n# Bitmovin', + 'skills/bitmovin-player-web/SKILL.md': '---\nname: bitmovin-player-web\ndescription: Web player\n---\n# Web', + }); + globalThis.fetch = vi.fn(async () => new Response(await fs.readFile(archive), {status: 200})) as typeof fetch; + + const skills = await loadSkills(); + + expect(skills).toEqual([ + {name: 'bitmovin', description: 'Hub skill', path: 'skills/bitmovin'}, + {name: 'bitmovin-player-web', description: 'Web player', path: 'skills/bitmovin-player-web', tags: ['player', 'web']}, + ]); + expect(searchSkills(skills, 'web')).toHaveLength(1); + }); + + it('loads remote skill payloads from repository archives', async () => { + const archive = await createSkillArchive(tempDir, { + 'skills/bitmovin/SKILL.md': '# Bitmovin', + 'skills/bitmovin/examples/example.txt': 'example', + 'skills/other/SKILL.md': '# Other', + }); + globalThis.fetch = vi.fn(async () => new Response(await fs.readFile(archive), {status: 200})) as typeof fetch; + + const payload = await loadRemoteSkillPayload({name: 'bitmovin', description: 'Hub', path: 'skills/bitmovin'}); + + expect(payload).toEqual({ + name: 'bitmovin', + files: [ + {path: 'examples/example.txt', content: 'example'}, + {path: 'SKILL.md', content: '# Bitmovin'}, + ], + }); + }); + + it('rejects linked archive entries', async () => { + const archive = await createSkillArchive(tempDir, {'skills/bitmovin/SKILL.md': '# Bitmovin'}, {symlink: 'skills/bitmovin/link'}); + globalThis.fetch = vi.fn(async () => new Response(await fs.readFile(archive), {status: 200})) as typeof fetch; + + await expect(loadRemoteSkillPayload({name: 'bitmovin', description: 'Hub', path: 'skills/bitmovin'})) + .rejects.toThrow('links are not allowed'); + }); + + it('validates skill definitions before loading remote payloads', async () => { + await expect(loadRemoteSkillPayload({ + name: 'bitmovin', + description: 'Hub', + path: 'other/bitmovin', + })).rejects.toThrow('Invalid skill path'); + }); +}); + +async function createSkillArchive(tempDir: string, files: Record, options: {symlink?: string} = {}): Promise { + const root = path.join(tempDir, `repo-${Math.random().toString(36).slice(2)}`); + for (const [file, content] of Object.entries(files)) { + const output = path.join(root, file); + await fs.mkdir(path.dirname(output), {recursive: true}); + await fs.writeFile(output, content); + } + + if (options.symlink) { + const link = path.join(root, options.symlink); + await fs.mkdir(path.dirname(link), {recursive: true}); + await fs.symlink('/tmp', link); + } + + const archive = path.join(tempDir, `${path.basename(root)}.tar.gz`); + await create({gzip: true, file: archive, cwd: tempDir}, [path.basename(root)]); + return archive; +} From e975b5cfa827792cc955ccaba14298be3172dfc9 Mon Sep 17 00:00:00 2001 From: Lukas Knoch-Girstmair Date: Fri, 22 May 2026 12:51:37 +0200 Subject: [PATCH 2/3] Update CLI skill command reference --- skills/bitmovin-cli/SKILL.md | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/skills/bitmovin-cli/SKILL.md b/skills/bitmovin-cli/SKILL.md index 9edac68..7b86554 100644 --- a/skills/bitmovin-cli/SKILL.md +++ b/skills/bitmovin-cli/SKILL.md @@ -14,6 +14,48 @@ bitmovin config list organizations bitmovin config set organization ``` +## AI Assistant Skills + +The CLI can install this local CLI reference skill and additional Bitmovin skills for AI assistants. + +```bash +# Install the local bitmovin-cli skill into detected agent skill directories +bitmovin init + +# Install the local CLI skill for a specific supported agent +bitmovin init --agent pi +bitmovin init --agent claude +bitmovin init --agent codex +bitmovin init --agent gemini + +# Print this local CLI skill markdown to stdout +bitmovin skill +``` + +Remote Bitmovin skills are discovered from the GitHub archive at `https://github.com/bitmovin/skills` by scanning `skills/*/SKILL.md`. + +```bash +# List available remote Bitmovin skills +bitmovin skills list +bitmovin skills list --long + +# Search by skill name, description, or inferred tags +bitmovin skills find android +bitmovin skills find encoding +bitmovin skills find player + +# Install remote skills +bitmovin skills add --skill bitmovin +bitmovin skills add --skill bitmovin-player-android --agent pi +bitmovin skills add --all --agent pi + +# Remove installed skills +bitmovin skills remove --skill bitmovin --agent pi +``` + +Supported agents: `pi`, `claude`, `codex`, `gemini`. If `--agent` is omitted, the CLI installs/removes skills in detected existing agent skill directories. + + ## Encoding ### Templates (recommended workflow) From 1e9b063e735b078040383f138681af3594fe9308 Mon Sep 17 00:00:00 2001 From: Lukas Knoch-Girstmair Date: Fri, 22 May 2026 13:16:48 +0200 Subject: [PATCH 3/3] Sync package lockfile for CI --- package-lock.json | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9efab0..bb8f4c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1015,6 +1015,31 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1022,6 +1047,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2231,7 +2257,6 @@ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.3.tgz", "integrity": "sha512-0mD8vcrrX5uRsxzvI8tbWmSVGngvZA/Qo6O0ZGvLPAWEauSf5GFniwgirhY0SkszuHwu0S1J1ivj/jHmqtIDuA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", @@ -3643,7 +3668,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3948,7 +3972,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4571,7 +4594,6 @@ "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7922,7 +7944,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9450,7 +9471,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9551,7 +9571,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9664,7 +9683,6 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4",