Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions skills/capabilities-manager/references/capabilities-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ tools:

# rules: [ { id, type, content?, url?, path?, def?, providers?, appliesTo?, alwaysApply?, description?, visibility?, scope? } ]

# plugins: [ { id?, type: github|gitlab, def: { repo, subpath?, version?, ref?, description? }, servers?: { <manifestKey>: { as?: <serverId>, expose?: all|except|exactly|none, tools?: [<remoteName>] } } } ]
# plugins: [ { id?, type: github|gitlab|git, def: { repo (github/gitlab) | url (git), subpath?, version?, ref?, description? }, servers?: { <manifestKey>: { as?: <serverId>, expose?: all|except|exactly|none, tools?: [<remoteName>] } } } ]
# Plugins unpack into skills + servers + rules + sub-agents + hooks (Claude/Cursor manifests).
# `def.repo` mirrors the skill grammar:
# - `owner/repo` — manifest at the repo root
Expand All @@ -48,11 +48,12 @@ tools:
# subagents: [ { id, providers?, description?, skills, tools, instructions? } ]
```

## Skills Section (seven types)
## Skills Section (eight types)

- **inline**: Embed SKILL.md content in `def.content`. Use `requires: ['@server_id.tool_id']` for MCP tools, plain ID for command tools.
- **github**: `def.repo: owner/repo@skill-name` (search) or `owner/repo::path/to/skill-name` (exact). See "Repo string format" below for details.
- **gitlab**: `def.repo: group/subgroup/repo@skill-name` (search) or `group/subgroup/repo::path/to/skill-name` (exact). Subgroups are supported.
- **git**: `def.url` is the clone URL of any git host (ends in `.git`; `https`, `http`, `ssh` or `file`); `def.path` is the skill directory inside the repo (default: the root). Pin with `def.version` (tag/branch) or `def.ref` (commit). Unpinned installs take the newest `vX.Y.Z` tag. Private repos use your git credential helper.
- **remote**: `def.url` to a SKILL.md URL.
- **local**: `def.path` to a directory containing SKILL.md (read on each install).
- **installed**: User installed the skill elsewhere; capa only records it for tool binding. Declare directly in capabilities.yaml (no CLI shortcut) — set `def.description` and `def.requires` as needed.
Expand Down Expand Up @@ -544,6 +545,14 @@ plugins:
def:
repo: acme/platform/team/services/devops-skills
version: v1.0.1

# Any other git host: the clone URL, plus an optional subpath.
- id: deploy-tools
type: git
def:
url: https://git.example.com/team/tools.git
subpath: plugins/deploy
version: v2.0.0
```

Plugins are resolved during `capa install`. The plugin manifest is fetched from the repository; skill files are copied onto disk for each provider; MCP servers are registered; rules / sub-agents / hooks from the plugin are merged into the install pipeline (`${CLAUDE_PLUGIN_ROOT}` in hook scripts is rewritten to the stable plugin copy under `~/.capa/plugins/<projectId>/`). Plugin tools must be declared explicitly in the top-level `tools` section using `@server-id` references; plugin skills are activated via `type: plugin` skill entries (capa warns when a `type: plugin` skill id does not match any plugin manifest skill).
Expand Down
2 changes: 2 additions & 0 deletions skills/capabilities-manager/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Add an entry to `capabilities.yaml` (default) or write provider-native files imm
- **Pinning** (works with both `@` and `::`): append `:v1.2.3` for a tag/branch or `#abc1234` for a commit SHA — e.g. `capa add owner/repo@skill:v1.2.3`, `capa add gitlab:group/repo::skills/x/y#abc1234`.
- **Registry**: `capa add <registryId>:<itemId>` — resolve the skill from a configured registry adapter. Example: `capa add skills-sh:vercel-labs/skills/find-skills`.
- **Installed (no CLI)**: declare `type: installed` directly in `capabilities.yaml` — capa only records the skill for tool binding, it does not fetch or install anything. Set `def.description` and `def.requires` on the entry. Same pattern for `type: plugin` (skills shipped by a configured plugin).
- **Any git host**: `capa add https://git.example.com/team/skills.git` — a clone URL ending in `.git`, stored as type `git` with `def.url`. Installs the `SKILL.md` at the repo root; add `::path/to/skill` for another directory, and `:v1.2.3` / `#abc1234` to pin. `https://`, `http://`, `ssh://` and `file://` URLs work. Private repos use your git credential helper or SSH key (clone once with `git` to sign in).
- **Remote URL**: `capa add https://example.com/path/to/SKILL.md`
- **Local path**: `capa add ./path/to/skill` — directory must contain `SKILL.md`; stored as type `local` so the file is read on each install

