diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml new file mode 100644 index 0000000..2fa1b47 --- /dev/null +++ b/.github/workflows/sync-skills.yml @@ -0,0 +1,72 @@ +name: Sync agent skills + +# Keeps skill/ in sync with the agent skills published in muxinc/skills. +# Triggered by a repository_dispatch from that repo on skill changes, with a +# weekly cron as a backstop and workflow_dispatch for manual runs. Opens a PR +# only when the synced content actually changed. +# +# EMBEDDED_SKILLS is an allowlist: muxinc/skills also publishes skills with +# vendored reference docs (skills/mux), which the CLI intentionally does not +# embed — the CLI ships routing skills only, never docs content. + +on: + repository_dispatch: + types: [skills-updated] + workflow_dispatch: + schedule: + - cron: '17 6 * * 1' + +permissions: + contents: write + pull-requests: write + +env: + EMBEDDED_SKILLS: mux-docs + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check out muxinc/skills + uses: actions/checkout@v4 + with: + repository: muxinc/skills + path: .skills-src + + - uses: oven-sh/setup-bun@v2 + + - name: Copy skills into skill/ + run: | + set -euo pipefail + rm -rf skill + mkdir -p skill + for name in $EMBEDDED_SKILLS; do + test -f ".skills-src/skills/$name/SKILL.md" + cp -R ".skills-src/skills/$name" "skill/$name" + done + SKILLS_SHA=$(git -C .skills-src log -1 --format=%H -- $(printf "skills/%s " $EMBEDDED_SKILLS)) + printf '{\n "source": "muxinc/skills",\n "commit": "%s"\n}\n' "$SKILLS_SHA" > skill/manifest.json + rm -rf .skills-src + + - name: Regenerate embedded skills module + run: bun scripts/generate-embedded-skills.ts + + - name: Verify embedded module matches skill/ + run: bun test src/lib/embedded-skills.test.ts + + - name: Open pull request + uses: peter-evans/create-pull-request@v6 + with: + commit-message: 'chore: sync agent skills from muxinc/skills' + title: 'Sync agent skills from muxinc/skills' + body: | + Automated sync of the agent skills embedded in the CLI from + [muxinc/skills](https://github.com/muxinc/skills). The source + commit is recorded in `skill/manifest.json`. + + The next tagged release will embed these files in the compiled + binaries via `src/lib/embedded-skills.gen.ts`. + branch: chore/sync-agent-skills + delete-branch: true diff --git a/package.json b/package.json index 47519bc..e38eca1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "check:write": "pnpm exec biome check --write", "typecheck": "bun --bun tsc --noEmit", "test": "bun test", - "test:watch": "bun test --watch" + "test:watch": "bun test --watch", + "generate:skills": "bun scripts/generate-embedded-skills.ts" }, "dependencies": { "@cliffy/ansi": "jsr:1.0.0-rc.8", diff --git a/scripts/generate-embedded-skills.ts b/scripts/generate-embedded-skills.ts new file mode 100644 index 0000000..d24a5f2 --- /dev/null +++ b/scripts/generate-embedded-skills.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun +/** + * Generates src/lib/embedded-skills.gen.ts from the contents of skill/. + * + * The skill/ directory is synced from muxinc/skills by the sync-skills + * workflow. Embedding the files as string constants means every build target + * (bun run, dist bundle, compiled binary) ships the skills with no runtime + * filesystem or network dependency. + * + * Run with: pnpm run generate:skills + */ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '..'); +const skillDir = join(repoRoot, 'skill'); +const outPath = join(repoRoot, 'src', 'lib', 'embedded-skills.gen.ts'); + +const entries = await readdir(skillDir, { + recursive: true, + withFileTypes: true, +}); + +const files: Record = {}; +for (const entry of entries) { + if (!entry.isFile()) continue; + const absolute = join(entry.parentPath, entry.name); + const relative = absolute.slice(skillDir.length + 1); + files[relative] = await Bun.file(absolute).text(); +} + +const sortedPaths = Object.keys(files).sort(); +if (sortedPaths.length === 0) { + console.error( + 'No files found in skill/ — refusing to generate an empty module.', + ); + process.exit(1); +} + +const lines = [ + '// Generated by scripts/generate-embedded-skills.ts from skill/ — do not edit.', + '// Regenerate with: pnpm run generate:skills', + '', + 'export const EMBEDDED_SKILLS: Record = {', + ...sortedPaths.map( + (path) => ` ${JSON.stringify(path)}: ${JSON.stringify(files[path])},`, + ), + '};', + '', +]; + +await Bun.write(outPath, lines.join('\n')); +console.log(`Embedded ${sortedPaths.length} files into ${outPath}`); diff --git a/skill/manifest.json b/skill/manifest.json new file mode 100644 index 0000000..bed42cf --- /dev/null +++ b/skill/manifest.json @@ -0,0 +1,4 @@ +{ + "source": "muxinc/skills", + "commit": "99ff3093cf76a57a6745d04f2e45622175034411" +} diff --git a/skill/mux-docs/SKILL.md b/skill/mux-docs/SKILL.md new file mode 100644 index 0000000..c80c43c --- /dev/null +++ b/skill/mux-docs/SKILL.md @@ -0,0 +1,76 @@ +--- +name: mux-docs +description: Use for any question about Mux APIs, SDKs, the mux CLI, webhooks, assets, uploads, playback, live streaming, Mux Data, or Mux Robots. Finds the current Mux documentation — Mux publishes every docs page as LLM-ready markdown indexed at mux.com/llms.txt — so answers come from today's published docs instead of web search or model memory. +--- + +# Mux docs discovery + +Mux publishes its entire documentation as agent-ready markdown, auto-generated from the same source as the real docs site. For any Mux question, fetch the relevant page and answer from it — never answer Mux API questions from memory, because API shapes and guidance change. + +## Workflow + +1. **Route — try these in order and stop at the first hit.** The order is cost-ordered: each step reads fewer tokens than the next. + 1. If the `mux` CLI is installed, run `mux docs find "" --agent` — it searches the docs index CLI-side and returns only the matching page URLs, so you never read an index into context. (Check `mux docs --help`; older versions lack it.) + 2. Construct the URL from a known pattern: the tables below (collections, Robots `robots-.md`, API references) cover most topics. + 3. Fetch the smallest collection index for the topic area (a few dozen lines). + 4. Fetch https://www.mux.com/llms.txt — the master index of every docs page — only when the steps above fail. +2. **Fetch ONE page** — the page's markdown version: append `.md` to its URL (e.g. `https://www.mux.com/docs/guides/start-live-streaming.md`). Only add an API reference bundle when you need exact request/response shapes; do not fetch extra pages "for context." +3. **Answer from the fetched content** and cite the page URL. + +**Never fetch https://www.mux.com/llms-full.txt.** It is the entire documentation in a single file and will flood the context window; every page in it is reachable individually through the steps above. + +## Collection indexes + +| Collection | URL | +| --- | --- | +| Core concepts | https://www.mux.com/docs/core.txt | +| Video: upload, encode, manage assets | https://www.mux.com/docs/guides/video.txt | +| Uploader: handle user uploads | https://www.mux.com/docs/guides/uploader.txt | +| Player: embed video playback | https://www.mux.com/docs/guides/player.txt | +| Data: playback quality and analytics | https://www.mux.com/docs/guides/data.txt | +| Data integrations: third-party player monitoring | https://www.mux.com/docs/guides/data-integrations.txt | +| Framework integrations and SDKs | https://www.mux.com/docs/integrations.txt | +| Example implementations | https://www.mux.com/docs/examples.txt | +| Pricing and billing | https://www.mux.com/docs/pricing.txt | + + +## Mux Robots (AI video workflows) + +Robots — Mux's AI-powered video workflows — don't have their own collection index; their guides are part of the video collection and the master llms.txt index. Start here: + +| Resource | URL | +| --- | --- | +| Robots overview | https://www.mux.com/docs/guides/robots.md | +| Robots directives reference | https://www.mux.com/docs/guides/robots-directives.md | + +Task-specific guides follow the pattern `https://www.mux.com/docs/guides/robots-.md`, where `` is one of: `summarize`, `moderate`, `generate-chapters`, `ask-questions`, `find-key-moments`, `find-scenes`, `find-best-thumbnails`, `translate-captions`, `translate-audio`, `generate-premium-captions`, `edit-captions`, `generate-engagement-insights`. + +## API references and specs + +For exact endpoint, parameter, and webhook shapes, prefer these over prose guides: + +| Resource | URL | +| --- | --- | +| Video API reference | https://www.mux.com/docs/api-reference/video.txt | +| Data API reference | https://www.mux.com/docs/api-reference/data.txt | +| System API reference | https://www.mux.com/docs/api-reference/system.txt | +| Full API spec (OpenAPI JSON) | https://www.mux.com/api-spec.json | +| Webhook spec (JSON) | https://www.mux.com/webhook-spec.json | +| Mux Player web component API | https://raw.githubusercontent.com/muxinc/elements/refs/heads/main/packages/mux-player/REFERENCE.md | +| Mux Player React component API | https://raw.githubusercontent.com/muxinc/elements/refs/heads/main/packages/mux-player-react/REFERENCE.md | + +All of these resources are also discoverable from the docs site itself: every docs page links to "Docs for LLMs" (https://www.mux.com/docs/core/llms-txt), the canonical page describing Mux's llms.txt files and machine-readable bundles. If this skill's URL lists ever drift from reality, that page is the source of truth. + +## Self-healing + +If a docs URL 404s, the docs have likely been reorganized. Do not give up after one 404: run `mux docs find "" --agent` (or fetch https://www.mux.com/llms.txt and search it) to get the page's current URL. + +## Staying in sync + +- This skill is distributed from the `muxinc/skills` repository and ships embedded in the `mux` CLI. Updating the CLI (`brew upgrade mux` / `npm update -g @mux/cli`) updates the embedded skills, and `mux skills update` refreshes all local copies (including `~/.claude/skills`) to match the installed version. For skills decoupled from CLI releases, install the `mux@mux` Claude Code plugin or pull directly from https://github.com/muxinc/skills. +- The CLI embeds only skill instruction files, never docs content — always fetch docs live using the workflow above. Check `mux skills --help` for what the installed version offers. + +## Guardrails + +- Cite the docs page URL you answered from. +- If the network is unavailable, say you could not verify against current docs — never guess API request/response shapes from memory. diff --git a/src/commands/docs/find.ts b/src/commands/docs/find.ts new file mode 100644 index 0000000..545c12e --- /dev/null +++ b/src/commands/docs/find.ts @@ -0,0 +1,50 @@ +import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; +import { + fetchDocsIndex, + parseDocsIndex, + searchDocsIndex, +} from '../../lib/docs-index.ts'; + +interface DocsFindOptions { + json?: boolean; + limit: number; +} + +export const docsFindCommand = new Command() + .description( + 'Search the live Mux docs index (mux.com/llms.txt) and print matching page URLs.\n\nSearches CLI-side so agents get page URLs without reading the index into context. Fetch the returned URL for the page content; no docs are stored locally.', + ) + .arguments('') + .option('--limit ', 'Maximum number of results', { default: 5 }) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: DocsFindOptions, ...query: string[]) => { + try { + const text = await fetchDocsIndex(); + const entries = parseDocsIndex(text); + const results = searchDocsIndex(entries, query.join(' '), options.limit); + + if (options.json) { + console.log( + JSON.stringify({ query: query.join(' '), results }, null, 2), + ); + return; + } + + if (results.length === 0) { + console.log( + 'No matching docs pages. Try different terms, or browse https://www.mux.com/llms.txt', + ); + return; + } + + for (const result of results) { + console.log(result.url); + if (result.description) { + console.log(` ${result.description}`); + } + } + } catch (error) { + await handleCommandError(error, 'docs', 'find', options); + } + }); diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts new file mode 100644 index 0000000..850bfa1 --- /dev/null +++ b/src/commands/docs/index.ts @@ -0,0 +1,12 @@ +import { Command } from '@cliffy/command'; +import { docsFindCommand } from './find.ts'; + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const docsCommand: Command = new Command() + .description( + 'Search the live Mux documentation index.\n\nNo docs are stored locally — `find` searches mux.com/llms.txt and prints current page URLs.', + ) + .action(function () { + this.showHelp(); + }) + .command('find', docsFindCommand); diff --git a/src/commands/skills/index.ts b/src/commands/skills/index.ts new file mode 100644 index 0000000..0ef441c --- /dev/null +++ b/src/commands/skills/index.ts @@ -0,0 +1,16 @@ +import { Command } from '@cliffy/command'; +import { skillsInstallCommand } from './install.ts'; +import { skillsPathCommand } from './path.ts'; +import { skillsUpdateCommand } from './update.ts'; + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const skillsCommand: Command = new Command() + .description( + 'Locate or install the agent skills embedded in this CLI build.\n\nFor agents and other tooling, start with `mux skills path --json`.', + ) + .action(function () { + this.showHelp(); + }) + .command('path', skillsPathCommand) + .command('install', skillsInstallCommand) + .command('update', skillsUpdateCommand); diff --git a/src/commands/skills/install.ts b/src/commands/skills/install.ts new file mode 100644 index 0000000..d2808c3 --- /dev/null +++ b/src/commands/skills/install.ts @@ -0,0 +1,59 @@ +import { join } from 'node:path'; +import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; +import { + getDefaultAgentSkillsDir, + getSkillsManifest, + installSkills, + listSkills, +} from '../../lib/embedded-skills.ts'; + +interface DocsInstallOptions { + dir: string; + json?: boolean; +} + +export const skillsInstallCommand = new Command() + .description( + 'Install the embedded agent skills into an agent skills directory.\n\nDefaults to ~/.claude/skills, which Claude Code loads automatically — no CLAUDE.md or AGENTS.md changes needed. Run `mux skills update` after upgrading the CLI to refresh the installed copy.', + ) + .option('--dir ', 'Target skills directory', { + default: getDefaultAgentSkillsDir(), + }) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: DocsInstallOptions) => { + try { + const { dir } = await installSkills(options.dir); + const manifest = getSkillsManifest(); + const skills = listSkills().map((skill) => ({ + name: skill.name, + path: join(dir, skill.path), + })); + + if (options.json) { + console.log( + JSON.stringify( + { skills_dir: dir, source: manifest, skills }, + null, + 2, + ), + ); + return; + } + + console.log(`Installed ${skills.length} agent skills to ${dir}`); + console.log( + `Synced from: ${manifest.source}@${manifest.commit.slice(0, 7)}`, + ); + console.log(''); + for (const skill of skills) { + console.log(` ${skill.name}`); + } + console.log(''); + console.log( + 'Claude Code loads these automatically in new sessions. Run `mux skills update` after upgrading the CLI to refresh this copy.', + ); + } catch (error) { + await handleCommandError(error, 'skills', 'install', options); + } + }); diff --git a/src/commands/skills/path.ts b/src/commands/skills/path.ts new file mode 100644 index 0000000..2256ab5 --- /dev/null +++ b/src/commands/skills/path.ts @@ -0,0 +1,50 @@ +import { join } from 'node:path'; +import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; +import { + getSkillsManifest, + listSkills, + materializeSkills, +} from '../../lib/embedded-skills.ts'; + +interface DocsPathOptions { + json?: boolean; +} + +export const skillsPathCommand = new Command() + .description( + 'Write the agent skills embedded in this CLI build to the Mux data directory and print their paths', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: DocsPathOptions) => { + try { + const { dir } = await materializeSkills(); + const manifest = getSkillsManifest(); + const skills = listSkills().map((skill) => ({ + name: skill.name, + path: join(dir, skill.path), + })); + + if (options.json) { + console.log( + JSON.stringify( + { skills_dir: dir, source: manifest, skills }, + null, + 2, + ), + ); + return; + } + + console.log(`Skills directory: ${dir}`); + console.log( + `Synced from: ${manifest.source}@${manifest.commit.slice(0, 7)}`, + ); + console.log(''); + for (const skill of skills) { + console.log(` ${skill.name.padEnd(14)} ${skill.path}`); + } + } catch (error) { + await handleCommandError(error, 'skills', 'path', options); + } + }); diff --git a/src/commands/skills/update.ts b/src/commands/skills/update.ts new file mode 100644 index 0000000..9f1a544 --- /dev/null +++ b/src/commands/skills/update.ts @@ -0,0 +1,86 @@ +import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; +import pkg from '../../../package.json'; +import { + getDefaultAgentSkillsDir, + getSkillsManifest, + hasInstalledSkills, + installSkills, + materializeSkills, +} from '../../lib/embedded-skills.ts'; +import { + compareSemver, + detectInstallMethod, + fetchLatestVersion, + getUpgradeCommand, +} from '../../lib/update-notifier.ts'; + +interface SkillsUpdateOptions { + json?: boolean; +} + +export const skillsUpdateCommand = new Command() + .description( + 'Refresh local skill copies from this CLI build and check for newer releases.\n\nSkills ship with the CLI, so the freshest copy comes from upgrading the CLI itself. This command rewrites the data-directory copy (and ~/.claude/skills, if installed) to match the installed CLI version, then reports when a newer release is available.', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: SkillsUpdateOptions) => { + try { + const { dir } = await materializeSkills(); + const manifest = getSkillsManifest(); + + const agentSkillsDir = getDefaultAgentSkillsDir(); + const installed = hasInstalledSkills(agentSkillsDir); + if (installed) { + await installSkills(agentSkillsDir); + } + + const latestVersion = + pkg.version === '0.0.0' ? null : await fetchLatestVersion(); + const updateAvailable = + latestVersion !== null && compareSemver(latestVersion, pkg.version) > 0; + + if (options.json) { + console.log( + JSON.stringify( + { + skills_dir: dir, + installed_dir: installed ? agentSkillsDir : null, + source: manifest, + cli_version: pkg.version, + latest_cli_version: latestVersion, + update_available: updateAvailable, + }, + null, + 2, + ), + ); + return; + } + + console.log(`Refreshed skills at ${dir}`); + if (installed) { + console.log(`Refreshed installed skills at ${agentSkillsDir}`); + } else { + console.log( + `No copy found in ${agentSkillsDir} — run \`mux skills install\` to enable automatic loading in Claude Code.`, + ); + } + console.log( + `Synced from ${manifest.source}@${manifest.commit.slice(0, 7)} (ships with CLI ${pkg.version})`, + ); + + if (updateAvailable) { + const command = getUpgradeCommand(detectInstallMethod()); + console.log(''); + console.log( + `A newer CLI release (${latestVersion}) is available and may include updated skills.`, + ); + console.log(`Run \`${command}\`, then \`mux skills update\` again.`); + } else if (latestVersion !== null) { + console.log('You are on the latest CLI release.'); + } + } catch (error) { + await handleCommandError(error, 'skills', 'update', options); + } + }); diff --git a/src/index.ts b/src/index.ts index f3670bc..5cba9e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { assetsCommand } from './commands/assets/index.ts'; import { completionsInstallCommand } from './commands/completions-install.ts'; import { deliveryUsageCommand } from './commands/delivery-usage/index.ts'; import { dimensionsCommand } from './commands/dimensions/index.ts'; +import { docsCommand } from './commands/docs/index.ts'; import { drmConfigurationsCommand } from './commands/drm-configurations/index.ts'; import { envCommand } from './commands/env/index.ts'; import { errorsCommand } from './commands/errors/index.ts'; @@ -22,6 +23,7 @@ import { playbackRestrictionsCommand } from './commands/playback-restrictions/in import { robotsCommand } from './commands/robots/index.ts'; import { signCommand } from './commands/sign.ts'; import { signingKeysCommand } from './commands/signing-keys/index.ts'; +import { skillsCommand } from './commands/skills/index.ts'; import { transcriptionVocabulariesCommand } from './commands/transcription-vocabularies/index.ts'; import { uploadsCommand } from './commands/uploads/index.ts'; import { videoViewsCommand } from './commands/video-views/index.ts'; @@ -56,7 +58,9 @@ function preprocessArgs(argv: string[]): string[] { const cli = new Command() .name('mux') .version(VERSION) - .description('Official Mux CLI for interacting with Mux APIs') + .description( + 'Official Mux CLI for interacting with Mux APIs\n\nAgent support:\n Run `mux skills path --json` to locate the embedded agent skills, or\n `mux skills install` to install them into ~/.claude/skills for Claude Code.\n `mux docs find "" --json` searches the live Mux docs index.', + ) .globalOption( '--agent', 'Agent mode: uses JSON output and identifies as an agent in User-Agent header.', @@ -68,6 +72,8 @@ const cli = new Command() .command('login', loginCommand) .command('logout', logoutCommand) .command('env', envCommand) + .command('docs', docsCommand) + .command('skills', skillsCommand) .command('assets', assetsCommand) .command('live', liveCommand) .command('playback-ids', playbackIdsCommand) diff --git a/src/lib/docs-index.test.ts b/src/lib/docs-index.test.ts new file mode 100644 index 0000000..9d514da --- /dev/null +++ b/src/lib/docs-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'bun:test'; +import { parseDocsIndex, searchDocsIndex } from './docs-index.ts'; + +const FIXTURE = `# Mux Documentation for LLMs + +> Mux is how developers build online video. + +## Quick start: What are you trying to do? + +- **Upload and stream a video file** → Start with /docs/core.txt + +## Docs + +- [/docs/core.txt](https://www.mux.com/docs/core.txt): Core Mux concepts (start here for most projects) +- [/llms-full.txt](https://www.mux.com/llms-full.txt): All Mux docs in one file (if your context window is large enough) +- [/docs/guides/video.txt](https://www.mux.com/docs/guides/video.txt): Upload, encode, and manage video assets +- [/docs/guides/robots-moderate.md](https://www.mux.com/docs/guides/robots-moderate.md): Analyze video content for policy violations with Mux Robots +- [/docs/guides/robots-summarize.md](https://www.mux.com/docs/guides/robots-summarize.md): Generate titles, descriptions, and tags +- [/docs/guides/start-live-streaming.md](https://www.mux.com/docs/guides/start-live-streaming.md): Create and broadcast live streams +`; + +describe('parseDocsIndex', () => { + it('parses path, url, and description from index lines', () => { + const entries = parseDocsIndex(FIXTURE); + const core = entries.find((entry) => entry.path === '/docs/core.txt'); + expect(core?.url).toBe('https://www.mux.com/docs/core.txt'); + expect(core?.description).toBe( + 'Core Mux concepts (start here for most projects)', + ); + }); + + it('ignores prose lines that are not index entries', () => { + const entries = parseDocsIndex(FIXTURE); + expect(entries.length).toBe(5); + }); + + it('excludes llms-full.txt so it is never recommended', () => { + const entries = parseDocsIndex(FIXTURE); + expect(entries.some((entry) => entry.url.includes('llms-full'))).toBe( + false, + ); + }); +}); + +describe('searchDocsIndex', () => { + const entries = parseDocsIndex(FIXTURE); + + it('ranks the page matching all terms first', () => { + const results = searchDocsIndex(entries, 'robots moderate'); + expect(results[0].path).toBe('/docs/guides/robots-moderate.md'); + }); + + it('matches terms in descriptions, not just paths', () => { + const results = searchDocsIndex(entries, 'policy violations'); + expect(results[0].path).toBe('/docs/guides/robots-moderate.md'); + }); + + it('returns no results for a query matching nothing', () => { + expect(searchDocsIndex(entries, 'kubernetes helm chart')).toEqual([]); + }); + + it('respects the limit', () => { + const results = searchDocsIndex(entries, 'video', 1); + expect(results.length).toBe(1); + }); +}); diff --git a/src/lib/docs-index.ts b/src/lib/docs-index.ts new file mode 100644 index 0000000..9b22e82 --- /dev/null +++ b/src/lib/docs-index.ts @@ -0,0 +1,76 @@ +const LLMS_INDEX_URL = 'https://www.mux.com/llms.txt'; +const FETCH_TIMEOUT_MS = 5000; + +export interface DocsIndexEntry { + path: string; + url: string; + description: string; +} + +/** + * Parse mux.com/llms.txt index lines of the form + * `- [/docs/guides/foo.md](https://www.mux.com/docs/guides/foo.md): description`. + * llms-full.txt is excluded: it is the entire documentation in one file and + * should never be recommended to an agent. + */ +export function parseDocsIndex(text: string): DocsIndexEntry[] { + const entries: DocsIndexEntry[] = []; + const pattern = /^- \[([^\]]+)\]\((https:\/\/[^)]+)\)(?::\s*(.*))?$/; + for (const line of text.split('\n')) { + const match = line.trim().match(pattern); + if (!match) continue; + if (match[2].includes('llms-full')) continue; + entries.push({ + path: match[1], + url: match[2], + description: match[3]?.trim() ?? '', + }); + } + return entries; +} + +/** + * Rank index entries against a search query. Terms match case-insensitively + * against the path and description; path matches score slightly higher so + * exact page names win over passing mentions. + */ +export function searchDocsIndex( + entries: DocsIndexEntry[], + query: string, + limit = 5, +): DocsIndexEntry[] { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return []; + + const scored = entries + .map((entry) => { + const path = entry.path.toLowerCase(); + const description = entry.description.toLowerCase(); + let score = 0; + for (const term of terms) { + if (path.includes(term)) score += 2; + else if (description.includes(term)) score += 1; + } + return { entry, score }; + }) + .filter((item) => item.score > 0); + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map((item) => item.entry); +} + +/** + * Fetch the live docs index from mux.com. Throws on network failure or a + * non-OK response so the command can report it. + */ +export async function fetchDocsIndex(): Promise { + const response = await fetch(LLMS_INDEX_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error( + `Failed to fetch ${LLMS_INDEX_URL}: ${response.status} ${response.statusText}`, + ); + } + return response.text(); +} diff --git a/src/lib/embedded-skills.gen.ts b/src/lib/embedded-skills.gen.ts new file mode 100644 index 0000000..0030e12 --- /dev/null +++ b/src/lib/embedded-skills.gen.ts @@ -0,0 +1,7 @@ +// Generated by scripts/generate-embedded-skills.ts from skill/ — do not edit. +// Regenerate with: pnpm run generate:skills + +export const EMBEDDED_SKILLS: Record = { + "manifest.json": "{\n \"source\": \"muxinc/skills\",\n \"commit\": \"99ff3093cf76a57a6745d04f2e45622175034411\"\n}\n", + "mux-docs/SKILL.md": "---\nname: mux-docs\ndescription: Use for any question about Mux APIs, SDKs, the mux CLI, webhooks, assets, uploads, playback, live streaming, Mux Data, or Mux Robots. Finds the current Mux documentation — Mux publishes every docs page as LLM-ready markdown indexed at mux.com/llms.txt — so answers come from today's published docs instead of web search or model memory.\n---\n\n# Mux docs discovery\n\nMux publishes its entire documentation as agent-ready markdown, auto-generated from the same source as the real docs site. For any Mux question, fetch the relevant page and answer from it — never answer Mux API questions from memory, because API shapes and guidance change.\n\n## Workflow\n\n1. **Route — try these in order and stop at the first hit.** The order is cost-ordered: each step reads fewer tokens than the next.\n 1. If the `mux` CLI is installed, run `mux docs find \"\" --agent` — it searches the docs index CLI-side and returns only the matching page URLs, so you never read an index into context. (Check `mux docs --help`; older versions lack it.)\n 2. Construct the URL from a known pattern: the tables below (collections, Robots `robots-.md`, API references) cover most topics.\n 3. Fetch the smallest collection index for the topic area (a few dozen lines).\n 4. Fetch https://www.mux.com/llms.txt — the master index of every docs page — only when the steps above fail.\n2. **Fetch ONE page** — the page's markdown version: append `.md` to its URL (e.g. `https://www.mux.com/docs/guides/start-live-streaming.md`). Only add an API reference bundle when you need exact request/response shapes; do not fetch extra pages \"for context.\"\n3. **Answer from the fetched content** and cite the page URL.\n\n**Never fetch https://www.mux.com/llms-full.txt.** It is the entire documentation in a single file and will flood the context window; every page in it is reachable individually through the steps above.\n\n## Collection indexes\n\n| Collection | URL |\n| --- | --- |\n| Core concepts | https://www.mux.com/docs/core.txt |\n| Video: upload, encode, manage assets | https://www.mux.com/docs/guides/video.txt |\n| Uploader: handle user uploads | https://www.mux.com/docs/guides/uploader.txt |\n| Player: embed video playback | https://www.mux.com/docs/guides/player.txt |\n| Data: playback quality and analytics | https://www.mux.com/docs/guides/data.txt |\n| Data integrations: third-party player monitoring | https://www.mux.com/docs/guides/data-integrations.txt |\n| Framework integrations and SDKs | https://www.mux.com/docs/integrations.txt |\n| Example implementations | https://www.mux.com/docs/examples.txt |\n| Pricing and billing | https://www.mux.com/docs/pricing.txt |\n\n\n## Mux Robots (AI video workflows)\n\nRobots — Mux's AI-powered video workflows — don't have their own collection index; their guides are part of the video collection and the master llms.txt index. Start here:\n\n| Resource | URL |\n| --- | --- |\n| Robots overview | https://www.mux.com/docs/guides/robots.md |\n| Robots directives reference | https://www.mux.com/docs/guides/robots-directives.md |\n\nTask-specific guides follow the pattern `https://www.mux.com/docs/guides/robots-.md`, where `` is one of: `summarize`, `moderate`, `generate-chapters`, `ask-questions`, `find-key-moments`, `find-scenes`, `find-best-thumbnails`, `translate-captions`, `translate-audio`, `generate-premium-captions`, `edit-captions`, `generate-engagement-insights`.\n\n## API references and specs\n\nFor exact endpoint, parameter, and webhook shapes, prefer these over prose guides:\n\n| Resource | URL |\n| --- | --- |\n| Video API reference | https://www.mux.com/docs/api-reference/video.txt |\n| Data API reference | https://www.mux.com/docs/api-reference/data.txt |\n| System API reference | https://www.mux.com/docs/api-reference/system.txt |\n| Full API spec (OpenAPI JSON) | https://www.mux.com/api-spec.json |\n| Webhook spec (JSON) | https://www.mux.com/webhook-spec.json |\n| Mux Player web component API | https://raw.githubusercontent.com/muxinc/elements/refs/heads/main/packages/mux-player/REFERENCE.md |\n| Mux Player React component API | https://raw.githubusercontent.com/muxinc/elements/refs/heads/main/packages/mux-player-react/REFERENCE.md |\n\nAll of these resources are also discoverable from the docs site itself: every docs page links to \"Docs for LLMs\" (https://www.mux.com/docs/core/llms-txt), the canonical page describing Mux's llms.txt files and machine-readable bundles. If this skill's URL lists ever drift from reality, that page is the source of truth.\n\n## Self-healing\n\nIf a docs URL 404s, the docs have likely been reorganized. Do not give up after one 404: run `mux docs find \"\" --agent` (or fetch https://www.mux.com/llms.txt and search it) to get the page's current URL.\n\n## Staying in sync\n\n- This skill is distributed from the `muxinc/skills` repository and ships embedded in the `mux` CLI. Updating the CLI (`brew upgrade mux` / `npm update -g @mux/cli`) updates the embedded skills, and `mux skills update` refreshes all local copies (including `~/.claude/skills`) to match the installed version. For skills decoupled from CLI releases, install the `mux@mux` Claude Code plugin or pull directly from https://github.com/muxinc/skills.\n- The CLI embeds only skill instruction files, never docs content — always fetch docs live using the workflow above. Check `mux skills --help` for what the installed version offers.\n\n## Guardrails\n\n- Cite the docs page URL you answered from.\n- If the network is unavailable, say you could not verify against current docs — never guess API request/response shapes from memory.\n", +}; diff --git a/src/lib/embedded-skills.test.ts b/src/lib/embedded-skills.test.ts new file mode 100644 index 0000000..22a4256 --- /dev/null +++ b/src/lib/embedded-skills.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + EMBEDDED_SKILLS, + getSkillsManifest, + hasInstalledSkills, + installSkills, + listSkills, + materializeSkills, +} from './embedded-skills.ts'; + +describe('embedded skills', () => { + it('includes the docs discovery skill', () => { + expect(Object.keys(EMBEDDED_SKILLS)).toContain('mux-docs/SKILL.md'); + }); + + it('includes a manifest recording the source repository', () => { + const manifest = getSkillsManifest(); + expect(manifest.source).toBe('muxinc/skills'); + expect(manifest.commit).toMatch(/^[0-9a-f]{40}$/); + }); + + it('stays in sync with the skill/ directory', async () => { + const skillDir = join(import.meta.dir, '..', '..', 'skill'); + const entries = await readdir(skillDir, { + recursive: true, + withFileTypes: true, + }); + const onDisk: Record = {}; + for (const entry of entries) { + if (!entry.isFile()) continue; + const absolute = join(entry.parentPath, entry.name); + const relative = absolute.slice(skillDir.length + 1); + onDisk[relative] = await Bun.file(absolute).text(); + } + expect(EMBEDDED_SKILLS).toEqual(onDisk); + }); + + it('lists each skill with its SKILL.md path', () => { + const skills = listSkills(); + const names = skills.map((skill) => skill.name); + expect(names).toContain('mux-docs'); + for (const skill of skills) { + expect(skill.path).toBe(`${skill.name}/SKILL.md`); + } + }); +}); + +describe('materializeSkills', () => { + it('writes every embedded file into the target directory', async () => { + const testDir = await mkdtemp(join(tmpdir(), 'mux-cli-skills-test-')); + try { + const { dir, files } = await materializeSkills(testDir); + expect(dir).toBe(testDir); + expect(files.length).toBe(Object.keys(EMBEDDED_SKILLS).length); + for (const [relative, contents] of Object.entries(EMBEDDED_SKILLS)) { + expect(await Bun.file(join(testDir, relative)).text()).toBe(contents); + } + } finally { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('detects whether skills are installed in a directory', async () => { + const testDir = await mkdtemp(join(tmpdir(), 'mux-cli-skills-test-')); + try { + expect(hasInstalledSkills(testDir)).toBe(false); + await installSkills(testDir); + expect(hasInstalledSkills(testDir)).toBe(true); + } finally { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('installs skill directories without the sync manifest', async () => { + const testDir = await mkdtemp(join(tmpdir(), 'mux-cli-skills-test-')); + try { + const { files } = await installSkills(testDir); + expect(files.length).toBe(Object.keys(EMBEDDED_SKILLS).length - 1); + expect(await Bun.file(join(testDir, 'manifest.json')).exists()).toBe( + false, + ); + expect( + await Bun.file(join(testDir, 'mux-docs', 'SKILL.md')).exists(), + ).toBe(true); + } finally { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('overwrites stale files from a previous CLI version', async () => { + const testDir = await mkdtemp(join(tmpdir(), 'mux-cli-skills-test-')); + try { + await materializeSkills(testDir); + const skillPath = join(testDir, 'mux-docs', 'SKILL.md'); + await Bun.write(skillPath, 'stale contents from an old version'); + await materializeSkills(testDir); + expect(await Bun.file(skillPath).text()).toBe( + EMBEDDED_SKILLS['mux-docs/SKILL.md'], + ); + } finally { + await rm(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts new file mode 100644 index 0000000..803b7cd --- /dev/null +++ b/src/lib/embedded-skills.ts @@ -0,0 +1,97 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { EMBEDDED_SKILLS } from './embedded-skills.gen.ts'; +import { getSkillsDir } from './xdg.ts'; + +export { EMBEDDED_SKILLS }; + +export interface SkillsManifest { + source: string; + commit: string; +} + +export interface SkillEntry { + name: string; + path: string; +} + +export interface MaterializedSkills { + dir: string; + files: string[]; +} + +/** + * Parse the manifest recording which muxinc/skills commit the embedded + * skills were synced from. + */ +export function getSkillsManifest(): SkillsManifest { + return JSON.parse(EMBEDDED_SKILLS['manifest.json']) as SkillsManifest; +} + +/** + * List the embedded skills by name with the relative path to each SKILL.md. + */ +export function listSkills(): SkillEntry[] { + return Object.keys(EMBEDDED_SKILLS) + .filter((path) => path.endsWith('/SKILL.md')) + .map((path) => ({ name: dirname(path), path })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Write the embedded skill files to disk so agents can read them. + * Existing files are overwritten so the on-disk copy always matches the + * installed CLI version. + */ +export async function materializeSkills( + targetDir: string = getSkillsDir(), +): Promise { + const files: string[] = []; + for (const [relative, contents] of Object.entries(EMBEDDED_SKILLS)) { + const destination = join(targetDir, relative); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents); + files.push(destination); + } + return { dir: targetDir, files }; +} + +/** + * Default agent skills directory: ~/.claude/skills, which Claude Code loads + * automatically at session start. + */ +export function getDefaultAgentSkillsDir(): string { + return join(homedir(), '.claude', 'skills'); +} + +/** + * True if a previous `mux skills install` left a copy of any embedded skill + * in the target directory. + */ +export function hasInstalledSkills( + targetDir: string = getDefaultAgentSkillsDir(), +): boolean { + return listSkills().some((skill) => existsSync(join(targetDir, skill.path))); +} + +/** + * Install the embedded skills into an agent's skills directory (for example + * ~/.claude/skills, which Claude Code loads automatically). Writes only the + * skill directories, not the sync manifest, so the target directory contains + * nothing but skills. + */ +export async function installSkills( + targetDir: string, +): Promise { + const files: string[] = []; + for (const [relative, contents] of Object.entries(EMBEDDED_SKILLS)) { + if (relative === 'manifest.json') continue; + const destination = join(targetDir, relative); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents); + files.push(destination); + } + return { dir: targetDir, files }; +} diff --git a/src/lib/xdg.ts b/src/lib/xdg.ts index 2665ba4..5e52394 100644 --- a/src/lib/xdg.ts +++ b/src/lib/xdg.ts @@ -59,3 +59,10 @@ export function getEventsPath(): string { export function getEventsDatabasePath(): string { return join(getDataDir(), 'events.db'); } + +/** + * Get the directory where the embedded agent skills are written + */ +export function getSkillsDir(): string { + return join(getDataDir(), 'skills'); +}