Skip to content

Commit 4deb733

Browse files
committed
feat: support non-interactive link command via --agents, --skills, --mode flags
Allow the link command to run fully non-interactively by passing CLI flags that skip the interactive @clack/prompts, enabling scripted and AI agent usage. Global mode skips the agent multiselect when --agents is provided. Project mode skips all three prompts (skills, mode, agents) when the corresponding flags are provided. Includes validation for unknown skills and invalid mode values.
1 parent d51191a commit 4deb733

6 files changed

Lines changed: 194 additions & 96 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const SKILL_LOCK_PATH = join(homedir(), '.agents', '.skill-lock.json');
3737
```
3838
push: ~/.agents/ → git add → git commit → git push origin <branch>
3939
pull: git pull --rebase → auto-run link
40-
link: read .skill-lock.json → detect local agents multiselect → create relative symlinks
41-
link --project: read .skill-lock.json → select skills → choose copy/symlink → select agents → group by projectPath → copy/link to CWD
40+
link: read .skill-lock.json → [--agents or multiselect] → create relative symlinks
41+
link --project: read .skill-lock.json → [--skills or select skills][--mode or select copy/symlink][--agents or select agents] → group by projectPath → copy/link to CWD
4242
```
4343

4444
## Critical Constraints

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,19 @@ skills-manager link --agents cursor opencode claude-code
9999

100100
| Option | Description |
101101
|--------|-------------|
102-
| `-a, --agents <ids...>` | Agent IDs to link (default: from lock file) |
102+
| `-a, --agents <ids...>` | Agent IDs to link (skips agent prompt when provided) |
103103
| `-p, --project` | Link skills to project directory (CWD) instead of global paths |
104-
104+
| `-s, --skills <skills...>` | Skill names to link (project mode only, skips skill prompt) |
105+
| `--mode <mode>` | `copy` or `symlink` (project mode only, default: `copy`, skips prompt) |
105106
An interactive multiselect prompt lets you pick which agents to link. Only agents with local directories are pre-selected. Your selection is remembered for next time.
106107

108+
When `--agents` is provided, the agent selection prompt is skipped entirely — useful for scripting and AI agent automation:
109+
110+
```bash
111+
# Non-interactive: link all skills to specific agents
112+
skills-manager link --agents cursor opencode
113+
```
114+
107115
**Symlink model:**
108116

109117
```
@@ -119,6 +127,12 @@ When using `--project` (or `-p`), you'll go through three interactive prompts:
119127
2. **Copy or symlink** — copy files (default, recommended) or create absolute symlinks
120128
3. **Select agents** — choose which agents to set up project-level skills for
121129

130+
All three prompts can be skipped by providing `--skills`, `--mode`, and `--agents` on the command line:
131+
132+
```bash
133+
# Fully non-interactive project link
134+
skills-manager link --project --agents cursor claude-code --skills my-skill --mode copy
135+
```
122136
Example project structure after `link --project`:
123137
```
124138
./project/

skills/skills-manager/SKILL.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,23 @@ Read `.skill-lock.json`, create **relative** symlinks from each agent's global s
6161

6262
```bash
6363
npx @tc9011/skills-manager link # interactive multiselect
64-
npx @tc9011/skills-manager link --agents cursor opencode # specific agents
64+
npx @tc9011/skills-manager link --agents cursor opencode # non-interactive (skips prompt)
6565
```
6666

67-
Selection is remembered across runs.
67+
When `--agents` is provided, the prompt is skipped entirely. Selection is remembered across runs.
6868

6969
### link --project
7070

71-
Link or copy skills to current working directory. Three-step interactive flow:
71+
Link or copy skills to current working directory. Three-step interactive flow (all skippable via flags):
7272

73-
1. **Select skills** — choose which skills (none pre-selected)
74-
2. **Copy or symlink** — copy (recommended) or absolute symlinks
75-
3. **Select agents** — choose agents for project-level setup
73+
1. **Select skills** (`--skills`) — choose which skills (none pre-selected)
74+
2. **Copy or symlink** (`--mode`) — copy (default, recommended) or absolute symlinks
75+
3. **Select agents** (`--agents`) — choose agents for project-level setup
7676

7777
```bash
7878
cd /path/to/project
79-
npx @tc9011/skills-manager link --project
79+
npx @tc9011/skills-manager link --project # interactive
80+
npx @tc9011/skills-manager link --project --agents cursor --skills my-skill --mode copy # non-interactive
8081
```
8182

