@orchestra-research/ai-research-skills 1.2.1 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ npx @orchestra-research/ai-research-skills
11
11
  - **83 skills** across 20 categories for AI research engineering
12
12
  - **Auto-detects** installed coding agents
13
13
  - **Interactive installer** with guided experience
14
- - **One canonical copy** with symlinks to all agents
14
+ - **Global or local install** — install globally with symlinks, or per-project with `--local` for version-controlled, project-specific skill sets
15
15
  - **Works with 8 agents**: Claude Code, OpenCode, OpenClaw, Cursor, Codex, Gemini CLI, Qwen Code, and shared `.agents/`
16
16
 
17
17
  ## Quick Start
@@ -34,7 +34,7 @@ This will:
34
34
  # Interactive mode (recommended)
35
35
  npx @orchestra-research/ai-research-skills
36
36
 
37
- # Install everything
37
+ # Install everything (global)
38
38
  npx @orchestra-research/ai-research-skills install --all
39
39
 
40
40
  # Install a specific category
@@ -47,6 +47,46 @@ npx @orchestra-research/ai-research-skills list
47
47
  npx @orchestra-research/ai-research-skills update
48
48
  ```
49
49
 
50
+ ### Local Installation (per-project)
51
+
52
+ Install skills directly into your project directory so different projects can have different skill sets:
53
+
54
+ ```bash
55
+ # Install all skills locally to the current project
56
+ npx @orchestra-research/ai-research-skills install --all --local
57
+
58
+ # Install a category locally
59
+ npx @orchestra-research/ai-research-skills install --category post-training --local
60
+
61
+ # List locally installed skills
62
+ npx @orchestra-research/ai-research-skills list --local
63
+
64
+ # Update local skills
65
+ npx @orchestra-research/ai-research-skills update --local
66
+
67
+ # Uninstall local skills
68
+ npx @orchestra-research/ai-research-skills uninstall --local
69
+ ```
70
+
71
+ Local installation copies skills (not symlinks) into agent directories within your project:
72
+
73
+ ```
74
+ my-project/
75
+ ├── .claude/skills/ # Claude Code picks these up
76
+ │ ├── grpo-rl-training/
77
+ │ └── vllm/
78
+ ├── .cursor/skills/ # Cursor picks these up
79
+ │ ├── grpo-rl-training/
80
+ │ └── vllm/
81
+ ├── .orchestra-skills.json # Tracks installed skills
82
+ └── ...
83
+ ```
84
+
85
+ Benefits:
86
+ - **Per-project skills**: Each project gets only the skills it needs
87
+ - **Version control**: Commit skills to your repo so the whole team has them
88
+ - **Reproducible**: Lock file (`.orchestra-skills.json`) tracks what's installed
89
+
50
90
  ## Categories
51
91
 
52
92
  | Category | Skills | Description |
@@ -61,25 +101,41 @@ npx @orchestra-research/ai-research-skills update
61
101
 
62
102
  ## How It Works
63
103
 
64
- 1. **Canonical Storage**: Skills are stored once at `~/.agents/skills/`
104
+ ### Global Install (default)
105
+
106
+ 1. **Canonical Storage**: Skills are stored once at `~/.orchestra/skills/`
65
107
  2. **Symlinks**: Each agent gets symlinks pointing to the canonical copy
66
108
  3. **Auto-activation**: Skills activate when you discuss relevant topics
67
109
 
68
110
  ```
69
- ~/.agents/skills/ # Single source of truth
111
+ ~/.orchestra/skills/ # Single source of truth
70
112
  ├── 06-post-training/
71
113
  │ ├── verl/
72
114
  │ └── grpo-rl-training/
73
115
  └── ...
74
116
 
75
117
  ~/.claude/skills/ # Symlinks for Claude Code
76
- ├── verl → ~/.agents/skills/.../verl
118
+ ├── verl → ~/.orchestra/skills/.../verl
77
119
  └── grpo-rl-training → ...
78
120
 
79
121
  ~/.cursor/skills/ # Symlinks for Cursor
80
122
  └── (same links)
