diff --git a/docs/src/content/docs/docs/getting-started/quick-start.mdx b/docs/src/content/docs/docs/getting-started/quick-start.mdx index ef5e062..5fcb021 100644 --- a/docs/src/content/docs/docs/getting-started/quick-start.mdx +++ b/docs/src/content/docs/docs/getting-started/quick-start.mdx @@ -40,13 +40,19 @@ allagents update ## Add Individual Skills -Install a specific skill from a GitHub repo without adding the full plugin: +Pass `owner/repo` directly to install all skills from that repo — no flags required: + +```bash +allagents skill add ReScienceLab/opc-skills +``` + +To install only a specific skill from a multi-skill repo: ```bash allagents skill add reddit --from ReScienceLab/opc-skills ``` -Or pass a GitHub URL directly: +Or point at a specific skill file with a full URL: ```bash allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index e205343..4a40ecb 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -195,30 +195,58 @@ After disabling, the skill is added to `disabledSkills` in workspace.yaml and sy ### skill add -Add a skill from a plugin, or re-enable a previously disabled skill. The `` argument can be a skill name or a GitHub URL pointing to a skill. +Add skills from a GitHub repo, re-enable a previously disabled skill, or add a skill from a specific plugin. The positional argument is interpreted by context: | Flag | Description | |------|-------------| | `-f, --from ` | Plugin source (GitHub URL, `owner/repo`, or `plugin@marketplace`) to install if the skill is not already available | +| `--skill ` | Comma-separated skill names to install when the positional is a plugin source | +| `--all` | Install every skill from the source | +| `--list` | List available skills at the source without installing | | `-p, --plugin ` | Plugin name (required if skill exists in multiple plugins) | | `-s, --scope ` | Scope: `project` (default) or `user` | -#### Adding a skill from a new plugin +#### Skill-first: install all skills from a repo -Use `--from` to specify the plugin source when the skill isn't already installed: +Pass `owner/repo` directly with no flags — all skills in that repo are installed automatically. This mirrors how `npx skills add` treats a package as a transport for its skills, not a named entity: + +```bash +# Install all skills from a repo (standalone skill or multi-skill bundle) +allagents skill add ReScienceLab/opc-skills +allagents skill add https://github.com/owner/repo +allagents skill add gh:owner/repo +``` + +This works for: +- **Standalone skill repos** — a single `SKILL.md` at the root (no `skills/` subdirectory) +- **Multi-skill bundles** — repos with a `skills/` directory containing multiple skill files + +Use `--list` first to preview what's available before installing: + +```bash +allagents skill add ReScienceLab/opc-skills --list +``` + +#### Installing a specific named skill + +To install only one skill from a multi-skill repo: ```bash -# Add a specific skill from a GitHub repo allagents skill add reddit --from ReScienceLab/opc-skills +allagents skill add ReScienceLab/opc-skills --skill reddit +``` -# Or pass a GitHub URL directly — the skill name is extracted from the URL path -allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill +To install multiple by name: + +```bash +allagents skill add ReScienceLab/opc-skills --skill reddit,terraform ``` -**Source formats for `--from`:** -- GitHub shorthand: `owner/repo` -- GitHub URL: `https://github.com/owner/repo` -- Marketplace: `plugin@marketplace` +To install a skill from a specific file URL: + +```bash +allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill +``` #### Re-enabling a disabled skill diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index a674b0d..b9e6f6c 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -747,12 +747,16 @@ async function installSkillViaMarketplace(opts: { /** * Decide whether the positional argument to `skill add` is a plugin source - * (npx-skills shape) or a skill name (legacy shape). + * (npx-skills shape), an auto-install source, or a skill name (legacy shape). * - * The positional is interpreted as a source only when it looks like a GitHub - * spec AND the user supplied an explicit selector (--skill, --list, --all). - * Without a selector we fall through to the legacy resolveSkillFromUrl path, - * which preserves the deep-URL form `skill add `. + * shape: 'source' — GitHub spec + explicit selector (--skill/--list/--all) + * shape: 'source-auto' — GitHub spec, no selector, no subpath: treat the repo + * as a collection of skills and install all of them. This + * mirrors `npx skills add owner/repo` where the repo is + * just a transport for its skills, not a named entity. + * shape: 'skill-name' — everything else, including deep-URL forms with a + * subpath (e.g. owner/repo/skills/foo) which are handled + * by the legacy resolveSkillFromUrl path. */ export function classifySkillAddPositional( positional: string | undefined, @@ -762,7 +766,8 @@ export function classifySkillAddPositional( ): | { shape: 'none' } | { shape: 'skill-name' } - | { shape: 'source'; skills: string[] } { + | { shape: 'source'; skills: string[] } + | { shape: 'source-auto' } { if (!positional) return { shape: 'none' }; const skills = skillFlag ? skillFlag @@ -771,8 +776,11 @@ export function classifySkillAddPositional( .filter(Boolean) : []; const hasSelector = skills.length > 0 || list || all; - if (isGitHubUrl(positional) && hasSelector) { - return { shape: 'source', skills }; + if (isGitHubUrl(positional)) { + if (hasSelector) return { shape: 'source', skills }; + // No subpath → the repo is a standalone skill or skill bundle; auto-install all. + // Subpath → legacy deep-URL form handled by resolveSkillFromUrl. + if (!parseGitHubUrl(positional)?.subpath) return { shape: 'source-auto' }; } return { shape: 'skill-name' }; } @@ -1093,6 +1101,118 @@ async function discoverSkillsFromSource( return { success: true, skills, isMarketplace: false }; } +/** + * Skill-first interactive install for `skill add owner/repo` (source-auto). + * + * When connected to a TTY, discovers skills from the source and shows an + * interactive multiselect with all skills pre-selected — the user can deselect + * any they don't want before confirming. Falls back to install-all in non-TTY + * or JSON mode, and for marketplace sources (which have a more complex picker + * model that isn't worth duplicating here). + */ +async function selectAndInstallSkillsFromSource(opts: { + from: string; + isUser: boolean; + workspacePath: string; +}): Promise< + | { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult } + | { success: false; error: string } + | { success: 'cancelled' } +> { + const { from, isUser, workspacePath } = opts; + const isTTY = process.stdout.isTTY && process.stdin.isTTY; + + // Non-interactive path: install everything silently + if (!isTTY || isJsonMode()) { + return installAllSkillsFromSource(opts); + } + + // Discover available skills (fetches the repo) + const discovered = await discoverSkillsFromSource(from); + if (!discovered.success) return { success: false, error: discovered.error }; + if (discovered.skills.length === 0) { + return { success: false, error: `No skills found in '${from}'.` }; + } + + // Marketplace repos or single-skill repos: skip the picker + if (discovered.isMarketplace || discovered.skills.length === 1) { + return installAllSkillsFromSource(opts); + } + + // Multiple skills on a direct repo: show a multiselect with all pre-selected + const p = await import('@clack/prompts'); + const allNames = discovered.skills.map((s) => s.name); + const options = discovered.skills.map((s) => ({ + label: s.name, + value: s.name, + ...(s.description ? { hint: s.description } : {}), + })); + + const selected = await p.autocompleteMultiselect({ + message: `Select skills to install from ${chalk.bold(from)}`, + options, + initialValues: allNames, + placeholder: 'Type to filter · Space to toggle · Enter to confirm', + required: false, + }); + + if (p.isCancel(selected) || (selected as string[]).length === 0) { + return { success: 'cancelled' }; + } + + const selectedNames = selected as string[]; + + // All skills selected: use the efficient bulk path + if (selectedNames.length === allNames.length) { + return installAllSkillsFromSource(opts); + } + + // Subset selected: configure allowlist with just those names, then sync once + const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null; + const fetchResult = await fetchPlugin(from, { + ...(parsed?.branch && { branch: parsed.branch }), + }); + if (!fetchResult.success) { + return { + success: false, + error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}`, + }; + } + + const existingEnabled = await getEnabledSkillsForGitHubSource(from, workspacePath); + const desiredSkills = [...existingEnabled]; + for (const name of selectedNames) { + if (!desiredSkills.includes(name)) desiredSkills.push(name); + } + + const updateResult = isUser + ? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills) + : await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath); + + if (!updateResult.success) { + return { + success: false, + error: `Failed to configure skill allowlist: ${updateResult.error ?? 'Unknown error'}`, + }; + } + + const pluginName = extractPrimaryPluginName(updateResult.normalizedPlugin ?? from); + console.log( + `✓ Enabled ${selectedNames.length} skill(s) from ${pluginName}: ${selectedNames.join(', ')}`, + ); + + const syncResult = isUser + ? await syncUserWorkspace() + : await syncWorkspace(workspacePath); + if (!syncResult.success) return { success: false, error: 'Sync failed' }; + + return { + success: true, + installed: [{ pluginName, skills: desiredSkills }], + syncResult, + }; +} + /** * Install all skills from a --from source. Mirrors installSkillFromSource but * enables every discovered skill rather than a single named one. @@ -1459,10 +1579,7 @@ const addCmd = command({ all, }) => { try { - // npx-skills shape: positional is the source, --skill/--list/--all picks - // what to install. Triggered only when the positional looks like a GitHub - // source AND a selector is provided, so the legacy deep-URL form - // (positional with implicit subpath selector) keeps working. + // Classify the positional argument so we know which code path to take. const classified = classifySkillAddPositional( skillArg, skillFlag, @@ -1470,7 +1587,10 @@ const addCmd = command({ all, ); let skillsFromFlag: string[] = []; - if (classified.shape === 'source') { + // source-auto: bare owner/repo (no subpath, no selector) — install all skills + // from the repo as if --all were passed. Mirrors `npx skills add owner/repo`. + let autoAll = false; + if (classified.shape === 'source' || classified.shape === 'source-auto') { if (fromArg) { const error = 'Cannot use --from when the positional argument is already a plugin source.'; @@ -1483,7 +1603,11 @@ const addCmd = command({ } fromArg = skillArg; skillArg = undefined; - skillsFromFlag = classified.skills; + if (classified.shape === 'source') { + skillsFromFlag = classified.skills; + } else { + autoAll = true; + } } else if (skillFlag) { const error = '--skill requires the positional argument to be a plugin source (e.g., `skill add owner/repo --skill foo`). To install a known skill, pass the skill name as the positional.'; @@ -1612,8 +1736,8 @@ const addCmd = command({ return; } - // --all: bulk install - if (all) { + // --all or auto-install (bare owner/repo with no selector): bulk install + if (all || autoAll) { if (!fromArg) { const error = '--all requires --from to specify a plugin source.'; if (isJsonMode()) { @@ -1637,11 +1761,22 @@ const addCmd = command({ const isUserAll = scope === 'user'; const workspacePathAll = isUserAll ? getHomeDir() : process.cwd(); - const installResult = await installAllSkillsFromSource({ - from: fromArg, - isUser: isUserAll, - workspacePath: workspacePathAll, - }); + // source-auto: interactive picker on TTY; --all: direct bulk install + const installResult = autoAll + ? await selectAndInstallSkillsFromSource({ + from: fromArg, + isUser: isUserAll, + workspacePath: workspacePathAll, + }) + : await installAllSkillsFromSource({ + from: fromArg, + isUser: isUserAll, + workspacePath: workspacePathAll, + }); + + if (installResult.success === 'cancelled') { + return; + } if (!installResult.success) { if (isJsonMode()) { diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index b19378d..33d94c2 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -129,6 +129,7 @@ export const skillsAddMeta: AgentCommandMeta = { whenToUse: 'To add a skill from a GitHub repo or marketplace plugin, or to re-enable a skill that was previously disabled', examples: [ + 'allagents skill add ReScienceLab/opc-skills', 'allagents skill add reddit --from ReScienceLab/opc-skills', 'allagents skill add NousResearch/hermes-agent --skill llm-wiki', 'allagents skill add NousResearch/hermes-agent --skill llm-wiki,dogfood', @@ -147,7 +148,10 @@ export const skillsAddMeta: AgentCommandMeta = { type: 'string', required: false, description: - 'Either a skill name (paired with --from) or a plugin source (owner/repo, gh:..., or a GitHub URL — paired with --skill, --list, or --all).', + 'A skill name (re-enable an installed skill, or pair with --from to install from a source); ' + + 'a plugin source without subpath (owner/repo, gh:owner/repo, or https://github.com/owner/repo — ' + + 'installs all skills from that repo without needing any flags); ' + + 'or a deep GitHub URL with a subpath pointing to a specific skill file.', }, ], options: [ diff --git a/tests/unit/cli/skill-add-classify-positional.test.ts b/tests/unit/cli/skill-add-classify-positional.test.ts index a28c86f..89b86ef 100644 --- a/tests/unit/cli/skill-add-classify-positional.test.ts +++ b/tests/unit/cli/skill-add-classify-positional.test.ts @@ -35,8 +35,33 @@ describe('classifySkillAddPositional', () => { }); }); - it('falls back to skill-name for source-shaped positional with no selector (legacy deep-URL form)', () => { - // `resolveSkillFromUrl` handles URL/owner-repo without selectors as legacy + // skill-first: bare owner/repo (no selector, no subpath) → auto-install all skills + it('auto-installs all skills for bare owner/repo with no selector', () => { + expect(classifySkillAddPositional('ReScienceLab/opc-skills', undefined, false, false)).toEqual({ + shape: 'source-auto', + }); + }); + + it('auto-installs all skills for bare https://github.com/owner/repo with no selector', () => { + expect( + classifySkillAddPositional('https://github.com/owner/repo', undefined, false, false), + ).toEqual({ shape: 'source-auto' }); + }); + + it('auto-installs all skills for gh: shorthand with no selector', () => { + expect(classifySkillAddPositional('gh:owner/repo', undefined, false, false)).toEqual({ + shape: 'source-auto', + }); + }); + + it('falls back to skill-name for owner/repo with subpath (legacy deep-URL form)', () => { + // Subpath → handled by resolveSkillFromUrl as a specific skill install + expect( + classifySkillAddPositional('owner/repo/skills/my-skill', undefined, false, false), + ).toEqual({ shape: 'skill-name' }); + }); + + it('falls back to skill-name for full GitHub URL with subpath (legacy deep-URL form)', () => { expect( classifySkillAddPositional( 'https://github.com/owner/repo/blob/main/skills/foo',