Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 40c34cd

Browse files
authored
feat(skill): treat bare owner/repo as skill-first install (#419)
* feat(skill): treat bare owner/repo as skill-first install (source-auto) Previously, `skill add owner/repo` with no flags was misclassified as a skill-name lookup and failed. This mirrors how `npx skills add owner/repo` works: the repo is just a transport for its skills, not a named entity. New behaviour: when the positional is a GitHub source (owner/repo, gh:owner/repo, or https://github.com/owner/repo) with no subpath and no selector flag (--skill/--list/--all), classifySkillAddPositional now returns shape 'source-auto', and the handler installs all skills from that source — equivalent to --all but without the flag. Subpath forms (owner/repo/skills/foo, deep GitHub URLs) remain as the legacy 'skill-name' shape handled by resolveSkillFromUrl, so existing behaviour is preserved. - Adds `source-auto` shape to classifySkillAddPositional return type - Handler routes source-auto through installAllSkillsFromSource - Updates skillsAddMeta examples and positional description - Updates cli.mdx and quick-start.mdx with the new bare-source pattern - Adds 4 new unit tests; total 11 pass * feat(skill): show interactive skill picker for bare owner/repo on TTY When `skill add owner/repo` is run interactively (TTY), show a multiselect picker with all discovered skills pre-selected, so the user can deselect any they don't want before confirming — mirroring the npx-skills UX. Falls back to silent install-all when: - stdout/stdin is not a TTY (CI, pipes) - --json mode - The source is a marketplace (complex multi-plugin picker not worth duplicating here) - The source has only one skill (no choice to make) All-selected shortcut: if the user confirms with all skills still selected, routes through the existing installAllSkillsFromSource for efficiency. For a subset, builds the allowlist with the chosen names and syncs once.
1 parent 3abda9d commit 40c34cd

5 files changed

Lines changed: 234 additions & 36 deletions

File tree

docs/src/content/docs/docs/getting-started/quick-start.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,19 @@ allagents update
4040

4141
## Add Individual Skills
4242

43-
Install a specific skill from a GitHub repo without adding the full plugin:
43+
Pass `owner/repo` directly to install all skills from that repo — no flags required:
44+
45+
```bash
46+
allagents skill add ReScienceLab/opc-skills
47+
```
48+
49+
To install only a specific skill from a multi-skill repo:
4450

4551
```bash
4652
allagents skill add reddit --from ReScienceLab/opc-skills
4753
```
4854

49-
Or pass a GitHub URL directly:
55+
Or point at a specific skill file with a full URL:
5056

5157
```bash
5258
allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill

docs/src/content/docs/docs/reference/cli.mdx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,30 +195,58 @@ After disabling, the skill is added to `disabledSkills` in workspace.yaml and sy
195195

196196
### skill add
197197

198-
Add a skill from a plugin, or re-enable a previously disabled skill. The `<skill>` argument can be a skill name or a GitHub URL pointing to a skill.
198+
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:
199199

200200
| Flag | Description |
201201
|------|-------------|
202202
| `-f, --from <source>` | Plugin source (GitHub URL, `owner/repo`, or `plugin@marketplace`) to install if the skill is not already available |
203+
| `--skill <names>` | Comma-separated skill names to install when the positional is a plugin source |
204+
| `--all` | Install every skill from the source |
205+
| `--list` | List available skills at the source without installing |
203206
| `-p, --plugin <plugin>` | Plugin name (required if skill exists in multiple plugins) |
204207
| `-s, --scope <scope>` | Scope: `project` (default) or `user` |
205208

206-
#### Adding a skill from a new plugin
209+
#### Skill-first: install all skills from a repo
207210

208-
Use `--from` to specify the plugin source when the skill isn't already installed:
211+
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:
212+
213+
```bash
214+
# Install all skills from a repo (standalone skill or multi-skill bundle)
215+
allagents skill add ReScienceLab/opc-skills
216+
allagents skill add https://github.com/owner/repo
217+
allagents skill add gh:owner/repo
218+
```
219+
220+
This works for:
221+
- **Standalone skill repos** — a single `SKILL.md` at the root (no `skills/` subdirectory)
222+
- **Multi-skill bundles** — repos with a `skills/` directory containing multiple skill files
223+
224+
Use `--list` first to preview what's available before installing:
225+
226+
```bash
227+
allagents skill add ReScienceLab/opc-skills --list
228+
```
229+
230+
#### Installing a specific named skill
231+
232+
To install only one skill from a multi-skill repo:
209233

210234
```bash
211-
# Add a specific skill from a GitHub repo
212235
allagents skill add reddit --from ReScienceLab/opc-skills
236+
allagents skill add ReScienceLab/opc-skills --skill reddit
237+
```
213238

214-
# Or pass a GitHub URL directly — the skill name is extracted from the URL path
215-
allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill
239+
To install multiple by name:
240+
241+
```bash
242+
allagents skill add ReScienceLab/opc-skills --skill reddit,terraform
216243
```
217244

218-
**Source formats for `--from`:**
219-
- GitHub shorthand: `owner/repo`
220-
- GitHub URL: `https://github.com/owner/repo`
221-
- Marketplace: `plugin@marketplace`
245+
To install a skill from a specific file URL:
246+
247+
```bash
248+
allagents skill add https://github.com/owner/repo/tree/main/skills/my-skill
249+
```
222250

223251
#### Re-enabling a disabled skill
224252

src/cli/commands/plugin-skills.ts

Lines changed: 156 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -747,12 +747,16 @@ async function installSkillViaMarketplace(opts: {
747747

748748
/**
749749
* Decide whether the positional argument to `skill add` is a plugin source
750-
* (npx-skills shape) or a skill name (legacy shape).
750+
* (npx-skills shape), an auto-install source, or a skill name (legacy shape).
751751
*
752-
* The positional is interpreted as a source only when it looks like a GitHub
753-
* spec AND the user supplied an explicit selector (--skill, --list, --all).
754-
* Without a selector we fall through to the legacy resolveSkillFromUrl path,
755-
* which preserves the deep-URL form `skill add <url-with-subpath>`.
752+
* shape: 'source' — GitHub spec + explicit selector (--skill/--list/--all)
753+
* shape: 'source-auto' — GitHub spec, no selector, no subpath: treat the repo
754+
* as a collection of skills and install all of them. This
755+
* mirrors `npx skills add owner/repo` where the repo is
756+
* just a transport for its skills, not a named entity.
757+
* shape: 'skill-name' — everything else, including deep-URL forms with a
758+
* subpath (e.g. owner/repo/skills/foo) which are handled
759+
* by the legacy resolveSkillFromUrl path.
756760
*/
757761
export function classifySkillAddPositional(
758762
positional: string | undefined,
@@ -762,7 +766,8 @@ export function classifySkillAddPositional(
762766
):
763767
| { shape: 'none' }
764768
| { shape: 'skill-name' }
765-
| { shape: 'source'; skills: string[] } {
769+
| { shape: 'source'; skills: string[] }
770+
| { shape: 'source-auto' } {
766771
if (!positional) return { shape: 'none' };
767772
const skills = skillFlag
768773
? skillFlag
@@ -771,8 +776,11 @@ export function classifySkillAddPositional(
771776
.filter(Boolean)
772777
: [];
773778
const hasSelector = skills.length > 0 || list || all;
774-
if (isGitHubUrl(positional) && hasSelector) {
775-
return { shape: 'source', skills };
779+
if (isGitHubUrl(positional)) {
780+
if (hasSelector) return { shape: 'source', skills };
781+
// No subpath → the repo is a standalone skill or skill bundle; auto-install all.
782+
// Subpath → legacy deep-URL form handled by resolveSkillFromUrl.
783+
if (!parseGitHubUrl(positional)?.subpath) return { shape: 'source-auto' };
776784
}
777785
return { shape: 'skill-name' };
778786
}
@@ -1093,6 +1101,118 @@ async function discoverSkillsFromSource(
10931101
return { success: true, skills, isMarketplace: false };
10941102
}
10951103

1104+
/**
1105+
* Skill-first interactive install for `skill add owner/repo` (source-auto).
1106+
*
1107+
* When connected to a TTY, discovers skills from the source and shows an
1108+
* interactive multiselect with all skills pre-selected — the user can deselect
1109+
* any they don't want before confirming. Falls back to install-all in non-TTY
1110+
* or JSON mode, and for marketplace sources (which have a more complex picker
1111+
* model that isn't worth duplicating here).
1112+
*/
1113+
async function selectAndInstallSkillsFromSource(opts: {
1114+
from: string;
1115+
isUser: boolean;
1116+
workspacePath: string;
1117+
}): Promise<
1118+
| { success: true; installed: Array<{ pluginName: string; skills: string[] }>; syncResult: SyncResult }
1119+
| { success: false; error: string }
1120+
| { success: 'cancelled' }
1121+
> {
1122+
const { from, isUser, workspacePath } = opts;
1123+
const isTTY = process.stdout.isTTY && process.stdin.isTTY;
1124+
1125+
// Non-interactive path: install everything silently
1126+
if (!isTTY || isJsonMode()) {
1127+
return installAllSkillsFromSource(opts);
1128+
}
1129+
1130+
// Discover available skills (fetches the repo)
1131+
const discovered = await discoverSkillsFromSource(from);
1132+
if (!discovered.success) return { success: false, error: discovered.error };
1133+
if (discovered.skills.length === 0) {
1134+
return { success: false, error: `No skills found in '${from}'.` };
1135+
}
1136+
1137+
// Marketplace repos or single-skill repos: skip the picker
1138+
if (discovered.isMarketplace || discovered.skills.length === 1) {
1139+
return installAllSkillsFromSource(opts);
1140+
}
1141+
1142+
// Multiple skills on a direct repo: show a multiselect with all pre-selected
1143+
const p = await import('@clack/prompts');
1144+
const allNames = discovered.skills.map((s) => s.name);
1145+
const options = discovered.skills.map((s) => ({
1146+
label: s.name,
1147+
value: s.name,
1148+
...(s.description ? { hint: s.description } : {}),
1149+
}));
1150+
1151+
const selected = await p.autocompleteMultiselect({
1152+
message: `Select skills to install from ${chalk.bold(from)}`,
1153+
options,
1154+
initialValues: allNames,
1155+
placeholder: 'Type to filter · Space to toggle · Enter to confirm',
1156+
required: false,
1157+
});
1158+
1159+
if (p.isCancel(selected) || (selected as string[]).length === 0) {
1160+
return { success: 'cancelled' };
1161+
}
1162+
1163+
const selectedNames = selected as string[];
1164+
1165+
// All skills selected: use the efficient bulk path
1166+
if (selectedNames.length === allNames.length) {
1167+
return installAllSkillsFromSource(opts);
1168+
}
1169+
1170+
// Subset selected: configure allowlist with just those names, then sync once
1171+
const parsed = isGitHubUrl(from) ? parseGitHubUrl(from) : null;
1172+
const fetchResult = await fetchPlugin(from, {
1173+
...(parsed?.branch && { branch: parsed.branch }),
1174+
});
1175+
if (!fetchResult.success) {
1176+
return {
1177+
success: false,
1178+
error: `Failed to fetch '${from}': ${fetchResult.error ?? 'Unknown error'}`,
1179+
};
1180+
}
1181+
1182+
const existingEnabled = await getEnabledSkillsForGitHubSource(from, workspacePath);
1183+
const desiredSkills = [...existingEnabled];
1184+
for (const name of selectedNames) {
1185+
if (!desiredSkills.includes(name)) desiredSkills.push(name);
1186+
}
1187+
1188+
const updateResult = isUser
1189+
? await upsertUserGitHubPluginSourceAllowlist(from, desiredSkills)
1190+
: await upsertGitHubPluginSourceAllowlist(from, desiredSkills, workspacePath);
1191+
1192+
if (!updateResult.success) {
1193+
return {
1194+
success: false,
1195+
error: `Failed to configure skill allowlist: ${updateResult.error ?? 'Unknown error'}`,
1196+
};
1197+
}
1198+
1199+
const pluginName = extractPrimaryPluginName(updateResult.normalizedPlugin ?? from);
1200+
console.log(
1201+
`✓ Enabled ${selectedNames.length} skill(s) from ${pluginName}: ${selectedNames.join(', ')}`,
1202+
);
1203+
1204+
const syncResult = isUser
1205+
? await syncUserWorkspace()
1206+
: await syncWorkspace(workspacePath);
1207+
if (!syncResult.success) return { success: false, error: 'Sync failed' };
1208+
1209+
return {
1210+
success: true,
1211+
installed: [{ pluginName, skills: desiredSkills }],
1212+
syncResult,
1213+
};
1214+
}
1215+
10961216
/**
10971217
* Install all skills from a --from source. Mirrors installSkillFromSource but
10981218
* enables every discovered skill rather than a single named one.
@@ -1459,18 +1579,18 @@ const addCmd = command({
14591579
all,
14601580
}) => {
14611581
try {
1462-
// npx-skills shape: positional is the source, --skill/--list/--all picks
1463-
// what to install. Triggered only when the positional looks like a GitHub
1464-
// source AND a selector is provided, so the legacy deep-URL form
1465-
// (positional with implicit subpath selector) keeps working.
1582+
// Classify the positional argument so we know which code path to take.
14661583
const classified = classifySkillAddPositional(
14671584
skillArg,
14681585
skillFlag,
14691586
list,
14701587
all,
14711588
);
14721589
let skillsFromFlag: string[] = [];
1473-
if (classified.shape === 'source') {
1590+
// source-auto: bare owner/repo (no subpath, no selector) — install all skills
1591+
// from the repo as if --all were passed. Mirrors `npx skills add owner/repo`.
1592+
let autoAll = false;
1593+
if (classified.shape === 'source' || classified.shape === 'source-auto') {
14741594
if (fromArg) {
14751595
const error =
14761596
'Cannot use --from when the positional argument is already a plugin source.';
@@ -1483,7 +1603,11 @@ const addCmd = command({
14831603
}
14841604
fromArg = skillArg;
14851605
skillArg = undefined;
1486-
skillsFromFlag = classified.skills;
1606+
if (classified.shape === 'source') {
1607+
skillsFromFlag = classified.skills;
1608+
} else {
1609+
autoAll = true;
1610+
}
14871611
} else if (skillFlag) {
14881612
const error =
14891613
'--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({
16121736
return;
16131737
}
16141738

1615-
// --all: bulk install
1616-
if (all) {
1739+
// --all or auto-install (bare owner/repo with no selector): bulk install
1740+
if (all || autoAll) {
16171741
if (!fromArg) {
16181742
const error = '--all requires --from to specify a plugin source.';
16191743
if (isJsonMode()) {
@@ -1637,11 +1761,22 @@ const addCmd = command({
16371761
const isUserAll = scope === 'user';
16381762
const workspacePathAll = isUserAll ? getHomeDir() : process.cwd();
16391763

1640-
const installResult = await installAllSkillsFromSource({
1641-
from: fromArg,
1642-
isUser: isUserAll,
1643-
workspacePath: workspacePathAll,
1644-
});
1764+
// source-auto: interactive picker on TTY; --all: direct bulk install
1765+
const installResult = autoAll
1766+
? await selectAndInstallSkillsFromSource({
1767+
from: fromArg,
1768+
isUser: isUserAll,
1769+
workspacePath: workspacePathAll,
1770+
})
1771+
: await installAllSkillsFromSource({
1772+
from: fromArg,
1773+
isUser: isUserAll,
1774+
workspacePath: workspacePathAll,
1775+
});
1776+
1777+
if (installResult.success === 'cancelled') {
1778+
return;
1779+
}
16451780

16461781
if (!installResult.success) {
16471782
if (isJsonMode()) {

src/cli/metadata/plugin-skills.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const skillsAddMeta: AgentCommandMeta = {
129129
whenToUse:
130130
'To add a skill from a GitHub repo or marketplace plugin, or to re-enable a skill that was previously disabled',
131131
examples: [
132+
'allagents skill add ReScienceLab/opc-skills',
132133
'allagents skill add reddit --from ReScienceLab/opc-skills',
133134
'allagents skill add NousResearch/hermes-agent --skill llm-wiki',
134135
'allagents skill add NousResearch/hermes-agent --skill llm-wiki,dogfood',
@@ -147,7 +148,10 @@ export const skillsAddMeta: AgentCommandMeta = {
147148
type: 'string',
148149
required: false,
149150
description:
150-
'Either a skill name (paired with --from) or a plugin source (owner/repo, gh:..., or a GitHub URL — paired with --skill, --list, or --all).',
151+
'A skill name (re-enable an installed skill, or pair with --from to install from a source); ' +
152+
'a plugin source without subpath (owner/repo, gh:owner/repo, or https://github.com/owner/repo — ' +
153+
'installs all skills from that repo without needing any flags); ' +
154+
'or a deep GitHub URL with a subpath pointing to a specific skill file.',
151155
},
152156
],
153157
options: [

tests/unit/cli/skill-add-classify-positional.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,33 @@ describe('classifySkillAddPositional', () => {
3535
});
3636
});
3737

38-
it('falls back to skill-name for source-shaped positional with no selector (legacy deep-URL form)', () => {
39-
// `resolveSkillFromUrl` handles URL/owner-repo without selectors as legacy
38+
// skill-first: bare owner/repo (no selector, no subpath) → auto-install all skills
39+
it('auto-installs all skills for bare owner/repo with no selector', () => {
40+
expect(classifySkillAddPositional('ReScienceLab/opc-skills', undefined, false, false)).toEqual({
41+
shape: 'source-auto',
42+
});
43+
});
44+
45+
it('auto-installs all skills for bare https://github.com/owner/repo with no selector', () => {
46+
expect(
47+
classifySkillAddPositional('https://github.com/owner/repo', undefined, false, false),
48+
).toEqual({ shape: 'source-auto' });
49+
});
50+
51+
it('auto-installs all skills for gh: shorthand with no selector', () => {
52+
expect(classifySkillAddPositional('gh:owner/repo', undefined, false, false)).toEqual({
53+
shape: 'source-auto',
54+
});
55+
});
56+
57+
it('falls back to skill-name for owner/repo with subpath (legacy deep-URL form)', () => {
58+
// Subpath → handled by resolveSkillFromUrl as a specific skill install
59+
expect(
60+
classifySkillAddPositional('owner/repo/skills/my-skill', undefined, false, false),
61+
).toEqual({ shape: 'skill-name' });
62+
});
63+
64+
it('falls back to skill-name for full GitHub URL with subpath (legacy deep-URL form)', () => {
4065
expect(
4166
classifySkillAddPositional(
4267
'https://github.com/owner/repo/blob/main/skills/foo',

0 commit comments

Comments
 (0)