8283
Agents sharing the same projectPath are deduplicated.

src/commands/link.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,49 @@ describe('linkCommand', () => {
323323
.mockReturnValueOnce(true); // for agent multiselect check
324324

325325
const { linkCommand } = await import('./link.js');
326-
await linkCommand({ agents: ['cursor'], project: true });
326+
// Do NOT pass agents so the interactive prompt is shown
327+
await linkCommand({ project: true });
327328

328329
expect(prompts.cancel).toHaveBeenCalledWith('No agents selected.');
329330
expect(copySkills).not.toHaveBeenCalled();
330331
expect(createProjectSymlinks).not.toHaveBeenCalled();
331332
});
332333

334+
it('skips all prompts in project mode when --agents, --skills, --mode provided', async () => {
335+
vi.mocked(listCanonicalSkills).mockResolvedValue(['my-skill', 'other-skill']);
336+
vi.mocked(copySkills).mockResolvedValue([
337+
{ skill: 'my-skill', status: 'copied' },
338+
]);
339+
340+
const { linkCommand } = await import('./link.js');
341+
await linkCommand({ agents: ['cursor'], project: true, skills: ['my-skill'], mode: 'copy' });
342+
343+
// No interactive prompts should be called
344+
expect(prompts.multiselect).not.toHaveBeenCalled();
345+
expect(prompts.select).not.toHaveBeenCalled();
346+
expect(copySkills).toHaveBeenCalledOnce();
347+
const callArgs = vi.mocked(copySkills).mock.calls[0];
348+
expect(callArgs[2]).toEqual(['my-skill']);
349+
});
350+
351+
it('throws CliError for unknown skills in --skills', async () => {
352+
vi.mocked(listCanonicalSkills).mockResolvedValue(['my-skill']);
353+
354+
const { linkCommand } = await import('./link.js');
355+
await expect(
356+
linkCommand({ agents: ['cursor'], project: true, skills: ['nonexistent'], mode: 'copy' }),
357+
).rejects.toThrow(CliError);
358+
});
359+
360+
it('throws CliError for invalid --mode value', async () => {
361+
vi.mocked(listCanonicalSkills).mockResolvedValue(['my-skill']);
362+
363+
const { linkCommand } = await import('./link.js');
364+
await expect(
365+
linkCommand({ agents: ['cursor'], project: true, skills: ['my-skill'], mode: 'invalid' }),
366+
).rejects.toThrow(CliError);
367+
});
368+
333369
it('does not show skill multiselect in global mode', async () => {
334370
vi.mocked(getLastSelectedAgents).mockResolvedValue(['cursor'] as AgentId[]);
335371
vi.mocked(prompts.multiselect).mockResolvedValue(['cursor']);
@@ -345,4 +381,18 @@ describe('linkCommand', () => {
345381
expect(prompts.multiselect).toHaveBeenCalledOnce();
346382
});
347383
});
384+
385+
it('skips agent prompt in global mode when --agents provided', async () => {
386+
vi.mocked(listCanonicalSkills).mockResolvedValue(['my-skill']);
387+
vi.mocked(createSkillSymlinks).mockResolvedValue([
388+
{ skill: 'my-skill', status: 'created' },
389+
]);
390+
391+
const { linkCommand } = await import('./link.js');
392+
await linkCommand({ agents: ['cursor'] });
393+
394+
// No multiselect prompt — agents provided via CLI
395+
expect(prompts.multiselect).not.toHaveBeenCalled();
396+
expect(createSkillSymlinks).toHaveBeenCalledOnce();
397+
});
348398
});

src/commands/link.ts

Lines changed: 115 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getLastSelectedAgents } from '../lockfile.js';
77
import { createSkillSymlinks, listCanonicalSkills, copySkills, createProjectSymlinks } from '../linker.js';
88
import * as p from '@clack/prompts';
99