Expand All @@ -123,6 +124,7 @@ Use `--plugin` to add a plugin entry (skills, MCP, rules, sub-agents, hooks) ins
- **GitHub subpath**: `capa add --plugin anthropics/claude-plugins-official::plugins/frontend-design`
- **GitLab (nested groups)**: `capa add --plugin gitlab:acme/platform/team/services/devops-skills:v1.0.1`
- **URL**: `capa add --plugin https://github.com/anthropics/claude-plugins-official/tree/main/plugins/code-review`
- **Any git host**: `capa add --plugin https://git.example.com/team/plugin.git[::path/to/plugin]` — stored as type `git` with `def.url` (and `def.subpath`)
- **Registry**: `capa add claude-plugins:frontend-design` — the registry adapter determines whether the item is a skill or plugin.

### Servers, tools, rules, hooks
Expand Down
13 changes: 13 additions & 0 deletions src/cli/commands/add-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { basename, resolve, relative } from 'path';
import { access } from 'fs/promises';
import { constants } from 'fs';
import { isGitUrl } from '../../shared/git-url';
import { CANONICAL_HOOK_EVENTS, type Hook, type HookSource } from '../../types/hooks';
import type { Rule } from '../../types/rules';
import type { MCPServer, Tool, SecretValue } from '../../types/capabilities';
Expand Down Expand Up @@ -328,6 +329,12 @@ export async function parseRuleSource(source: string): Promise<ParsedRuleSource>
};
}