81
123
  ```
82
124
 
125
+ ### Local Install (`--local`)
126
+
127
+ 1. **Direct Copy**: Skills are copied into agent directories within your project
128
+ 2. **Version Control**: Files can be committed to git for team sharing
129
+ 3. **Lock File**: `.orchestra-skills.json` tracks what's installed
130
+
131
+ ```
132
+ my-project/
133
+ ├── .claude/skills/verl/ # Copied for Claude Code
134
+ ├── .cursor/skills/verl/ # Copied for Cursor
135
+ ├── .codex/skills/verl/ # Copied for Codex
136
+ └── .orchestra-skills.json # Lock file
137
+ ```
138
+
83
139
  ## Supported Agents
84
140
 
85
141
  | Agent | Config Directory |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchestra-research/ai-research-skills",
3
- "version": "1.2.1",
3
+ "version": "1.3.6",
4
4
  "description": "Install AI research engineering skills to your coding agents (Claude Code, OpenCode, Cursor, Gemini CLI, and more)",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -3,8 +3,13 @@ import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
 
5
5
  /**
6
- * Supported coding agents with their global config directories and skills paths
7
- * All agents now support ~/.{agent}/skills/ format
6
+ * Supported coding agents with their global and local config directories
7
+ *
8
+ * Global: ~/.{agent}/skills/ (home directory)
9
+ * Local: .{agent}/skills/ (project directory)
10
+ *
11
+ * localConfigDir/localSkillsDir define where skills go at the project level.
12
+ * These may differ from global paths (e.g., OpenClaw uses <project>/skills/).
8
13
  */
9
14
  export const SUPPORTED_AGENTS = [
10
15
  {
@@ -12,53 +17,69 @@ export const SUPPORTED_AGENTS = [
12
17
  name: 'Claude Code',
13
18
  configDir: '.claude',
14
19
  skillsDir: 'skills',
20
+ localConfigDir: '.claude',
21
+ localSkillsDir: 'skills',
15
22
  },
16
23
  {
17
24
  id: 'cursor',
18
25
  name: 'Cursor',
19
26
  configDir: '.cursor',
20
27
  skillsDir: 'skills',
28
+ localConfigDir: '.cursor',
29
+ localSkillsDir: 'skills',
21
30
  },
22
31
  {
23
32
  id: 'codex',
24
33
  name: 'Codex',
25
34
  configDir: '.codex',
26
35
  skillsDir: 'skills',
36
+ localConfigDir: '.codex',
37
+ localSkillsDir: 'skills',
27
38
  },
28
39
  {
29
40
  id: 'gemini',
30
41
  name: 'Gemini CLI',
31
42
  configDir: '.gemini',
32
43
  skillsDir: 'skills',
44
+ localConfigDir: '.gemini',
45
+ localSkillsDir: 'skills',
33
46
  },
34
47
  {
35
48
  id: 'qwen',
36
49
  name: 'Qwen Code',
37
50
  configDir: '.qwen',
38
51
  skillsDir: 'skills',
52
+ localConfigDir: '.qwen',
53
+ localSkillsDir: 'skills',
39
54
  },
40
55
  {
41
56
  id: 'opencode',
42
57
  name: 'OpenCode',
43
58
  configDir: '.config/opencode',
44
59
  skillsDir: 'skills',
60
+ localConfigDir: '.opencode',
61
+ localSkillsDir: 'skills',
45
62
  },
46
63
  {
47
64
  id: 'openclaw',
48
65
  name: 'OpenClaw',
49
66
  configDir: '.openclaw',
50
67
  skillsDir: 'skills',
68
+ localConfigDir: '.',
69
+ localSkillsDir: 'skills',
51
70
  },
52
71
  {
53
72
  id: 'agents',
54
73
  name: 'Shared Agents',
55
74
  configDir: '.agents',
56
75
  skillsDir: 'skills',
76
+ localConfigDir: '.agents',
77
+ localSkillsDir: 'skills',
57
78
  },
58
79
  ];
59
80
 
60
81
  /**
61
- * Detect which coding agents are installed on the system
82
+ * Detect which coding agents are installed on the system (global)
62
83
  * @returns {Array} List of detected agents with their paths
63
84
  */
64
85
  export function detectAgents() {
@@ -81,6 +102,49 @@ export function detectAgents() {
81
102
  return detected;
82
103
  }
83
104
 
105
+ /**
106
+ * Build local agent targets for a given project directory
107
+ * @param {Array} agents - List of agent configs (from SUPPORTED_AGENTS or detectAgents)
108
+ * @param {string} projectDir - Absolute path to the project root
109
+ * @returns {Array} List of agents with local paths set
110
+ */
111
+ export function buildLocalAgentTargets(agents, projectDir) {
112
+ return agents.map(agent => ({
113
+ ...agent,
114
+ path: `./${agent.localConfigDir || agent.configDir}`,
115
+ fullPath: join(projectDir, agent.localConfigDir || agent.configDir),
116
+ skillsPath: join(projectDir, agent.localConfigDir || agent.configDir, agent.localSkillsDir || agent.skillsDir),
117
+ local: true,
118
+ }));
119
+ }
120
+
121
+ /**
122
+ * Detect which coding agents have local skills in a project directory
123
+ * @param {string} projectDir - Absolute path to the project root
124
+ * @returns {Array} List of agents with local skills directories
125
+ */
126
+ export function detectLocalAgents(projectDir) {
127
+ const detected = [];
128
+
129
+ for (const agent of SUPPORTED_AGENTS) {
130
+ const localConfigDir = agent.localConfigDir || agent.configDir;
131
+ const localSkillsDir = agent.localSkillsDir || agent.skillsDir;
132
+ const skillsPath = join(projectDir, localConfigDir, localSkillsDir);
133
+
134
+ if (existsSync(skillsPath)) {
135
+ detected.push({
136
+ ...agent,
137
+ path: `./${localConfigDir}`,
138
+ fullPath: join(projectDir, localConfigDir),
139
+ skillsPath,
140
+ local: true,
141
+ });
142
+ }
143
+ }
144
+
145
+ return detected;
146
+ }
147
+
84
148
  /**
85
149
  * Get agent by ID
86
150
  * @param {string} id Agent ID
package/src/ascii.js CHANGED
@@ -100,6 +100,43 @@ export function showSuccess(skillCount, agents) {
100
100
  console.log();
101
101
  }
102
102
 
103
+ /**
104
+ * Local installation success screen
105
+ */
106
+ export function showLocalSuccess(skillCount, agents, projectDir) {
107
+ console.clear();
108
+ console.log();
109
+ console.log();
110
+ console.log(chalk.green.bold(' ✓ Local Installation Complete'));
111
+ console.log();
112
+ console.log();
113
+ console.log(` Installed ${chalk.white(skillCount)} skills to ${chalk.white(agents.length)} agent${agents.length !== 1 ? 's' : ''}`);
114
+ console.log(` Project: ${chalk.white(projectDir)}`);
115
+ console.log();
116
+
117
+ console.log(chalk.dim(' Skills copied to:'));
118
+ for (const agent of agents) {
119
+ console.log(chalk.dim(` → ${agent.skillsPath.replace(projectDir, '.')}`));
120
+ }
121
+ console.log();
122
+ console.log(chalk.dim(' Skills are copied (not symlinked) and can be'));
123
+ console.log(chalk.dim(' committed to version control for team sharing.'));
124
+ console.log();
125
+ console.log(chalk.dim(' ────────────────────────────────────────────────────────────'));
126
+ console.log();
127
+ console.log(chalk.white(' Commands:'));
128
+ console.log();
129
+ console.log(` ${chalk.dim('$')} ${chalk.cyan('npx @orchestra-research/ai-research-skills list --local')}`);
130
+ console.log(` ${chalk.dim('$')} ${chalk.cyan('npx @orchestra-research/ai-research-skills update --local')}`);
131
+ console.log(` ${chalk.dim('$')} ${chalk.cyan('npx @orchestra-research/ai-research-skills uninstall --local')}`);
132
+ console.log();
133
+ console.log(chalk.dim(' ────────────────────────────────────────────────────────────'));
134
+ console.log();
135
+ console.log(chalk.dim(' Tip: Add .orchestra-skills.json to your repo'));
136
+ console.log(chalk.dim(' so teammates can run `update --local` to sync.'));
137
+ console.log();
138
+ }
139
+
103
140
  /**
104
141
  * No agents found screen
105
142
  */
package/src/index.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import ora from 'ora';
2
2
  import chalk from 'chalk';
3
3
 
4
- import { detectAgents } from './agents.js';
5
- import { showWelcome, showAgentsDetected, showSuccess, showNoAgents, showMenuHeader } from './ascii.js';
4
+ import { detectAgents, buildLocalAgentTargets, detectLocalAgents, SUPPORTED_AGENTS } from './agents.js';
5
+ import { showWelcome, showAgentsDetected, showSuccess, showLocalSuccess, showNoAgents, showMenuHeader } from './ascii.js';
6
6
  import {
7
7
  askInstallChoice,
8
8
  askCategories,
9
9
  askIndividualSkills,
10
10
  askConfirmation,
11
+ askLocalConfirmation,
11
12
  askMainMenuAction,
12
13
  askSelectAgents,
14
+ askSelectLocalAgents,
13
15
  askAfterAction,
14
16
  askUninstallChoice,
15
17
  askSelectSkillsToUninstall,
@@ -20,7 +22,25 @@ import {
20
22
  QUICK_START_SKILLS,
21
23
  getTotalSkillCount,
22
24
  } from './prompts.js';
23
- import { installSkills, installSpecificSkills, listInstalledSkills, getAllCategoryIds, updateInstalledSkills, uninstallAllSkills, uninstallSpecificSkills, getInstalledSkillPaths, getInstalledSkillsForSelection } from './installer.js';
25
+ import {
26
+ installSkills,
27
+ installSpecificSkills,
28
+ installSkillsLocal,
29
+ installSpecificSkillsLocal,
30
+ listInstalledSkills,
31
+ listLocalSkills,
32
+ getAllCategoryIds,
33
+ updateInstalledSkills,
34
+ updateLocalSkills,
35
+ uninstallAllSkills,
36
+ uninstallSpecificSkills,
37
+ uninstallLocalSkills,
38
+ uninstallAllLocalSkills,
39
+ getInstalledSkillPaths,
40
+ getInstalledSkillsForSelection,
41
+ getLocalSkillPaths,
42
+ getLocalSkillsForSelection,
43
+ } from './installer.js';
24
44
 
25
45
  /**
26
46
  * Sleep utility
@@ -166,6 +186,103 @@ async function interactiveFlow() {
166
186
  continue step2_menu;
167
187
  }
168
188
 
189
+ if (menuAction === 'install-local') {
190
+ // LOCAL INSTALLATION FLOW
191
+ const projectDir = process.cwd();
192
+ const localAgents = buildLocalAgentTargets(
193
+ agents.length > 0 ? agents : SUPPORTED_AGENTS.slice(0, 1).map(a => ({ ...a })),
194
+ projectDir
195
+ );
196
+
197
+ // Choose what to install locally
198
+ step_local_choice:
199
+ while (true) {
200
+ showMenuHeader();
201
+ console.log(chalk.cyan(` Local install to: ${projectDir}`));
202
+ console.log();
203
+ const choice = await askInstallChoice();
204
+
205
+ if (choice === 'back') {
206
+ continue step2_menu;
207
+ }
208
+
209
+ let categories = [];
210
+ let selectedSkills = [];
211
+ let skillCount = 0;
212
+ let installType = choice;
213
+
214
+ if (choice === 'everything') {
215
+ categories = getAllCategoryIds();
216
+ skillCount = getTotalSkillCount();
217
+ } else if (choice === 'quickstart') {
218
+ categories = [...new Set(QUICK_START_SKILLS.map(s => s.split('/')[0]))];
219
+ skillCount = QUICK_START_SKILLS.length;
220
+ } else if (choice === 'categories') {
221
+ step_local_categories:
222
+ while (true) {
223
+ showMenuHeader();
224
+ const result = await askCategories();
225
+ if (result.action === 'back') continue step_local_choice;
226
+ if (result.action === 'retry') continue step_local_categories;
227
+ categories = result.categories;
228
+ skillCount = CATEGORIES
229
+ .filter(c => categories.includes(c.id))
230
+ .reduce((sum, c) => sum + c.skills, 0);
231
+ break;
232
+ }
233
+ } else if (choice === 'individual') {
234
+ step_local_individual:
235
+ while (true) {
236
+ showMenuHeader();
237
+ const result = await askIndividualSkills();
238
+ if (result.action === 'back') continue step_local_choice;
239
+ if (result.action === 'retry') continue step_local_individual;
240
+ selectedSkills = result.skills;
241
+ skillCount = selectedSkills.length;
242
+ break;
243
+ }
244
+ }
245
+
246
+ // Select local agents
247
+ let targetAgents = localAgents;
248
+ step_local_agents:
249
+ while (true) {
250
+ showMenuHeader();
251
+ const agentResult = await askSelectLocalAgents(localAgents);
252
+ if (agentResult.action === 'back') continue step_local_choice;
253
+ if (agentResult.action === 'retry') continue step_local_agents;
254
+ targetAgents = agentResult.agents;
255
+
256
+ // Confirmation
257
+ showMenuHeader();
258
+ const confirmAction = await askLocalConfirmation(skillCount, targetAgents, projectDir, categories, selectedSkills, installType);
259
+ if (confirmAction === 'exit') {
260
+ console.log(chalk.dim(' Goodbye!'));
261
+ console.log();
262
+ return;
263
+ }
264
+ if (confirmAction === 'back') continue step_local_agents;
265
+ break;
266
+ }
267
+
268
+ // Install locally
269
+ console.log();
270
+ console.log(chalk.cyan(' Installing locally...'));
271
+ console.log();
272
+
273
+ let installedCount;
274
+ if (selectedSkills.length > 0) {
275
+ installedCount = await installSpecificSkillsLocal(selectedSkills, targetAgents, projectDir);
276
+ } else {
277
+ installedCount = await installSkillsLocal(categories, targetAgents, projectDir);
278
+ }
279
+
280
+ await sleep(500);
281
+ showLocalSuccess(installedCount, targetAgents, projectDir);
282
+ return;
283
+ }
284
+ }
285
+
169
286
  // STEP 3: Choose what to install (menuAction === 'install')
170
287
  step3_choice:
171
288
  while (true) {
@@ -286,35 +403,77 @@ async function interactiveFlow() {
286
403
  * Direct command mode (for power users)
287
404
  */
288
405
  async function commandMode(options) {
406
+ const projectDir = process.cwd();
407
+ const isLocal = options.local;
408
+
289
409
  if (options.command === 'list') {
290
- listInstalledSkills();
410
+ if (isLocal) {
411
+ listLocalSkills(projectDir);
412
+ } else {
413
+ listInstalledSkills();
414
+ }
291
415
  return;
292
416
  }
293
417
 
294
418
  if (options.command === 'update') {
295
- const agents = detectAgents();
296
- if (agents.length === 0) {
297
- console.log(chalk.yellow('No agents detected.'));
298
- return;
299
- }
300
- const installedPaths = getInstalledSkillPaths();
301
- if (installedPaths.length === 0) {
302
- console.log(chalk.yellow('No skills installed to update.'));
303
- return;
419
+ if (isLocal) {
420
+ const agents = detectAgents();
421
+ const localAgents = buildLocalAgentTargets(
422
+ agents.length > 0 ? agents : [SUPPORTED_AGENTS[0]],
423
+ projectDir
424
+ );
425
+ const localPaths = getLocalSkillPaths(projectDir);
426
+ if (localPaths.length === 0) {
427
+ console.log(chalk.yellow('No local skills installed to update.'));
428
+ return;
429
+ }
430
+ console.log(chalk.cyan(`Updating ${localPaths.length} local skills...`));
431
+ await updateLocalSkills(localAgents, projectDir);
432
+ console.log(chalk.green('✓ Local skills updated!'));
433
+ } else {
434
+ const agents = detectAgents();
435
+ if (agents.length === 0) {
436
+ console.log(chalk.yellow('No agents detected.'));
437
+ return;
438
+ }
439
+ const installedPaths = getInstalledSkillPaths();
440
+ if (installedPaths.length === 0) {
441
+ console.log(chalk.yellow('No skills installed to update.'));
442
+ return;
443
+ }
444
+ console.log(chalk.cyan(`Updating ${installedPaths.length} installed skills...`));
445
+ await updateInstalledSkills(agents);
446
+ console.log(chalk.green('✓ Skills updated!'));
304
447
  }
305
- console.log(chalk.cyan(`Updating ${installedPaths.length} installed skills...`));
306
- await updateInstalledSkills(agents);
307
- console.log(chalk.green('✓ Skills updated!'));
308
448
  return;
309
449
  }
310
450
 
311
- if (options.command === 'install' || options.all || options.category || options.skill) {
312
- const agents = detectAgents();
313
- if (agents.length === 0) {
314
- console.log(chalk.yellow('No agents detected.'));
315
- return;
451
+ if (options.command === 'uninstall') {
452
+ if (isLocal) {
453
+ const agents = detectAgents();
454
+ const localAgents = buildLocalAgentTargets(
455
+ agents.length > 0 ? agents : [SUPPORTED_AGENTS[0]],
456
+ projectDir
457
+ );
458
+ const detectedLocal = detectLocalAgents(projectDir);
459
+ const targets = detectedLocal.length > 0 ? detectedLocal : localAgents;
460
+ console.log(chalk.cyan('Uninstalling local skills...'));
461
+ await uninstallAllLocalSkills(targets, projectDir);
462
+ console.log(chalk.green('✓ Local skills removed!'));
463
+ } else {
464
+ const agents = detectAgents();
465
+ if (agents.length === 0) {
466
+ console.log(chalk.yellow('No agents detected.'));
467
+ return;
468
+ }
469
+ console.log(chalk.cyan('Uninstalling all skills...'));
470
+ await uninstallAllSkills(agents);
471
+ console.log(chalk.green('✓ Skills removed!'));
316
472
  }
473
+ return;
474
+ }
317
475
 
476
+ if (options.command === 'install' || options.all || options.category || options.skill) {
318
477
  let categories;
319
478
  if (options.all) {
320
479
  categories = getAllCategoryIds();
@@ -334,9 +493,25 @@ async function commandMode(options) {
334
493
  categories = getAllCategoryIds();
335
494
  }
336
495
 
337
- console.log(chalk.cyan('Installing skills...'));
338
- await installSkills(categories, agents);
339
- console.log(chalk.green('✓ Done!'));
496
+ if (isLocal) {
497
+ const agents = detectAgents();
498
+ const localAgents = buildLocalAgentTargets(
499
+ agents.length > 0 ? agents : [SUPPORTED_AGENTS[0]],
500
+ projectDir
501
+ );
502
+ console.log(chalk.cyan(`Installing skills locally to ${projectDir}...`));
503
+ await installSkillsLocal(categories, localAgents, projectDir);
504
+ console.log(chalk.green('✓ Done! Skills installed to project directory.'));
505
+ } else {
506
+ const agents = detectAgents();
507
+ if (agents.length === 0) {
508
+ console.log(chalk.yellow('No agents detected.'));
509
+ return;
510
+ }
511
+ console.log(chalk.cyan('Installing skills...'));
512
+ await installSkills(categories, agents);
513
+ console.log(chalk.green('✓ Done!'));
514
+ }
340
515
  return;
341
516
  }
342
517
  }
package/src/installer.js CHANGED
@@ -8,6 +8,7 @@ import ora from 'ora';
8
8
  const REPO_URL = 'https://github.com/Orchestra-Research/AI-research-SKILLs';
9
9
  const CANONICAL_DIR = join(homedir(), '.orchestra', 'skills');
10
10
  const LOCK_FILE = join(homedir(), '.orchestra', '.lock.json');
11
+ const LOCAL_LOCK_FILENAME = '.orchestra-skills.json';
11
12
 
12
13
  /**
13
14
  * Copy directory contents (cross-platform replacement for `cp -r source/* dest/`)
@@ -621,3 +622,409 @@ export function getInstalledSkillsForSelection() {
621
622
  }
622
623
  });
623
624
  }
625
+
626
+ // ─────────────────────────────────────────────────────────────────────────────
627
+ // Local (project-level) installation
628
+ // ─────────────────────────────────────────────────────────────────────────────
629
+
630
+ /**
631
+ * Get the local lock file path for a project
632
+ */
633
+ function getLocalLockPath(projectDir) {
634
+ return join(projectDir, LOCAL_LOCK_FILENAME);
635
+ }
636
+
637
+ /**
638
+ * Read local lock file
639
+ */
640
+ function readLocalLock(projectDir) {
641
+ const lockPath = getLocalLockPath(projectDir);
642
+ if (existsSync(lockPath)) {
643
+ try {
644
+ return JSON.parse(readFileSync(lockPath, 'utf8'));
645
+ } catch {
646
+ return { version: null, installedAt: null, skills: [], agents: [] };
647
+ }
648
+ }
649
+ return { version: null, installedAt: null, skills: [], agents: [] };
650
+ }
651
+
652
+ /**
653
+ * Write local lock file
654
+ */
655
+ function writeLocalLock(projectDir, data) {
656
+ writeFileSync(getLocalLockPath(projectDir), JSON.stringify(data, null, 2));
657
+ }
658
+
659
+ /**
660
+ * Copy skills directly into agent local directories (no symlinks)
661
+ * @param {Object} agent - Agent with skillsPath set to local project path
662
+ * @param {Array} skills - Skills list from download
663
+ * @param {string} tempDir - Temp clone directory
664
+ */
665
+ function copySkillsToLocal(agent, skills, tempDir) {
666
+ const agentSkillsPath = agent.skillsPath;
667
+
668
+ if (!existsSync(agentSkillsPath)) {
669
+ mkdirSync(agentSkillsPath, { recursive: true });
670
+ }
671
+
672
+ let copiedCount = 0;
673
+
674
+ for (const skill of skills) {
675
+ const sourcePath = skill.standalone
676
+ ? join(tempDir, skill.category)
677
+ : join(tempDir, skill.category, skill.skill);
678
+
679
+ if (!existsSync(sourcePath)) continue;
680
+
681
+ const destName = skill.standalone ? skill.category : skill.skill;
682
+ const destPath = join(agentSkillsPath, destName);
683
+
684
+ // Remove existing if present
685
+ if (existsSync(destPath)) {
686
+ rmSync(destPath, { recursive: true, force: true });
687
+ }
688
+
689
+ mkdirSync(destPath, { recursive: true });
690
+ copyDirectoryContents(sourcePath, destPath);
691
+ copiedCount++;
692
+ }
693
+
694
+ return copiedCount;
695
+ }
696
+
697
+ /**
698
+ * Download and install skills locally to agent project directories
699
+ */
700
+ export async function installSkillsLocal(categories, agents, projectDir) {
701
+ const spinner = ora('Downloading from GitHub...').start();
702
+
703
+ const tempDir = join(homedir(), '.orchestra', '.temp-clone');
704
+
705
+ try {
706
+ if (existsSync(tempDir)) {
707
+ rmSync(tempDir, { recursive: true, force: true });
708
+ }
709
+
710
+ spinner.text = 'Cloning repository...';
711
+ execSync(`git clone --depth 1 ${REPO_URL}.git ${tempDir}`, {
712
+ stdio: 'pipe',
713
+ });
714
+
715
+ // Build skills list from categories
716
+ const skills = [];
717
+ for (const categoryId of categories) {
718
+ const categoryPath = join(tempDir, categoryId);
719
+ if (!existsSync(categoryPath)) continue;
720
+
721
+ const standaloneSkillPath = join(categoryPath, 'SKILL.md');
722
+ if (existsSync(standaloneSkillPath)) {
723
+ skills.push({ category: categoryId, skill: categoryId, standalone: true });
724
+ } else {
725
+ const entries = readdirSync(categoryPath, { withFileTypes: true });
726
+ for (const entry of entries) {
727
+ if (entry.isDirectory()) {
728
+ const skillPath = join(categoryPath, entry.name, 'SKILL.md');
729
+ if (existsSync(skillPath)) {
730
+ skills.push({ category: categoryId, skill: entry.name, standalone: false });
731
+ }
732
+ }
733
+ }
734
+ }
735
+ }
736
+
737
+ spinner.succeed(`Found ${skills.length} skills`);
738
+
739
+ // Copy to each agent's local directory
740
+ spinner.start('Installing to project...');
741
+
742
+ for (const agent of agents) {
743
+ const count = copySkillsToLocal(agent, skills, tempDir);
744
+ console.log(` ${chalk.green('✓')} ${agent.name.padEnd(14)} ${chalk.dim('→')} ${agent.skillsPath.replace(projectDir, '.').padEnd(30)} ${chalk.green(count + ' skills')}`);
745
+ }
746
+
747
+ spinner.stop();
748
+
749
+ // Cleanup
750
+ rmSync(tempDir, { recursive: true, force: true });
751
+
752
+ // Update local lock file
753
+ const lock = readLocalLock(projectDir);
754
+ lock.version = '1.0.0';
755
+ lock.installedAt = new Date().toISOString();
756
+ lock.skills = [...(lock.skills || []).filter(s => {
757
+ const existing = `${s.category}/${s.skill}`;
758
+ return !skills.some(ns => `${ns.category}/${ns.skill}` === existing);
759
+ }), ...skills];
760
+ lock.agents = agents.map(a => a.id);
761
+ writeLocalLock(projectDir, lock);
762
+
763
+ return skills.length;
764
+ } catch (error) {
765
+ if (existsSync(tempDir)) {
766
+ rmSync(tempDir, { recursive: true, force: true });
767
+ }
768
+ spinner.fail('Installation failed');
769
+ throw error;
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Download and install specific skills locally
775
+ */
776
+ export async function installSpecificSkillsLocal(skillPaths, agents, projectDir) {
777
+ const spinner = ora('Downloading from GitHub...').start();
778
+
779
+ const tempDir = join(homedir(), '.orchestra', '.temp-clone');
780
+
781
+ try {
782
+ if (existsSync(tempDir)) {
783
+ rmSync(tempDir, { recursive: true, force: true });
784
+ }
785
+
786
+ spinner.text = 'Cloning repository...';
787
+ execSync(`git clone --depth 1 ${REPO_URL}.git ${tempDir}`, {
788
+ stdio: 'pipe',
789
+ });
790
+
791
+ const skills = [];
792
+ for (const skillPath of skillPaths) {
793
+ const parts = skillPath.split('/');
794
+ const categoryId = parts[0];
795
+ const skillName = parts[1] || null;
796
+
797
+ if (skillName) {
798
+ const sourcePath = join(tempDir, categoryId, skillName);
799
+ if (existsSync(sourcePath)) {
800
+ skills.push({ category: categoryId, skill: skillName, standalone: false });
801
+ }
802
+ } else {
803
+ const sourcePath = join(tempDir, categoryId);
804
+ if (existsSync(sourcePath)) {
805
+ skills.push({ category: categoryId, skill: categoryId, standalone: true });
806
+ }
807
+ }
808
+ }
809
+
810
+ spinner.succeed(`Found ${skills.length} skills`);
811
+
812
+ // Copy to each agent's local directory
813
+ spinner.start('Installing to project...');
814
+
815
+ for (const agent of agents) {
816
+ const count = copySkillsToLocal(agent, skills, tempDir);
817
+ console.log(` ${chalk.green('✓')} ${agent.name.padEnd(14)} ${chalk.dim('→')} ${agent.skillsPath.replace(projectDir, '.').padEnd(30)} ${chalk.green(count + ' skills')}`);
818
+ }
819
+
820
+ spinner.stop();
821
+
822
+ // Cleanup
823
+ rmSync(tempDir, { recursive: true, force: true });
824
+
825
+ // Update local lock file
826
+ const lock = readLocalLock(projectDir);
827
+ lock.version = '1.0.0';
828
+ lock.installedAt = new Date().toISOString();
829
+ lock.skills = [...(lock.skills || []).filter(s => {
830
+ const existing = `${s.category}/${s.skill}`;
831
+ return !skills.some(ns => `${ns.category}/${ns.skill}` === existing);
832
+ }), ...skills];
833
+ lock.agents = agents.map(a => a.id);
834
+ writeLocalLock(projectDir, lock);
835
+
836
+ return skills.length;
837
+ } catch (error) {
838
+ if (existsSync(tempDir)) {
839
+ rmSync(tempDir, { recursive: true, force: true });
840
+ }
841
+ spinner.fail('Installation failed');
842
+ throw error;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * List locally installed skills for a project
848
+ */
849
+ export function listLocalSkills(projectDir) {
850
+ const lock = readLocalLock(projectDir);
851
+
852
+ if (!lock.skills || lock.skills.length === 0) {
853
+ console.log(chalk.yellow(' No skills installed locally in this project.'));
854
+ console.log();
855
+ console.log(` Run ${chalk.cyan('npx @orchestra-research/ai-research-skills install --local')} to install skills.`);
856
+ return;
857
+ }
858
+
859
+ const byCategory = {};
860
+ let totalSkills = 0;
861
+
862
+ for (const skill of lock.skills) {
863
+ const category = skill.category;
864
+ if (!byCategory[category]) {
865
+ byCategory[category] = [];
866
+ }
867
+ if (skill.standalone) {
868
+ byCategory[category].push(category);
869
+ } else {
870
+ byCategory[category].push(skill.skill);
871
+ }
872
+ totalSkills++;
873
+ }
874
+
875
+ console.log(chalk.white.bold(` Local Skills (${totalSkills})`));
876
+ console.log(chalk.dim(` Project: ${projectDir}`));
877
+ console.log();
878
+
879
+ for (const [category, skills] of Object.entries(byCategory)) {
880
+ console.log(chalk.cyan(` ${category}`));
881
+ for (const skill of skills) {
882
+ if (skill === category) {
883
+ console.log(` ${chalk.dim('●')} ${chalk.white('(standalone)')}`);
884
+ } else {
885
+ console.log(` ${chalk.dim('●')} ${skill}`);
886
+ }
887
+ }
888
+ console.log();
889
+ }
890
+
891
+ // Show agent directories
892
+ if (lock.agents && lock.agents.length > 0) {
893
+ console.log(chalk.dim(` Agents: ${lock.agents.join(', ')}`));
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Get locally installed skill paths for a project
899
+ */
900
+ export function getLocalSkillPaths(projectDir) {
901
+ const lock = readLocalLock(projectDir);
902
+ if (!lock.skills || lock.skills.length === 0) {
903
+ return [];
904
+ }
905
+
906
+ return lock.skills.map(s => {
907
+ return s.standalone ? s.category : `${s.category}/${s.skill}`;
908
+ });
909
+ }
910
+
911
+ /**
912
+ * Get locally installed skills with display info for selection
913
+ */
914
+ export function getLocalSkillsForSelection(projectDir) {
915
+ const lock = readLocalLock(projectDir);
916
+ if (!lock.skills || lock.skills.length === 0) {
917
+ return [];
918
+ }
919
+
920
+ return lock.skills.map(s => {
921
+ if (s.standalone) {
922
+ return { path: s.category, name: s.category, category: 'Standalone', standalone: true };
923
+ } else {
924
+ return { path: `${s.category}/${s.skill}`, name: s.skill, category: s.category, standalone: false };
925
+ }
926
+ });
927
+ }
928
+
929
+ /**
930
+ * Update locally installed skills
931
+ */
932
+ export async function updateLocalSkills(agents, projectDir) {
933
+ const installedPaths = getLocalSkillPaths(projectDir);
934
+
935
+ if (installedPaths.length === 0) {
936
+ console.log(chalk.yellow(' No local skills installed to update.'));
937
+ return 0;
938
+ }
939
+
940
+ // Re-install the same skills
941
+ return await installSpecificSkillsLocal(installedPaths, agents, projectDir);
942
+ }
943
+
944
+ /**
945
+ * Uninstall specific local skills
946
+ */
947
+ export async function uninstallLocalSkills(skillPaths, agents, projectDir) {
948
+ const spinner = ora('Removing local skills...').start();
949
+
950
+ try {
951
+ for (const skillPath of skillPaths) {
952
+ const parts = skillPath.split('/');
953
+ const categoryId = parts[0];
954
+ const skillName = parts[1] || null;
955
+ const linkName = skillName || categoryId;
956
+
957
+ // Remove from each agent's local directory
958
+ for (const agent of agents) {
959
+ const skillDir = join(agent.skillsPath, linkName);
960
+ if (existsSync(skillDir)) {
961
+ rmSync(skillDir, { recursive: true, force: true });
962
+ }
963
+ }
964
+
965
+ spinner.text = `Removed ${linkName}`;
966
+ }
967
+
968
+ spinner.succeed(`Removed ${skillPaths.length} skill${skillPaths.length !== 1 ? 's' : ''}`);
969
+
970
+ // Update local lock file
971
+ const lock = readLocalLock(projectDir);
972
+ if (lock.skills) {
973
+ lock.skills = lock.skills.filter(s => {
974
+ const path = s.standalone ? s.category : `${s.category}/${s.skill}`;
975
+ return !skillPaths.includes(path);
976
+ });
977
+ writeLocalLock(projectDir, lock);
978
+ }
979
+
980
+ return skillPaths.length;
981
+ } catch (error) {
982
+ spinner.fail('Uninstall failed');
983
+ throw error;
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Uninstall all local skills
989
+ */
990
+ export async function uninstallAllLocalSkills(agents, projectDir) {
991
+ const lock = readLocalLock(projectDir);
992
+ const trackedSkills = lock.skills || [];
993
+
994
+ if (trackedSkills.length === 0) {
995
+ console.log(chalk.yellow(' No tracked local skills to remove.'));
996
+ return false;
997
+ }
998
+
999
+ const spinner = ora('Removing all local skills...').start();
1000
+
1001
+ try {
1002
+ // Build set of directory names to remove (only tracked skills)
1003
+ const skillNames = trackedSkills.map(s => s.standalone ? s.category : s.skill);
1004
+
1005
+ for (const agent of agents) {
1006
+ if (existsSync(agent.skillsPath)) {
1007
+ for (const name of skillNames) {
1008
+ const skillDir = join(agent.skillsPath, name);
1009
+ if (existsSync(skillDir)) {
1010
+ rmSync(skillDir, { recursive: true, force: true });
1011
+ }
1012
+ }
1013
+ }
1014
+ console.log(` ${chalk.green('✓')} Removed skills from ${agent.name} (${agent.skillsPath.replace(projectDir, '.')})`);
1015
+ }
1016
+
1017
+ // Remove local lock file
1018
+ const lockPath = getLocalLockPath(projectDir);
1019
+ if (existsSync(lockPath)) {
1020
+ rmSync(lockPath, { force: true });
1021
+ console.log(` ${chalk.green('✓')} Removed ${LOCAL_LOCK_FILENAME}`);
1022
+ }
1023
+
1024
+ spinner.stop();
1025
+ return true;
1026
+ } catch (error) {
1027
+ spinner.fail('Uninstall failed');
1028
+ throw error;
1029
+ }
1030
+ }
package/src/prompts.js CHANGED
@@ -94,8 +94,10 @@ export function getTotalSkillCount() {
94
94
  /**
95
95
  * Ask main menu action after agent detection
96
96
  */
97
- export async function askMainMenuAction() {
97
+ export async function askMainMenuAction(projectDir) {
98
98
  console.log();
99
+ const cwd = projectDir || process.cwd();
100
+ const shortCwd = cwd.split('/').slice(-2).join('/');
99
101
  const { action } = await inquirer.prompt([
100
102
  {
101
103
  type: 'list',
@@ -103,9 +105,10 @@ export async function askMainMenuAction() {
103
105
  message: ' ',
104
106
  choices: [
105
107
  { name: 'Install new skills', value: 'install' },
108
+ { name: `Install to project (local) ${chalk.dim('→ ./' + shortCwd)}`, value: 'install-local' },
106
109
  { name: 'View installed skills', value: 'view' },
107
110
  { name: 'Update installed skills', value: 'update' },
108
- { name: 'Uninstall all skills', value: 'uninstall' },
111
+ { name: 'Uninstall skills', value: 'uninstall' },
109
112
  new inquirer.Separator(' '),
110
113
  { name: chalk.dim('Exit'), value: 'exit' },
111
114
  ],
@@ -115,6 +118,141 @@ export async function askMainMenuAction() {
115
118
  return action;
116
119
  }
117
120
 
121
+ /**
122
+ * Ask which agents to install to locally
123
+ */
124
+ export async function askSelectLocalAgents(agents) {
125
+ console.log();
126
+ console.log(chalk.dim(' Install to which agents in this project?'));
127
+ console.log();
128
+
129
+ const { selection } = await inquirer.prompt([
130
+ {
131
+ type: 'list',
132
+ name: 'selection',
133
+ message: ' ',
134
+ choices: [
135
+ { name: `All detected agents (${agents.length})`, value: 'all' },
136
+ { name: 'Select specific agents', value: 'select' },
137
+ new inquirer.Separator(' '),
138
+ { name: chalk.dim('← Back'), value: 'back' },
139
+ ],
140
+ prefix: ' ',
141
+ },
142
+ ]);
143
+
144
+ if (selection === 'back') {
145
+ return { agents: [], action: 'back' };
146
+ }
147
+
148
+ if (selection === 'all') {
149
+ return { agents, action: 'confirm' };
150
+ }
151
+
152
+ // Select specific agents
153
+ console.log();
154
+ const { selectedAgents } = await inquirer.prompt([
155
+ {
156
+ type: 'checkbox',
157
+ name: 'selectedAgents',
158
+ message: ' ',
159
+ choices: agents.map(agent => ({
160
+ name: `${agent.name.padEnd(14)} ${chalk.dim(agent.path)}`,
161
+ value: agent,
162
+ checked: false,
163
+ })),
164
+ prefix: ' ',
165
+ },
166
+ ]);
167
+
168
+ if (selectedAgents.length === 0) {
169
+ console.log();
170
+ const { action } = await inquirer.prompt([
171
+ {
172
+ type: 'list',
173
+ name: 'action',
174
+ message: chalk.yellow('No agents selected'),
175
+ choices: [
176
+ { name: 'Try again', value: 'retry' },
177
+ { name: chalk.dim('← Back'), value: 'back' },
178
+ ],
179
+ prefix: ' ',
180
+ },
181
+ ]);
182
+ return { agents: [], action };
183
+ }
184
+
185
+ return { agents: selectedAgents, action: 'confirm' };
186
+ }
187
+
188
+ /**
189
+ * Ask for local install confirmation
190
+ */
191
+ export async function askLocalConfirmation(skillCount, agents, projectDir, categories, selectedSkills, installType) {
192
+ console.log();
193
+ console.log(chalk.white(' Local Installation Summary'));
194
+ console.log(chalk.dim(' ─────────────────────────────────────────────────────'));
195
+ console.log();
196
+
197
+ console.log(` ${chalk.white('Skills:')} ${skillCount} skills`);
198
+ console.log(` ${chalk.white('Project:')} ${projectDir}`);
199
+ console.log(` ${chalk.white('Agents:')} ${agents.map(a => a.name).join(', ')}`);
200
+ console.log();
201
+
202
+ // Destinations
203
+ console.log(chalk.dim(' Destinations:'));
204
+ for (const agent of agents) {
205
+ console.log(chalk.dim(` • ${agent.skillsPath.replace(projectDir, '.')}`));
206
+ }
207
+ console.log();
208
+
209
+ // Description based on install type
210
+ if (installType === 'everything') {
211
+ console.log(chalk.dim(' All 20 categories'));
212
+ } else if (installType === 'quickstart') {
213
+ console.log(chalk.dim(' Essential skills for AI research'));
214
+ } else if (categories && categories.length > 0) {
215
+ const catNames = CATEGORIES
216
+ .filter(c => categories.includes(c.id))
217
+ .map(c => c.name);
218
+ console.log(chalk.dim(' Selected categories:'));
219
+ catNames.forEach(name => console.log(chalk.dim(` • ${name}`)));
220
+ } else if (selectedSkills && selectedSkills.length > 0) {
221
+ console.log(chalk.dim(' Selected skills:'));
222
+ const skillNames = INDIVIDUAL_SKILLS
223
+ .filter(s => selectedSkills.includes(s.id))
224
+ .map(s => s.name)
225
+ .slice(0, 8);
226
+ skillNames.forEach(name => console.log(chalk.dim(` • ${name}`)));
227
+ if (selectedSkills.length > 8) {
228
+ console.log(chalk.dim(` • ...and ${selectedSkills.length - 8} more`));
229
+ }
230
+ }
231
+
232
+ console.log();
233
+ console.log(chalk.dim(' ─────────────────────────────────────────────────────'));
234
+ console.log();
235
+ console.log(chalk.dim(' Skills will be copied (not symlinked) so you can'));
236
+ console.log(chalk.dim(' commit them to version control.'));
237
+ console.log();
238
+
239
+ const { action } = await inquirer.prompt([
240
+ {
241
+ type: 'list',
242
+ name: 'action',
243
+ message: ' ',
244
+ choices: [
245
+ { name: chalk.green('Install locally'), value: 'confirm' },
246
+ { name: chalk.dim('← Back'), value: 'back' },
247
+ { name: chalk.dim('Exit'), value: 'exit' },
248
+ ],
249
+ prefix: ' ',
250
+ },
251
+ ]);
252
+
253
+ return action;
254
+ }
255
+
118
256
  /**
119
257
  * Ask what to uninstall
120
258
  */
@@ -500,6 +638,7 @@ export function parseArgs(args) {
500
638
  const options = {
501
639
  command: null,
502
640
  all: false,
641
+ local: false,
503
642
  category: null,
504
643
  skill: null,
505
644
  agent: null,
@@ -514,8 +653,12 @@ export function parseArgs(args) {
514
653
  options.command = 'list';
515
654
  } else if (arg === 'update') {
516
655
  options.command = 'update';
656
+ } else if (arg === 'uninstall') {
657
+ options.command = 'uninstall';
517
658
  } else if (arg === '--all' || arg === '-a') {
518
659
  options.all = true;
660
+ } else if (arg === '--local' || arg === '-l') {
661
+ options.local = true;
519
662
  } else if (arg === '--agent' && args[i + 1]) {
520
663
  options.agent = args[++i];
521
664
  } else if (arg === '--category' && args[i + 1]) {