10-
export async function linkCommand(options: { agents?: string[]; project?: boolean }): Promise<void> {
10+
export async function linkCommand(options: { agents?: string[]; project?: boolean; skills?: string[]; mode?: string }): Promise<void> {
1111
p.intro('skills-manager link');
1212

1313
// 1. Read lock file for lastSelectedAgents
@@ -42,68 +42,94 @@ export async function linkCommand(options: { agents?: string[]; project?: boolea
4242
if (options.project) {
4343
// Project mode prompt order: skills → copy/symlink → agents
4444

45-
// 3a. Select skills (no pre-selection in project mode)
46-
const skillChoices = skills.map(name => ({
47-
value: name,
48-
label: name,
49-
}));
50-
51-
const pickedSkills = await p.multiselect<string>({
52-
message: 'Select skills to link:',
53-
options: skillChoices,
54-
required: false,
55-
});
56-
57-
if (p.isCancel(pickedSkills) || !pickedSkills.length) {
58-
p.cancel('No skills selected.');
59-
return;
60-
}
45+
// 3a. Select skills — skip prompt if --skills provided
46+
let selectedSkills: string[];
47+
if (options.skills?.length) {
48+
const skillSet = new Set(skills);
49+
const invalid = options.skills.filter(s => !skillSet.has(s));
50+
if (invalid.length > 0) {
51+
p.cancel(`Unknown skill(s): ${invalid.join(', ')}. Available: ${skills.join(', ')}`);
52+
throw new CliError(`Unknown skill(s): ${invalid.join(', ')}`);
53+
}
54+
selectedSkills = options.skills;
55+
} else {
56+
const skillChoices = skills.map(name => ({
57+
value: name,
58+
label: name,
59+
}));
60+
61+
const pickedSkills = await p.multiselect<string>({
62+
message: 'Select skills to link:',
63+
options: skillChoices,
64+
required: false,
65+
});
66+
67+
if (p.isCancel(pickedSkills) || !pickedSkills.length) {
68+
p.cancel('No skills selected.');
69+
return;
70+
}
6171

62-
const selectedSkills = pickedSkills as string[];
63-
64-
// 3b. Select copy vs symlink
65-
const mode = await p.select({
66-
message: 'How should skills be added to the project?',
67-
options: [
68-
{ value: 'copy', label: 'Copy files', hint: 'recommended — independent copies' },
69-
{ value: 'symlink', label: 'Create symlinks', hint: 'links to ~/.agents/skills' },
70-
],
71-
initialValue: 'copy',
72-
});
73-
74-
if (p.isCancel(mode)) {
75-
p.cancel('Cancelled.');
76-
return;
72+
selectedSkills = pickedSkills as string[];
7773
}
7874

79-
// 3c. Select agents
80-
const agentChoices = agents.map(id => {
81-
const projectPath = agentRegistry[id].projectPath;
82-
const targetDir = join(process.cwd(), projectPath);
83-
const dirExists = existsSync(targetDir);
84-
return {
85-
value: id as string,
86-
label: `${agentRegistry[id].displayName} (${id})`,
87-
hint: dirExists ? projectPath : `${projectPath} — directory will be created`,
88-
};
89-
});
90-
91-
const selected = await p.multiselect<string>({
92-
message: 'Select agents to link skills to:',
93-
options: agentChoices,
94-
initialValues: computeInitialValues(agents, agentChoices),
95-
required: false,
96-
});
97-
98-
if (p.isCancel(selected) || !selected.length) {
99-
p.cancel('No agents selected.');
100-
return;
75+
// 3b. Select copy vs symlink — skip prompt if --mode provided
76+
let mode: string;
77+
if (options.mode) {
78+
if (options.mode !== 'copy' && options.mode !== 'symlink') {
79+
p.cancel(`Invalid mode '${options.mode}'. Must be 'copy' or 'symlink'.`);
80+
throw new CliError(`Invalid mode '${options.mode}'. Must be 'copy' or 'symlink'.`);
81+
}
82+
mode = options.mode;
83+
} else {
84+
const modeResult = await p.select({
85+
message: 'How should skills be added to the project?',
86+
options: [
87+
{ value: 'copy', label: 'Copy files', hint: 'recommended — independent copies' },
88+
{ value: 'symlink', label: 'Create symlinks', hint: 'links to ~/.agents/skills' },
89+
],
90+
initialValue: 'copy',
91+
});
92+
93+
if (p.isCancel(modeResult)) {
94+
p.cancel('Cancelled.');
95+
return;
96+
}
97+
mode = modeResult as string;
10198
}
10299

103-
const validSelected = new Set(Object.keys(agentRegistry));
104-
const selectedAgents = (selected as string[]).filter(
105-
(id): id is AgentId => validSelected.has(id)
106-
);
100+
// 3c. Select agents — skip prompt if --agents provided
101+
let selectedAgents: AgentId[];
102+
if (options.agents?.length) {
103+
selectedAgents = agents;
104+
} else {
105+
const agentChoices = agents.map(id => {
106+
const projectPath = agentRegistry[id].projectPath;
107+
const targetDir = join(process.cwd(), projectPath);
108+
const dirExists = existsSync(targetDir);
109+
return {
110+
value: id as string,
111+
label: `${agentRegistry[id].displayName} (${id})`,
112+
hint: dirExists ? projectPath : `${projectPath} — directory will be created`,
113+
};
114+
});
115+
116+
const selected = await p.multiselect<string>({
117+
message: 'Select agents to link skills to:',
118+
options: agentChoices,
119+
initialValues: computeInitialValues(agents, agentChoices),
120+
required: false,
121+
});
122+
123+
if (p.isCancel(selected) || !selected.length) {
124+
p.cancel('No agents selected.');
125+
return;
126+
}
127+
128+
const validSelected = new Set(Object.keys(agentRegistry));
129+
selectedAgents = (selected as string[]).filter(
130+
(id): id is AgentId => validSelected.has(id)
131+
);
132+
}
107133

108134
// 3d. Execute: group by projectPath, copy/link
109135
const groups = groupAgentsByProjectPath(selectedAgents);
@@ -151,33 +177,38 @@ export async function linkCommand(options: { agents?: string[]; project?: boolea
151177
} else {
152178
// Global mode: agents → link all skills
153179

154-
// 3a. Select agents
155-
const agentChoices = agents.map(id => {
156-
const globalPath = getAgentGlobalPath(id);
157-
const dirExists = existsSync(globalPath);
158-
return {
159-
value: id as string,
160-
label: `${agentRegistry[id].displayName} (${id})`,
161-
hint: dirExists ? globalPath : `${globalPath} — directory will be created`,
162-
};
163-
});
164-
165-
const selected = await p.multiselect<string>({
166-
message: 'Select agents to link skills to:',
167-
options: agentChoices,
168-
initialValues: computeInitialValues(agents, agentChoices),
169-
required: false,
170-
});
171-
172-
if (p.isCancel(selected) || !selected.length) {
173-
p.cancel('No agents selected.');
174-
return;
175-
}
180+
// 3a. Select agents — skip prompt if --agents provided
181+
let selectedAgents: AgentId[];
182+
if (options.agents?.length) {
183+
selectedAgents = agents;
184+
} else {
185+
const agentChoices = agents.map(id => {
186+
const globalPath = getAgentGlobalPath(id);
187+
const dirExists = existsSync(globalPath);
188+
return {
189+
value: id as string,
190+
label: `${agentRegistry[id].displayName} (${id})`,
191+
hint: dirExists ? globalPath : `${globalPath} — directory will be created`,
192+
};
193+
});
176194

177-
const validSelected = new Set(Object.keys(agentRegistry));
178-
const selectedAgents = (selected as string[]).filter(
179-
(id): id is AgentId => validSelected.has(id)
180-
);
195+
const selected = await p.multiselect<string>({
196+
message: 'Select agents to link skills to:',
197+
options: agentChoices,
198+
initialValues: computeInitialValues(agents, agentChoices),
199+
required: false,
200+
});
201+
202+
if (p.isCancel(selected) || !selected.length) {
203+
p.cancel('No agents selected.');
204+
return;
205+
}
206+
207+
const validSelected = new Set(Object.keys(agentRegistry));
208+
selectedAgents = (selected as string[]).filter(
209+
(id): id is AgentId => validSelected.has(id)
210+
);
211+
}
181212

182213
// 3b. Link all skills for each agent
183214
for (const agentId of selectedAgents) {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ program
3434
.description('Create symlinks from canonical skills to agent directories')
3535
.option('-a, --agents <agents...>', 'Agent IDs to link (default: from .skill-lock.json)')
3636
.option('-p, --project', 'Link skills to project directory (CWD)')
37+
.option('-s, --skills <skills...>', 'Skill names to link (project mode only, skips prompt)')
38+
.option('--mode <mode>', 'copy or symlink (project mode only, skips prompt)', 'copy')
3739
.action(linkCommand);
3840

3941

0 commit comments

Comments
 (0)