if (isGitUrl(source)) {
throw new Error(
`Rules from a git clone URL are not supported yet: ${source}\n` +
` Use a raw URL to the rule file, or a GitHub/GitLab repo.`,
);
}
if (source.startsWith('http://') || source.startsWith('https://')) {
return {
id: basename(source).replace(/\.md$/i, '') || 'custom-rule',
Expand Down Expand Up @@ -422,6 +429,12 @@ function parseHookSource(raw: string): HookSource {
: absPath;
return { type: 'local', path: pathToStore };
}
if (isGitUrl(trimmed)) {
throw new Error(
`Hooks from a git clone URL are not supported yet: ${raw}\n` +
` Use a raw URL to the hook script, or a GitHub/GitLab repo.`,
);
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { type: 'remote', url: trimmed };
}
Expand Down
17 changes: 15 additions & 2 deletions src/cli/commands/add-parse-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { basename } from 'path';
import type { PluginDefinition } from '../../types/plugin';
import { validatePluginDef } from '../../shared/plugin-source';
import { getAllGitProviders } from '../../shared/git-providers/registry';
import { parseGitUrlSource } from '../../shared/git-url';

export interface ParsedPluginSource {
type: 'github' | 'gitlab';
type: 'github' | 'gitlab' | 'git';
def: PluginDefinition;
idHint: string;
}
Expand Down Expand Up @@ -63,6 +64,17 @@ export function parsePluginSource(source: string): ParsedPluginSource {
return buildPluginSourceFromRepoUrl(gp.id as 'github' | 'gitlab', parsed);
}

// A clone URL on any other git host: https://host/path/repo.git[::subpath][:version|#sha]
const gitSource = parseGitUrlSource(source);
if (gitSource) {
const { url, path, version, ref, idHint } = gitSource;
const def: PluginDefinition = { url };
if (path) def.subpath = path;
if (version) def.version = version;
if (ref) def.ref = ref;
return assertValidPluginSource({ type: 'git', def, idHint });
}

// GitLab `@name` search: gitlab:group/sub/project@plugin-name[:version|#sha]
const gitlabAtMatch = source.match(
/^gitlab:([\w.-]+(?:\/[\w.-]+)+)@([\w.-]+)(?::([\w.-]+))?(?:#([a-f0-9]{7,40}))?$/i
Expand Down Expand Up @@ -138,7 +150,8 @@ export function parsePluginSource(source: string): ParsedPluginSource {
` - Nested groups: gitlab:group/sub/project\n` +
` - Exact subpath: gitlab:group/project::plugins/my-plugin\n` +
` - Recursive @: gitlab:group/project@my-plugin\n` +
` - URL: https://gitlab.com/group/project/-/tree/main/plugins/my-plugin\n\n` +
` - URL: https://gitlab.com/group/project/-/tree/main/plugins/my-plugin\n` +
` Any git host: https://git.example.com/team/plugin.git[::path/to/plugin]\n\n` +
`Pinning (any of the above):\n` +
` - Tag: capa add --plugin owner/repo@my-plugin:v1.2.3\n` +
` - Commit: capa add --plugin owner/repo@my-plugin#abc123def\n\n` +
Expand Down
20 changes: 19 additions & 1 deletion src/cli/commands/add-parse-skill.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { resolve, basename, join, relative } from 'path';
import { access } from 'fs/promises';
import { constants } from 'fs';
import { parseGitUrlSource } from '../../shared/git-url';

export interface ParsedSkillSource {
id: string;
type: 'remote' | 'github' | 'gitlab' | 'local';
type: 'remote' | 'github' | 'gitlab' | 'git' | 'local';
def: {
repo?: string;
url?: string;
Expand Down Expand Up @@ -154,6 +155,22 @@ export async function parseSkillSource(source: string): Promise<ParsedSkillSourc
};
}

// A clone URL on any git host: https://host/path/repo.git[::path][:version|#sha]
const gitSource = parseGitUrlSource(source);
if (gitSource) {
const { url, path, version, ref, idHint } = gitSource;
return {
id: idHint || 'custom-skill',
type: 'git',
def: {
url,
...(path && { path }),
...(version && { version }),
...(ref && { ref }),
},
};
}

// Any other HTTP/HTTPS URL - treat as remote
if (source.startsWith('http://') || source.startsWith('https://')) {
const id = basename(source).replace(/\.md$/i, '') || 'custom-skill';
Expand All @@ -177,6 +194,7 @@ export async function parseSkillSource(source: string): Promise<ParsedSkillSourc
` - Recursive search: gitlab:owner/repo@skill-name\n` +
` - Exact path: gitlab:owner/repo::skills/path/to/skill-name\n` +
` - URL: https://gitlab.com/owner/repo/-/tree/main/skills/skill-name\n` +
` Any git host: https://git.example.com/team/skills.git[::path/to/skill]\n` +
` Local path: ./my-local-skills (directory containing SKILL.md)\n` +
` Remote SKILL.md URL: https://example.com/path/to/SKILL.md\n\n` +
`Pinning (any of the above):\n` +
Expand Down
10 changes: 6 additions & 4 deletions src/cli/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
import { tryResolveRegistryItem } from './resolve-registry-source';
import { parseSkillSource, type ParsedSkillSource } from './add-parse-skill';
import { parsePluginSource, type ParsedPluginSource } from './add-parse-plugin';
import { pluginSourceOf } from '../../shared/plugin-source';
import { stripHttpUrlUserinfo } from '../../shared/git-url';

export { parseSkillSource, type ParsedSkillSource } from './add-parse-skill';
export { parsePluginSource, type ParsedPluginSource } from './add-parse-plugin';
Expand Down Expand Up @@ -190,7 +192,7 @@ export async function addCommand(
(p) =>
(p as { id?: string }).id === resolved.itemName ||
(p.type === newPlugin.type &&
p.def.repo === newPlugin.def.repo &&
pluginSourceOf(p.def) === pluginSourceOf(newPlugin.def) &&
(p.def.subpath ?? '') === (newPlugin.def.subpath ?? '')),
);
if (existing) {
Expand Down Expand Up @@ -229,7 +231,7 @@ export async function addCommand(
const dup = capabilities.plugins.find(p =>
p.id === id ||
(p.type === parsed.type
&& p.def.repo === parsed.def.repo
&& pluginSourceOf(p.def) === pluginSourceOf(parsed.def)
&& (p.def.subpath ?? '') === (parsed.def.subpath ?? '')));
if (dup) {
console.error(`✗ Plugin "${id}" already exists in capabilities file.`);
Expand All @@ -246,7 +248,7 @@ export async function addCommand(

console.log(`✓ Added plugin "${id}" to ${capabilitiesFile.path}`);
console.log(` Type: ${parsed.type}`);
console.log(` Repo: ${parsed.def.repo}`);
console.log(` Repo: ${stripHttpUrlUserinfo(pluginSourceOf(parsed.def) ?? '')}`);
if (parsed.def.version) console.log(` Version: ${parsed.def.version}`);
if (parsed.def.ref) console.log(` Ref: ${parsed.def.ref}`);

Expand Down Expand Up @@ -288,7 +290,7 @@ export async function addCommand(
if (skillDef.def.repo) {
console.log(` Repo: ${skillDef.def.repo}`);
} else if (skillDef.def.url) {
console.log(` URL: ${skillDef.def.url}`);
console.log(` URL: ${stripHttpUrlUserinfo(skillDef.def.url)}`);
} else if (skillDef.def.path) {
console.log(` Path: ${skillDef.def.path}`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/install-tasks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,5 @@ export type GetRepoSnapshotFn = (
platform: CachePlatform,
repoPath: string,
authFetch: AuthenticatedFetch,
opts?: { version?: string; ref?: string; pinnedSha?: string; noCache?: boolean }
opts?: { version?: string; ref?: string; pinnedSha?: string; noCache?: boolean; repoUrl?: string }
) => Promise<GetSnapshotResult>
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { pathToFileURL } from 'url';
import { CapaDatabase } from '../../../../../db/database';
import { LockfileBuilder } from '../../../../../shared/lockfile';
import { installOneSkill, looksLikeHtml } from '../install-one-skill';
import { getCacheStats } from '../../../../../shared/cache';
import * as repoSnapshot from '../repo-snapshot';
import type { Capabilities, Skill } from '../../../../../types/capabilities';

// A skill on "any git host": a real repository cloned over file://, so the
// whole path (clone, tag resolution, snapshot, copy, lock entry) runs for real.
describe('installOneSkill with a git clone URL', () => {
let tempDir: string;
let projectPath: string;
let repoUrl: string;
let db: CapaDatabase;
const prevCacheDir = process.env.CAPA_CACHE_DIR;

const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'capa-git-skill-'));
process.env.CAPA_CACHE_DIR = join(tempDir, 'cache');
projectPath = join(tempDir, 'project');
mkdirSync(projectPath, { recursive: true });

const repo = join(tempDir, 'work');
mkdirSync(join(repo, 'nested', 'lint'), { recursive: true });
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'config', 'user.email', 'capa-test@example.com');
git(repo, 'config', 'user.name', 'capa-test');
writeFileSync(join(repo, 'SKILL.md'), '---\nname: code-review\ndescription: v1\n---\n\nFirst.\n');
writeFileSync(join(repo, 'nested', 'lint', 'SKILL.md'), '---\nname: lint\ndescription: lint\n---\n');
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'first');
git(repo, 'tag', 'v1.0.0');
writeFileSync(join(repo, 'SKILL.md'), '---\nname: code-review\ndescription: unreleased\n---\n\nSecond.\n');
git(repo, 'commit', '-q', '-am', 'second');
// Served like a real host: a bare repository whose name ends in .git.
const bare = join(tempDir, '@acme', 'code-review.git');
mkdirSync(join(tempDir, '@acme'));
git(tempDir, 'clone', '-q', '--bare', repo, bare);
repoUrl = pathToFileURL(bare).href;

db = new CapaDatabase(join(tempDir, 'capa.db'));
db.upsertProject({ id: 'proj', path: projectPath });
});

afterEach(() => {
db.close();
if (prevCacheDir === undefined) delete process.env.CAPA_CACHE_DIR;
else process.env.CAPA_CACHE_DIR = prevCacheDir;
rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
});

async function install(skill: Skill, lockBuilder = new LockfileBuilder()) {
const capabilities: Capabilities = { providers: ['cursor'], skills: [skill], servers: [], tools: [] };
await installOneSkill(
skill,
projectPath,
'proj',
['cursor'],
db,
{ server: { host: '127.0.0.1', port: 5912 } },
capabilities,
join(projectPath, 'capabilities.yaml'),
lockBuilder,
false,
new Map(),
);
return lockBuilder.build();
}

it('installs the root SKILL.md at the latest version tag and locks the URL', async () => {
const lock = await install({ id: 'code-review', type: 'git', def: { url: repoUrl } });

const installed = readFileSync(join(projectPath, '.cursor', 'skills', 'code-review', 'SKILL.md'), 'utf8');
expect(installed).toContain('description: v1');
expect(lock.skills[0]).toMatchObject({
id: 'code-review',
source: 'git',
url: repoUrl,
resolvedVersion: 'v1.0.0',
});
});

it('keeps credentials in the URL out of the lock entry and the clone', async () => {
const real = repoSnapshot.getRepoSnapshot;
const seen: (string | undefined)[] = [];
// No real host in tests: record the URL capa clones, then serve the local repo.
const spy = spyOn(repoSnapshot, 'getRepoSnapshot').mockImplementation((platform, repoPath, auth, opts = {}) => {
seen.push(opts.repoUrl);
return real(platform, repoPath, auth, { ...opts, repoUrl });
});
try {
const lock = await install({
id: 'code-review',
type: 'git',
def: { url: 'https://user:s3cret@git.example.com/acme/code-review.git' },
});
expect(lock.skills[0].url).toBe('https://git.example.com/acme/code-review.git');
expect(JSON.stringify(lock)).not.toContain('s3cret');
expect(seen).toEqual(['https://git.example.com/acme/code-review.git']);
} finally {
spy.mockRestore();
}
});

it('installs a nested skill from def.path', async () => {
await install({ id: 'lint', type: 'git', def: { url: repoUrl, path: 'nested/lint' } });
expect(readFileSync(join(projectPath, '.cursor', 'skills', 'lint', 'SKILL.md'), 'utf8')).toContain('name: lint');
});

it('names the skills it found when SKILL.md is missing, and leaves no lock pin', async () => {
const lockBuilder = new LockfileBuilder();
await expect(
install({ id: 'x', type: 'git', def: { url: repoUrl, path: 'nested' } }, lockBuilder),
).rejects.toThrow(/nested\/lint/);
expect(lockBuilder.build().skills).toEqual([]);
});

it('reports a missing tag as a missing tag, not a missing repository', async () => {
await expect(
install({ id: 'code-review', type: 'git', def: { url: repoUrl, version: 'v9.9.9' } }),
).rejects.toThrow(/Tag\/branch "v9\.9\.9" not found/);
});

it('shows git repositories in cache stats', async () => {
await install({ id: 'code-review', type: 'git', def: { url: repoUrl } });
const repos = getCacheStats().repos.filter((r) => r.platform === 'git');
expect(repos).toHaveLength(1);
expect(repos[0].repoPath).toMatch(/^file\/code-review-[0-9a-f]{12}$/);
expect(repos[0].snapshotCount).toBe(1);
});
});

describe('looksLikeHtml', () => {
it('catches ordinary web pages, with or without leading whitespace', () => {
expect(looksLikeHtml('<!DOCTYPE html>\n<html lang="en">')).toBe(true);
expect(looksLikeHtml(' \n<html lang="en"><head>')).toBe(true);
expect(looksLikeHtml('---\nname: x\n---\n<html> in a code sample')).toBe(false);
expect(looksLikeHtml('# Skill\n')).toBe(false);
});
});
Loading
Loading