@rbbtsn0w/adg 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- package/vendor/skills/src/use.ts +675 -0
|
@@ -0,0 +1,1999 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { sep, join, dirname } from 'path';
|
|
6
|
+
import { parseSource, getOwnerRepo, parseOwnerRepo, isRepoPrivate } from './source-parser.ts';
|
|
7
|
+
import { stripTerminalEscapes } from './sanitize.ts';
|
|
8
|
+
import { searchMultiselect } from './prompts/search-multiselect.ts';
|
|
9
|
+
|
|
10
|
+
// Helper to check if a value is a cancel symbol (works with both clack and our custom prompts)
|
|
11
|
+
const isCancelled = (value: unknown): value is symbol => typeof value === 'symbol';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a source identifier (owner/repo format) represents a private GitHub repo.
|
|
15
|
+
* Returns true if private, false if public, null if unable to determine or not a GitHub repo.
|
|
16
|
+
*/
|
|
17
|
+
async function isSourcePrivate(source: string): Promise<boolean | null> {
|
|
18
|
+
const ownerRepo = parseOwnerRepo(source);
|
|
19
|
+
if (!ownerRepo) {
|
|
20
|
+
// Not in owner/repo format, assume not private (could be other providers)
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return isRepoPrivate(ownerRepo.owner, ownerRepo.repo);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getLockSource(parsedUrl: string, normalizedSource: string | null): string | null {
|
|
27
|
+
// Preserve SSH URLs in lock files instead of normalizing to owner/repo shorthand.
|
|
28
|
+
// When normalizedSource is used, parseSource() later resolves it to HTTPS,
|
|
29
|
+
// breaking restore for private repos that require SSH authentication.
|
|
30
|
+
const isSSH = parsedUrl.startsWith('git@') || parsedUrl.startsWith('ssh://');
|
|
31
|
+
return isSSH ? parsedUrl : normalizedSource;
|
|
32
|
+
}
|
|
33
|
+
import { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts';
|
|
34
|
+
import { gitTreeShaForFolder } from './git-tree.ts';
|
|
35
|
+
import { discoverSkills, getSkillDisplayName, filterSkills } from './skills.ts';
|
|
36
|
+
import {
|
|
37
|
+
installSkillForAgent,
|
|
38
|
+
installBlobSkillForAgent,
|
|
39
|
+
isSkillInstalled,
|
|
40
|
+
getCanonicalPath,
|
|
41
|
+
installWellKnownSkillForAgent,
|
|
42
|
+
type InstallMode,
|
|
43
|
+
} from './installer.ts';
|
|
44
|
+
import {
|
|
45
|
+
detectInstalledAgents,
|
|
46
|
+
agents,
|
|
47
|
+
getUniversalAgents,
|
|
48
|
+
getVisibleUniversalAgents,
|
|
49
|
+
getNonUniversalAgents,
|
|
50
|
+
isUniversalAgent,
|
|
51
|
+
} from './agents.ts';
|
|
52
|
+
import {
|
|
53
|
+
track,
|
|
54
|
+
setVersion,
|
|
55
|
+
fetchAuditData,
|
|
56
|
+
type AuditResponse,
|
|
57
|
+
type PartnerAudit,
|
|
58
|
+
} from './telemetry.ts';
|
|
59
|
+
import { detectAgent, getAgentType } from './detect-agent.ts';
|
|
60
|
+
import { wellKnownProvider, type WellKnownSkill } from './providers/index.ts';
|
|
61
|
+
import {
|
|
62
|
+
addSkillToLock,
|
|
63
|
+
fetchSkillFolderHash,
|
|
64
|
+
getGitHubToken,
|
|
65
|
+
isPromptDismissed,
|
|
66
|
+
dismissPrompt,
|
|
67
|
+
getLastSelectedAgents,
|
|
68
|
+
saveSelectedAgents,
|
|
69
|
+
} from './skill-lock.ts';
|
|
70
|
+
import { addSkillToLocalLock, computeSkillFolderHash } from './local-lock.ts';
|
|
71
|
+
import type { Skill, AgentType } from './types.ts';
|
|
72
|
+
import {
|
|
73
|
+
tryBlobInstall,
|
|
74
|
+
BLOB_ALLOWED_REPOS,
|
|
75
|
+
getSkillFolderHashFromTree,
|
|
76
|
+
fetchRepoTree,
|
|
77
|
+
type BlobSkill,
|
|
78
|
+
type BlobInstallResult,
|
|
79
|
+
} from './blob.ts';
|
|
80
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
81
|
+
export function initTelemetry(version: string): void {
|
|
82
|
+
setVersion(version);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Security Advisory ───
|
|
86
|
+
|
|
87
|
+
function riskLabel(risk: string): string {
|
|
88
|
+
switch (risk) {
|
|
89
|
+
case 'critical':
|
|
90
|
+
return pc.red(pc.bold('Critical Risk'));
|
|
91
|
+
case 'high':
|
|
92
|
+
return pc.red('High Risk');
|
|
93
|
+
case 'medium':
|
|
94
|
+
return pc.yellow('Med Risk');
|
|
95
|
+
case 'low':
|
|
96
|
+
return pc.green('Low Risk');
|
|
97
|
+
case 'safe':
|
|
98
|
+
return pc.green('Safe');
|
|
99
|
+
default:
|
|
100
|
+
return pc.dim('--');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function socketLabel(audit: PartnerAudit | undefined): string {
|
|
105
|
+
if (!audit) return pc.dim('--');
|
|
106
|
+
const count = audit.alerts ?? 0;
|
|
107
|
+
return count > 0 ? pc.red(`${count} alert${count !== 1 ? 's' : ''}`) : pc.green('0 alerts');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Pad a string to a given visible width (ignoring ANSI escape codes). */
|
|
111
|
+
function padEnd(str: string, width: number): string {
|
|
112
|
+
// Strip ANSI codes to measure visible length
|
|
113
|
+
const visible = stripTerminalEscapes(str);
|
|
114
|
+
const pad = Math.max(0, width - visible.length);
|
|
115
|
+
return str + ' '.repeat(pad);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a compact security table showing partner audit results.
|
|
120
|
+
* Returns the lines to display, or empty array if no data.
|
|
121
|
+
*/
|
|
122
|
+
function buildSecurityLines(
|
|
123
|
+
auditData: AuditResponse | null,
|
|
124
|
+
skills: Array<{ slug: string; displayName: string }>,
|
|
125
|
+
source: string
|
|
126
|
+
): string[] {
|
|
127
|
+
if (!auditData) return [];
|
|
128
|
+
|
|
129
|
+
// Check if we have any audit data at all
|
|
130
|
+
const hasAny = skills.some((s) => {
|
|
131
|
+
const data = auditData[s.slug];
|
|
132
|
+
return data && Object.keys(data).length > 0;
|
|
133
|
+
});
|
|
134
|
+
if (!hasAny) return [];
|
|
135
|
+
|
|
136
|
+
// Compute column width for skill names
|
|
137
|
+
const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36);
|
|
138
|
+
|
|
139
|
+
// Header
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
const header =
|
|
142
|
+
padEnd('', nameWidth + 2) +
|
|
143
|
+
padEnd(pc.dim('Gen'), 18) +
|
|
144
|
+
padEnd(pc.dim('Socket'), 18) +
|
|
145
|
+
pc.dim('Snyk');
|
|
146
|
+
lines.push(header);
|
|
147
|
+
|
|
148
|
+
// Rows
|
|
149
|
+
for (const skill of skills) {
|
|
150
|
+
const data = auditData[skill.slug];
|
|
151
|
+
const name =
|
|
152
|
+
skill.displayName.length > nameWidth
|
|
153
|
+
? skill.displayName.slice(0, nameWidth - 1) + '\u2026'
|
|
154
|
+
: skill.displayName;
|
|
155
|
+
|
|
156
|
+
const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim('--');
|
|
157
|
+
const socket = data?.socket ? socketLabel(data.socket) : pc.dim('--');
|
|
158
|
+
const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim('--');
|
|
159
|
+
|
|
160
|
+
lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Footer link
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push(`${pc.dim('Details:')} ${pc.dim(`https://skills.sh/${source}`)}`);
|
|
166
|
+
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shortens a path for display: replaces homedir with ~ and cwd with .
|
|
172
|
+
* Handles both Unix and Windows path separators.
|
|
173
|
+
*/
|
|
174
|
+
function shortenPath(fullPath: string, cwd: string): string {
|
|
175
|
+
const home = homedir();
|
|
176
|
+
// Ensure we match complete path segments by checking for separator after the prefix
|
|
177
|
+
if (fullPath === home || fullPath.startsWith(home + sep)) {
|
|
178
|
+
return '~' + fullPath.slice(home.length);
|
|
179
|
+
}
|
|
180
|
+
if (fullPath === cwd || fullPath.startsWith(cwd + sep)) {
|
|
181
|
+
return '.' + fullPath.slice(cwd.length);
|
|
182
|
+
}
|
|
183
|
+
return fullPath;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Formats a list of items, truncating if too many
|
|
188
|
+
*/
|
|
189
|
+
function formatList(items: string[], maxShow: number = 5): string {
|
|
190
|
+
if (items.length <= maxShow) {
|
|
191
|
+
return items.join(', ');
|
|
192
|
+
}
|
|
193
|
+
const shown = items.slice(0, maxShow);
|
|
194
|
+
const remaining = items.length - maxShow;
|
|
195
|
+
return `${shown.join(', ')} +${remaining} more`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Splits agents into universal and non-universal (symlinked) groups.
|
|
200
|
+
* Returns display names for each group.
|
|
201
|
+
*/
|
|
202
|
+
function splitAgentsByType(agentTypes: AgentType[]): {
|
|
203
|
+
universal: string[];
|
|
204
|
+
symlinked: string[];
|
|
205
|
+
} {
|
|
206
|
+
const universal: string[] = [];
|
|
207
|
+
const symlinked: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const a of agentTypes) {
|
|
210
|
+
if (isUniversalAgent(a)) {
|
|
211
|
+
universal.push(agents[a].displayName);
|
|
212
|
+
} else {
|
|
213
|
+
symlinked.push(agents[a].displayName);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { universal, symlinked };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds summary lines showing universal vs symlinked agents
|
|
222
|
+
*/
|
|
223
|
+
function buildAgentSummaryLines(targetAgents: AgentType[], installMode: InstallMode): string[] {
|
|
224
|
+
const lines: string[] = [];
|
|
225
|
+
const { universal, symlinked } = splitAgentsByType(targetAgents);
|
|
226
|
+
|
|
227
|
+
if (installMode === 'symlink') {
|
|
228
|
+
if (universal.length > 0) {
|
|
229
|
+
lines.push(` ${pc.green('universal:')} ${formatList(universal)}`);
|
|
230
|
+
}
|
|
231
|
+
if (symlinked.length > 0) {
|
|
232
|
+
lines.push(` ${pc.dim('symlink →')} ${formatList(symlinked)}`);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// Copy mode - all agents get copies
|
|
236
|
+
const allNames = targetAgents.map((a) => agents[a].displayName);
|
|
237
|
+
lines.push(` ${pc.dim('copy →')} ${formatList(allNames)}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ensures universal agents are always included in the target agents list.
|
|
245
|
+
* Used when -y flag is passed or when auto-selecting agents.
|
|
246
|
+
*/
|
|
247
|
+
function ensureUniversalAgents(targetAgents: AgentType[]): AgentType[] {
|
|
248
|
+
const universalAgents = getUniversalAgents();
|
|
249
|
+
const result = [...targetAgents];
|
|
250
|
+
|
|
251
|
+
for (const ua of universalAgents) {
|
|
252
|
+
if (!result.includes(ua)) {
|
|
253
|
+
result.push(ua);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Builds result lines from installation results, splitting by universal vs symlinked
|
|
262
|
+
*/
|
|
263
|
+
function buildResultLines(
|
|
264
|
+
results: Array<{
|
|
265
|
+
agent: string;
|
|
266
|
+
symlinkFailed?: boolean;
|
|
267
|
+
skipped?: boolean;
|
|
268
|
+
}>,
|
|
269
|
+
targetAgents: AgentType[]
|
|
270
|
+
): string[] {
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
|
|
273
|
+
// Split target agents by type
|
|
274
|
+
const { universal, symlinked: symlinkAgents } = splitAgentsByType(targetAgents);
|
|
275
|
+
|
|
276
|
+
// For symlink results, also track which ones actually succeeded vs failed
|
|
277
|
+
// Exclude skipped agents (those whose config dir doesn't exist in the project)
|
|
278
|
+
const successfulSymlinks = results
|
|
279
|
+
.filter((r) => !r.symlinkFailed && !r.skipped && !universal.includes(r.agent))
|
|
280
|
+
.map((r) => r.agent);
|
|
281
|
+
const failedSymlinks = results.filter((r) => r.symlinkFailed && !r.skipped).map((r) => r.agent);
|
|
282
|
+
|
|
283
|
+
if (universal.length > 0) {
|
|
284
|
+
lines.push(` ${pc.green('universal:')} ${formatList(universal)}`);
|
|
285
|
+
}
|
|
286
|
+
if (successfulSymlinks.length > 0) {
|
|
287
|
+
lines.push(` ${pc.dim('symlinked:')} ${formatList(successfulSymlinks)}`);
|
|
288
|
+
}
|
|
289
|
+
if (failedSymlinks.length > 0) {
|
|
290
|
+
lines.push(` ${pc.yellow('copied:')} ${formatList(failedSymlinks)}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Wrapper around p.multiselect that adds a hint for keyboard usage.
|
|
298
|
+
* Accepts options with required labels (matching our usage pattern).
|
|
299
|
+
*/
|
|
300
|
+
function multiselect<Value>(opts: {
|
|
301
|
+
message: string;
|
|
302
|
+
options: Array<{ value: Value; label: string; hint?: string }>;
|
|
303
|
+
initialValues?: Value[];
|
|
304
|
+
required?: boolean;
|
|
305
|
+
}) {
|
|
306
|
+
return p.multiselect({
|
|
307
|
+
...opts,
|
|
308
|
+
// Cast is safe: our options always have labels, which satisfies p.Option requirements
|
|
309
|
+
options: opts.options as p.Option<Value>[],
|
|
310
|
+
message: `${opts.message} ${pc.dim('(space to toggle)')}`,
|
|
311
|
+
}) as Promise<Value[] | symbol>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Prompts the user to select agents using interactive search.
|
|
316
|
+
* Pre-selects the last used agents if available.
|
|
317
|
+
* Saves the selection for future use.
|
|
318
|
+
*/
|
|
319
|
+
export async function promptForAgents(
|
|
320
|
+
message: string,
|
|
321
|
+
choices: Array<{ value: AgentType; label: string; hint?: string }>
|
|
322
|
+
): Promise<AgentType[] | symbol> {
|
|
323
|
+
// Get last selected agents to pre-select
|
|
324
|
+
let lastSelected: string[] | undefined;
|
|
325
|
+
try {
|
|
326
|
+
lastSelected = await getLastSelectedAgents();
|
|
327
|
+
} catch {
|
|
328
|
+
// Silently ignore errors reading lock file
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const validAgents = choices.map((c) => c.value);
|
|
332
|
+
|
|
333
|
+
// Default agents to pre-select when no valid history exists
|
|
334
|
+
const defaultAgents: AgentType[] = ['claude-code', 'opencode', 'codex'];
|
|
335
|
+
const defaultValues = defaultAgents.filter((a) => validAgents.includes(a));
|
|
336
|
+
|
|
337
|
+
let initialValues: AgentType[] = [];
|
|
338
|
+
|
|
339
|
+
if (lastSelected && lastSelected.length > 0) {
|
|
340
|
+
// Filter stored agents against currently valid agents
|
|
341
|
+
initialValues = lastSelected.filter((a) => validAgents.includes(a as AgentType)) as AgentType[];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// If no valid selection from history, use defaults
|
|
345
|
+
if (initialValues.length === 0) {
|
|
346
|
+
initialValues = defaultValues;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const selected = await searchMultiselect({
|
|
350
|
+
message,
|
|
351
|
+
items: choices,
|
|
352
|
+
initialSelected: initialValues,
|
|
353
|
+
required: true,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!isCancelled(selected)) {
|
|
357
|
+
// Save selection for next time
|
|
358
|
+
try {
|
|
359
|
+
await saveSelectedAgents(selected as string[]);
|
|
360
|
+
} catch {
|
|
361
|
+
// Silently ignore errors writing lock file
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return selected as AgentType[] | symbol;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Interactive agent selection using fuzzy search.
|
|
370
|
+
* Shows universal agents as locked (always selected), and other agents as selectable.
|
|
371
|
+
*/
|
|
372
|
+
async function selectAgentsInteractive(options: {
|
|
373
|
+
global?: boolean;
|
|
374
|
+
}): Promise<AgentType[] | symbol> {
|
|
375
|
+
// Filter out agents that don't support global installation when --global is used
|
|
376
|
+
const supportsGlobalFilter = (a: AgentType) => !options.global || agents[a].globalSkillsDir;
|
|
377
|
+
|
|
378
|
+
const universalAgents = getUniversalAgents().filter(supportsGlobalFilter);
|
|
379
|
+
const visibleUniversalAgents = getVisibleUniversalAgents().filter(supportsGlobalFilter);
|
|
380
|
+
const otherAgents = getNonUniversalAgents().filter(supportsGlobalFilter);
|
|
381
|
+
|
|
382
|
+
// Universal agents shown as locked section
|
|
383
|
+
const universalSection = {
|
|
384
|
+
title: 'Universal (.agents/skills)',
|
|
385
|
+
items: visibleUniversalAgents.map((a) => ({
|
|
386
|
+
value: a,
|
|
387
|
+
label: agents[a].displayName,
|
|
388
|
+
})),
|
|
389
|
+
hiddenCount: universalAgents.length - visibleUniversalAgents.length,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Other agents are selectable with their skillsDir as hint
|
|
393
|
+
const otherChoices = otherAgents.map((a) => ({
|
|
394
|
+
value: a,
|
|
395
|
+
label: agents[a].displayName,
|
|
396
|
+
hint: options.global ? agents[a].globalSkillsDir! : agents[a].skillsDir,
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
// Get last selected agents (filter to only non-universal ones for initial selection)
|
|
400
|
+
let lastSelected: string[] | undefined;
|
|
401
|
+
try {
|
|
402
|
+
lastSelected = await getLastSelectedAgents();
|
|
403
|
+
} catch {
|
|
404
|
+
// Silently ignore errors
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const initialSelected = lastSelected
|
|
408
|
+
? (lastSelected.filter(
|
|
409
|
+
(a) => otherAgents.includes(a as AgentType) && !universalAgents.includes(a as AgentType)
|
|
410
|
+
) as AgentType[])
|
|
411
|
+
: [];
|
|
412
|
+
|
|
413
|
+
const selected = await searchMultiselect({
|
|
414
|
+
message: 'Which agents do you want to install to?',
|
|
415
|
+
items: otherChoices,
|
|
416
|
+
initialSelected,
|
|
417
|
+
lockedSection: universalSection,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (!isCancelled(selected)) {
|
|
421
|
+
// Save selection (all agents including universal)
|
|
422
|
+
try {
|
|
423
|
+
await saveSelectedAgents(selected as string[]);
|
|
424
|
+
} catch {
|
|
425
|
+
// Silently ignore errors
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return selected as AgentType[] | symbol;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const version = packageJson.version;
|
|
433
|
+
setVersion(version);
|
|
434
|
+
|
|
435
|
+
export interface AddOptions {
|
|
436
|
+
global?: boolean;
|
|
437
|
+
agent?: string[];
|
|
438
|
+
yes?: boolean;
|
|
439
|
+
skill?: string[];
|
|
440
|
+
list?: boolean;
|
|
441
|
+
all?: boolean;
|
|
442
|
+
fullDepth?: boolean;
|
|
443
|
+
copy?: boolean;
|
|
444
|
+
dangerouslyAcceptOpenclawRisks?: boolean;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Handle skills from a well-known endpoint (RFC 8615).
|
|
449
|
+
* Discovers skills from /.well-known/agent-skills/index.json (preferred)
|
|
450
|
+
* or /.well-known/skills/index.json (legacy fallback).
|
|
451
|
+
*/
|
|
452
|
+
async function handleWellKnownSkills(
|
|
453
|
+
source: string,
|
|
454
|
+
url: string,
|
|
455
|
+
options: AddOptions,
|
|
456
|
+
spinner: ReturnType<typeof p.spinner>
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
spinner.start('Discovering skills from well-known endpoint...');
|
|
459
|
+
|
|
460
|
+
// Fetch all skills from the well-known endpoint
|
|
461
|
+
const skills = await wellKnownProvider.fetchAllSkills(url);
|
|
462
|
+
|
|
463
|
+
if (skills.length === 0) {
|
|
464
|
+
spinner.stop(pc.red('No skills found'));
|
|
465
|
+
p.outro(
|
|
466
|
+
pc.red(
|
|
467
|
+
'No skills found at this URL. Make sure the server has a /.well-known/agent-skills/index.json or /.well-known/skills/index.json file.'
|
|
468
|
+
)
|
|
469
|
+
);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
|
|
474
|
+
|
|
475
|
+
// Log discovered skills
|
|
476
|
+
for (const skill of skills) {
|
|
477
|
+
p.log.info(`Skill: ${pc.cyan(skill.installName)}`);
|
|
478
|
+
p.log.message(pc.dim(skill.description));
|
|
479
|
+
if (skill.files.size > 1) {
|
|
480
|
+
p.log.message(pc.dim(` Files: ${Array.from(skill.files.keys()).join(', ')}`));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (options.list) {
|
|
485
|
+
console.log();
|
|
486
|
+
p.log.step(pc.bold('Available Skills'));
|
|
487
|
+
for (const skill of skills) {
|
|
488
|
+
p.log.message(` ${pc.cyan(skill.installName)}`);
|
|
489
|
+
p.log.message(` ${pc.dim(skill.description)}`);
|
|
490
|
+
if (skill.files.size > 1) {
|
|
491
|
+
p.log.message(` ${pc.dim(`Files: ${skill.files.size}`)}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
console.log();
|
|
495
|
+
p.outro('Run without --list to install');
|
|
496
|
+
process.exit(0);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Filter skills if --skill option is provided
|
|
500
|
+
let selectedSkills: WellKnownSkill[];
|
|
501
|
+
|
|
502
|
+
if (options.skill?.includes('*')) {
|
|
503
|
+
// --skill '*' selects all skills
|
|
504
|
+
selectedSkills = skills;
|
|
505
|
+
p.log.info(`Installing all ${skills.length} skills`);
|
|
506
|
+
} else if (options.skill && options.skill.length > 0) {
|
|
507
|
+
selectedSkills = skills.filter((s) =>
|
|
508
|
+
options.skill!.some(
|
|
509
|
+
(name) =>
|
|
510
|
+
s.installName.toLowerCase() === name.toLowerCase() ||
|
|
511
|
+
s.name.toLowerCase() === name.toLowerCase()
|
|
512
|
+
)
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
if (selectedSkills.length === 0) {
|
|
516
|
+
p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);
|
|
517
|
+
p.log.info('Available skills:');
|
|
518
|
+
for (const s of skills) {
|
|
519
|
+
p.log.message(` - ${s.installName}`);
|
|
520
|
+
}
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
} else if (skills.length === 1) {
|
|
524
|
+
selectedSkills = skills;
|
|
525
|
+
const firstSkill = skills[0]!;
|
|
526
|
+
p.log.info(`Skill: ${pc.cyan(firstSkill.installName)}`);
|
|
527
|
+
} else if (options.yes) {
|
|
528
|
+
selectedSkills = skills;
|
|
529
|
+
p.log.info(`Installing all ${skills.length} skills`);
|
|
530
|
+
} else {
|
|
531
|
+
// Prompt user to select skills
|
|
532
|
+
const skillChoices = skills.map((s) => ({
|
|
533
|
+
value: s,
|
|
534
|
+
label: s.installName,
|
|
535
|
+
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
const selected = await multiselect({
|
|
539
|
+
message: 'Select skills to install',
|
|
540
|
+
options: skillChoices,
|
|
541
|
+
required: true,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (p.isCancel(selected)) {
|
|
545
|
+
p.cancel('Installation cancelled');
|
|
546
|
+
process.exit(0);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
selectedSkills = selected as WellKnownSkill[];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Detect agents
|
|
553
|
+
let targetAgents: AgentType[];
|
|
554
|
+
const validAgents = Object.keys(agents);
|
|
555
|
+
|
|
556
|
+
if (options.agent?.includes('*')) {
|
|
557
|
+
// --agent '*' selects all agents
|
|
558
|
+
targetAgents = validAgents as AgentType[];
|
|
559
|
+
p.log.info(`Installing to all ${targetAgents.length} agents`);
|
|
560
|
+
} else if (options.agent && options.agent.length > 0) {
|
|
561
|
+
const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
|
|
562
|
+
|
|
563
|
+
if (invalidAgents.length > 0) {
|
|
564
|
+
p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);
|
|
565
|
+
p.log.info(`Valid agents: ${validAgents.join(', ')}`);
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
targetAgents = options.agent as AgentType[];
|
|
570
|
+
} else {
|
|
571
|
+
spinner.start('Loading agents...');
|
|
572
|
+
const installedAgents = await detectInstalledAgents();
|
|
573
|
+
const totalAgents = Object.keys(agents).length;
|
|
574
|
+
spinner.stop(`${totalAgents} agents`);
|
|
575
|
+
|
|
576
|
+
if (installedAgents.length === 0) {
|
|
577
|
+
if (options.yes) {
|
|
578
|
+
targetAgents = validAgents as AgentType[];
|
|
579
|
+
p.log.info('Installing to all agents');
|
|
580
|
+
} else {
|
|
581
|
+
p.log.info('Select agents to install skills to');
|
|
582
|
+
|
|
583
|
+
const allAgentChoices = Object.entries(agents).map(([key, config]) => ({
|
|
584
|
+
value: key as AgentType,
|
|
585
|
+
label: config.displayName,
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
// Use helper to prompt with search
|
|
589
|
+
const selected = await promptForAgents(
|
|
590
|
+
'Which agents do you want to install to?',
|
|
591
|
+
allAgentChoices
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
if (p.isCancel(selected)) {
|
|
595
|
+
p.cancel('Installation cancelled');
|
|
596
|
+
process.exit(0);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
targetAgents = selected as AgentType[];
|
|
600
|
+
}
|
|
601
|
+
} else if (installedAgents.length === 1 || options.yes) {
|
|
602
|
+
// Auto-select detected agents + ensure universal agents are included
|
|
603
|
+
targetAgents = ensureUniversalAgents(installedAgents);
|
|
604
|
+
if (installedAgents.length === 1) {
|
|
605
|
+
const firstAgent = installedAgents[0]!;
|
|
606
|
+
p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);
|
|
607
|
+
} else {
|
|
608
|
+
p.log.info(
|
|
609
|
+
`Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
const selected = await selectAgentsInteractive({ global: options.global });
|
|
614
|
+
|
|
615
|
+
if (p.isCancel(selected)) {
|
|
616
|
+
p.cancel('Installation cancelled');
|
|
617
|
+
process.exit(0);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
targetAgents = selected as AgentType[];
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let installGlobally = options.global ?? false;
|
|
625
|
+
|
|
626
|
+
// Check if any selected agents support global installation
|
|
627
|
+
const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);
|
|
628
|
+
|
|
629
|
+
if (options.global === undefined && !options.yes && supportsGlobal) {
|
|
630
|
+
const scope = await p.select({
|
|
631
|
+
message: 'Installation scope',
|
|
632
|
+
options: [
|
|
633
|
+
{
|
|
634
|
+
value: false,
|
|
635
|
+
label: 'Project',
|
|
636
|
+
hint: 'Install in current directory (committed with your project)',
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
value: true,
|
|
640
|
+
label: 'Global',
|
|
641
|
+
hint: 'Install in home directory (available across all projects)',
|
|
642
|
+
},
|
|
643
|
+
],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (p.isCancel(scope)) {
|
|
647
|
+
p.cancel('Installation cancelled');
|
|
648
|
+
process.exit(0);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
installGlobally = scope as boolean;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Determine install mode (symlink vs copy)
|
|
655
|
+
let installMode: InstallMode = options.copy ? 'copy' : 'symlink';
|
|
656
|
+
|
|
657
|
+
// Only prompt for install mode when there are multiple unique target directories.
|
|
658
|
+
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
|
|
659
|
+
const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));
|
|
660
|
+
|
|
661
|
+
if (!options.copy && !options.yes && uniqueDirs.size > 1) {
|
|
662
|
+
const modeChoice = await p.select({
|
|
663
|
+
message: 'Installation method',
|
|
664
|
+
options: [
|
|
665
|
+
{
|
|
666
|
+
value: 'symlink',
|
|
667
|
+
label: 'Symlink (Recommended)',
|
|
668
|
+
hint: 'Single source of truth, easy updates',
|
|
669
|
+
},
|
|
670
|
+
{ value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },
|
|
671
|
+
],
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (p.isCancel(modeChoice)) {
|
|
675
|
+
p.cancel('Installation cancelled');
|
|
676
|
+
process.exit(0);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
installMode = modeChoice as InstallMode;
|
|
680
|
+
} else if (uniqueDirs.size <= 1) {
|
|
681
|
+
// Single target directory — default to copy (no symlink needed)
|
|
682
|
+
installMode = 'copy';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const cwd = process.cwd();
|
|
686
|
+
|
|
687
|
+
// Build installation summary
|
|
688
|
+
const summaryLines: string[] = [];
|
|
689
|
+
const agentNames = targetAgents.map((a) => agents[a].displayName);
|
|
690
|
+
|
|
691
|
+
// Check if any skill will be overwritten (parallel)
|
|
692
|
+
const overwriteChecks = await Promise.all(
|
|
693
|
+
selectedSkills.flatMap((skill) =>
|
|
694
|
+
targetAgents.map(async (agent) => ({
|
|
695
|
+
skillName: skill.installName,
|
|
696
|
+
agent,
|
|
697
|
+
installed: await isSkillInstalled(skill.installName, agent, { global: installGlobally }),
|
|
698
|
+
}))
|
|
699
|
+
)
|
|
700
|
+
);
|
|
701
|
+
const overwriteStatus = new Map<string, Map<string, boolean>>();
|
|
702
|
+
for (const { skillName, agent, installed } of overwriteChecks) {
|
|
703
|
+
if (!overwriteStatus.has(skillName)) {
|
|
704
|
+
overwriteStatus.set(skillName, new Map());
|
|
705
|
+
}
|
|
706
|
+
overwriteStatus.get(skillName)!.set(agent, installed);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
for (const skill of selectedSkills) {
|
|
710
|
+
if (summaryLines.length > 0) summaryLines.push('');
|
|
711
|
+
|
|
712
|
+
const canonicalPath = getCanonicalPath(skill.installName, { global: installGlobally });
|
|
713
|
+
const shortCanonical = shortenPath(canonicalPath, cwd);
|
|
714
|
+
summaryLines.push(`${pc.cyan(shortCanonical)}`);
|
|
715
|
+
summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));
|
|
716
|
+
if (skill.files.size > 1) {
|
|
717
|
+
summaryLines.push(` ${pc.dim('files:')} ${skill.files.size}`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const skillOverwrites = overwriteStatus.get(skill.installName);
|
|
721
|
+
const overwriteAgents = targetAgents
|
|
722
|
+
.filter((a) => skillOverwrites?.get(a))
|
|
723
|
+
.map((a) => agents[a].displayName);
|
|
724
|
+
|
|
725
|
+
if (overwriteAgents.length > 0) {
|
|
726
|
+
summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
console.log();
|
|
731
|
+
p.note(summaryLines.join('\n'), 'Installation Summary');
|
|
732
|
+
|
|
733
|
+
if (!options.yes) {
|
|
734
|
+
const confirmed = await p.confirm({ message: 'Proceed with installation?' });
|
|
735
|
+
|
|
736
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
737
|
+
p.cancel('Installation cancelled');
|
|
738
|
+
process.exit(0);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Kick off privacy check early so it runs in parallel with installation
|
|
743
|
+
const sourceIdentifier = wellKnownProvider.getSourceIdentifier(url);
|
|
744
|
+
const wellKnownPrivacyPromise = isSourcePrivate(sourceIdentifier).catch(() => null);
|
|
745
|
+
|
|
746
|
+
spinner.start('Installing skills...');
|
|
747
|
+
|
|
748
|
+
const results: {
|
|
749
|
+
skill: string;
|
|
750
|
+
agent: string;
|
|
751
|
+
success: boolean;
|
|
752
|
+
path: string;
|
|
753
|
+
canonicalPath?: string;
|
|
754
|
+
mode: InstallMode;
|
|
755
|
+
symlinkFailed?: boolean;
|
|
756
|
+
error?: string;
|
|
757
|
+
}[] = [];
|
|
758
|
+
|
|
759
|
+
for (const skill of selectedSkills) {
|
|
760
|
+
for (const agent of targetAgents) {
|
|
761
|
+
const result = await installWellKnownSkillForAgent(skill, agent, {
|
|
762
|
+
global: installGlobally,
|
|
763
|
+
mode: installMode,
|
|
764
|
+
});
|
|
765
|
+
results.push({
|
|
766
|
+
skill: skill.installName,
|
|
767
|
+
agent: agents[agent].displayName,
|
|
768
|
+
...result,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
spinner.stop('Installation complete');
|
|
774
|
+
|
|
775
|
+
console.log();
|
|
776
|
+
const successful = results.filter((r) => r.success);
|
|
777
|
+
const failed = results.filter((r) => !r.success);
|
|
778
|
+
|
|
779
|
+
// Build skillFiles map: { skillName: sourceUrl }
|
|
780
|
+
const skillFiles: Record<string, string> = {};
|
|
781
|
+
for (const skill of selectedSkills) {
|
|
782
|
+
skillFiles[skill.installName] = skill.sourceUrl;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Privacy promise was started before installation — should be resolved by now
|
|
786
|
+
const isPrivate = await wellKnownPrivacyPromise;
|
|
787
|
+
if (isPrivate !== true) {
|
|
788
|
+
track({
|
|
789
|
+
event: 'install',
|
|
790
|
+
source: sourceIdentifier,
|
|
791
|
+
skills: selectedSkills.map((s) => s.installName).join(','),
|
|
792
|
+
agents: targetAgents.join(','),
|
|
793
|
+
...(installGlobally && { global: '1' }),
|
|
794
|
+
skillFiles: JSON.stringify(skillFiles),
|
|
795
|
+
sourceType: 'well-known',
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Add to skill lock file for update tracking (only for global installs)
|
|
800
|
+
if (successful.length > 0 && installGlobally) {
|
|
801
|
+
const successfulSkillNames = new Set(successful.map((r) => r.skill));
|
|
802
|
+
for (const skill of selectedSkills) {
|
|
803
|
+
if (successfulSkillNames.has(skill.installName)) {
|
|
804
|
+
try {
|
|
805
|
+
await addSkillToLock(skill.installName, {
|
|
806
|
+
source: sourceIdentifier,
|
|
807
|
+
sourceType: 'well-known',
|
|
808
|
+
sourceUrl: skill.sourceUrl,
|
|
809
|
+
skillFolderHash: '', // Well-known skills don't have a folder hash
|
|
810
|
+
});
|
|
811
|
+
} catch {
|
|
812
|
+
// Don't fail installation if lock file update fails
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Add to local lock file for project-scoped installs
|
|
819
|
+
if (successful.length > 0 && !installGlobally) {
|
|
820
|
+
const successfulSkillNames = new Set(successful.map((r) => r.skill));
|
|
821
|
+
for (const skill of selectedSkills) {
|
|
822
|
+
if (successfulSkillNames.has(skill.installName)) {
|
|
823
|
+
try {
|
|
824
|
+
const matchingResult = successful.find((r) => r.skill === skill.installName);
|
|
825
|
+
const installDir = matchingResult?.canonicalPath || matchingResult?.path;
|
|
826
|
+
if (installDir) {
|
|
827
|
+
const computedHash = await computeSkillFolderHash(installDir);
|
|
828
|
+
await addSkillToLocalLock(
|
|
829
|
+
skill.installName,
|
|
830
|
+
{
|
|
831
|
+
source: sourceIdentifier,
|
|
832
|
+
sourceType: 'well-known',
|
|
833
|
+
computedHash,
|
|
834
|
+
},
|
|
835
|
+
cwd
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
} catch {
|
|
839
|
+
// Don't fail installation if lock file update fails
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (successful.length > 0) {
|
|
846
|
+
const bySkill = new Map<string, typeof results>();
|
|
847
|
+
for (const r of successful) {
|
|
848
|
+
const skillResults = bySkill.get(r.skill) || [];
|
|
849
|
+
skillResults.push(r);
|
|
850
|
+
bySkill.set(r.skill, skillResults);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const skillCount = bySkill.size;
|
|
854
|
+
const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);
|
|
855
|
+
const copiedAgents = symlinkFailures.map((r) => r.agent);
|
|
856
|
+
const resultLines: string[] = [];
|
|
857
|
+
|
|
858
|
+
for (const [skillName, skillResults] of bySkill) {
|
|
859
|
+
const firstResult = skillResults[0]!;
|
|
860
|
+
|
|
861
|
+
if (firstResult.mode === 'copy') {
|
|
862
|
+
// Copy mode: show skill name and list all agent paths
|
|
863
|
+
resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`);
|
|
864
|
+
for (const r of skillResults) {
|
|
865
|
+
const shortPath = shortenPath(r.path, cwd);
|
|
866
|
+
resultLines.push(` ${pc.dim('→')} ${shortPath}`);
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
// Symlink mode: show canonical path and universal/symlinked agents
|
|
870
|
+
if (firstResult.canonicalPath) {
|
|
871
|
+
const shortPath = shortenPath(firstResult.canonicalPath, cwd);
|
|
872
|
+
resultLines.push(`${pc.green('✓')} ${shortPath}`);
|
|
873
|
+
} else {
|
|
874
|
+
resultLines.push(`${pc.green('✓')} ${skillName}`);
|
|
875
|
+
}
|
|
876
|
+
resultLines.push(...buildResultLines(skillResults, targetAgents));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);
|
|
881
|
+
p.note(resultLines.join('\n'), title);
|
|
882
|
+
|
|
883
|
+
// Show symlink failure warning (only for symlink mode)
|
|
884
|
+
if (symlinkFailures.length > 0) {
|
|
885
|
+
p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));
|
|
886
|
+
p.log.message(
|
|
887
|
+
pc.dim(
|
|
888
|
+
' Files were copied instead. On Windows, enable Developer Mode for symlink support.'
|
|
889
|
+
)
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (failed.length > 0) {
|
|
895
|
+
console.log();
|
|
896
|
+
p.log.error(pc.red(`Failed to install ${failed.length}`));
|
|
897
|
+
for (const r of failed) {
|
|
898
|
+
p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
console.log();
|
|
903
|
+
p.outro(
|
|
904
|
+
pc.green('Done!') + pc.dim(' Review skills before use; they run with full agent permissions.')
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
// Prompt for find-skills after successful install
|
|
908
|
+
await promptForFindSkills(options, targetAgents);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export async function runAdd(args: string[], options: AddOptions = {}): Promise<void> {
|
|
912
|
+
const source = args[0];
|
|
913
|
+
let installTipShown = false;
|
|
914
|
+
|
|
915
|
+
const showInstallTip = (): void => {
|
|
916
|
+
if (installTipShown) return;
|
|
917
|
+
p.log.message(
|
|
918
|
+
pc.dim('Tip: use the --yes (-y) and --global (-g) flags to install without prompts.')
|
|
919
|
+
);
|
|
920
|
+
installTipShown = true;
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
if (!source) {
|
|
924
|
+
console.log();
|
|
925
|
+
console.log(
|
|
926
|
+
pc.bgRed(pc.white(pc.bold(' ERROR '))) + ' ' + pc.red('Missing required argument: source')
|
|
927
|
+
);
|
|
928
|
+
console.log();
|
|
929
|
+
console.log(pc.dim(' Usage:'));
|
|
930
|
+
console.log(` ${pc.cyan('npx skills add')} ${pc.yellow('<source>')} ${pc.dim('[options]')}`);
|
|
931
|
+
console.log();
|
|
932
|
+
console.log(pc.dim(' Example:'));
|
|
933
|
+
console.log(` ${pc.cyan('npx skills add')} ${pc.yellow('vercel-labs/agent-skills')}`);
|
|
934
|
+
console.log();
|
|
935
|
+
process.exit(1);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// --all implies --skill '*' and --agent '*' and -y
|
|
939
|
+
if (options.all) {
|
|
940
|
+
options.skill = ['*'];
|
|
941
|
+
options.agent = ['*'];
|
|
942
|
+
options.yes = true;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Auto-enable non-interactive mode when running inside an AI agent
|
|
946
|
+
const agentResult = await detectAgent();
|
|
947
|
+
if (agentResult.isAgent) {
|
|
948
|
+
options.yes = true;
|
|
949
|
+
// Auto-select the detected agent + universal agents (unless user explicitly specified agents)
|
|
950
|
+
if (!options.agent || options.agent.length === 0) {
|
|
951
|
+
const mappedAgent = getAgentType(agentResult.agent.name);
|
|
952
|
+
if (mappedAgent) {
|
|
953
|
+
options.agent = ensureUniversalAgents([mappedAgent]);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
console.log();
|
|
959
|
+
if (!agentResult.isAgent) {
|
|
960
|
+
p.intro(pc.bgCyan(pc.black(' skills ')));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (agentResult.isAgent) {
|
|
964
|
+
p.log.info(
|
|
965
|
+
pc.bgCyan(pc.black(pc.bold(` ${agentResult.agent.name} `))) +
|
|
966
|
+
' ' +
|
|
967
|
+
'Agent detected — installing non-interactively'
|
|
968
|
+
);
|
|
969
|
+
} else if (!process.stdin.isTTY) {
|
|
970
|
+
showInstallTip();
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let tempDir: string | null = null;
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const spinner = p.spinner();
|
|
977
|
+
|
|
978
|
+
spinner.start('Parsing source...');
|
|
979
|
+
const parsed = parseSource(source);
|
|
980
|
+
spinner.stop(
|
|
981
|
+
`Source: ${parsed.type === 'local' ? parsed.localPath! : parsed.url}${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}${parsed.subpath ? ` (${parsed.subpath})` : ''}${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}`
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
// Kick off the repo privacy check early so it runs in parallel with
|
|
985
|
+
// cloning/discovering/installing. The result is only needed later for
|
|
986
|
+
// telemetry gating — it should never block user-visible output.
|
|
987
|
+
const ownerRepoRaw = getOwnerRepo(parsed);
|
|
988
|
+
const repoPrivacyPromise: Promise<boolean | null> = (() => {
|
|
989
|
+
if (!ownerRepoRaw) return Promise.resolve(null);
|
|
990
|
+
const ownerRepo = parseOwnerRepo(ownerRepoRaw);
|
|
991
|
+
if (!ownerRepo) return Promise.resolve(null);
|
|
992
|
+
return isRepoPrivate(ownerRepo.owner, ownerRepo.repo).catch(() => null);
|
|
993
|
+
})();
|
|
994
|
+
|
|
995
|
+
// Block openclaw sources unless explicitly opted in
|
|
996
|
+
const sourceOwner = ownerRepoRaw?.split('/')[0]?.toLowerCase();
|
|
997
|
+
if (sourceOwner === 'openclaw' && !options.dangerouslyAcceptOpenclawRisks) {
|
|
998
|
+
console.log();
|
|
999
|
+
p.log.warn(pc.yellow(pc.bold('⚠ OpenClaw skills are unverified community submissions.')));
|
|
1000
|
+
p.log.message(
|
|
1001
|
+
pc.yellow(
|
|
1002
|
+
'This source contains user-submitted skills that have not been reviewed for safety or quality.'
|
|
1003
|
+
)
|
|
1004
|
+
);
|
|
1005
|
+
p.log.message(pc.yellow('Skills run with full agent permissions and could be malicious.'));
|
|
1006
|
+
console.log();
|
|
1007
|
+
p.log.message(
|
|
1008
|
+
`If you understand the risks, re-run with:\n\n ${pc.cyan(`npx skills add ${source} --dangerously-accept-openclaw-risks`)}\n`
|
|
1009
|
+
);
|
|
1010
|
+
p.outro(pc.red('Installation blocked'));
|
|
1011
|
+
process.exit(1);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Handle well-known skills from arbitrary URLs
|
|
1015
|
+
if (parsed.type === 'well-known') {
|
|
1016
|
+
await handleWellKnownSkills(source, parsed.url, options, spinner);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// If skillFilter is present from @skill syntax (e.g., owner/repo@skill-name),
|
|
1021
|
+
// merge it into options.skill
|
|
1022
|
+
if (parsed.skillFilter) {
|
|
1023
|
+
options.skill = options.skill || [];
|
|
1024
|
+
if (!options.skill.includes(parsed.skillFilter)) {
|
|
1025
|
+
options.skill.push(parsed.skillFilter);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Include internal skills when a specific skill is explicitly requested
|
|
1030
|
+
// (via --skill or @skill syntax)
|
|
1031
|
+
const includeInternal = !!(options.skill && options.skill.length > 0);
|
|
1032
|
+
|
|
1033
|
+
let skills: Skill[];
|
|
1034
|
+
let blobResult: BlobInstallResult | null = null;
|
|
1035
|
+
|
|
1036
|
+
if (parsed.type === 'local') {
|
|
1037
|
+
// Use local path directly, no cloning needed
|
|
1038
|
+
spinner.start('Validating local path...');
|
|
1039
|
+
if (!existsSync(parsed.localPath!)) {
|
|
1040
|
+
spinner.stop(pc.red('Path not found'));
|
|
1041
|
+
p.outro(pc.red(`Local path does not exist: ${parsed.localPath}`));
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
spinner.stop('Local path validated');
|
|
1045
|
+
|
|
1046
|
+
spinner.start('Discovering skills...');
|
|
1047
|
+
skills = await discoverSkills(parsed.localPath!, parsed.subpath, {
|
|
1048
|
+
includeInternal,
|
|
1049
|
+
fullDepth: options.fullDepth,
|
|
1050
|
+
});
|
|
1051
|
+
} else if (parsed.type === 'github' && !options.fullDepth) {
|
|
1052
|
+
// Try the blob-based fast install for GitHub sources; skip for --full-depth.
|
|
1053
|
+
// Eligible per repo (a BLOB_ALLOWED_REPOS entry = self-hosted download URL) or
|
|
1054
|
+
// per owner (BLOB_ALLOWED_OWNERS = all their repos, skills.sh-hosted).
|
|
1055
|
+
const BLOB_ALLOWED_OWNERS = ['vercel', 'vercel-labs', 'heygen-com'];
|
|
1056
|
+
const ownerRepo = getOwnerRepo(parsed);
|
|
1057
|
+
const owner = ownerRepo?.split('/')[0]?.toLowerCase();
|
|
1058
|
+
const isSelfHostedRepo =
|
|
1059
|
+
!!ownerRepo && Object.hasOwn(BLOB_ALLOWED_REPOS, ownerRepo.toLowerCase());
|
|
1060
|
+
if (ownerRepo && owner && (isSelfHostedRepo || BLOB_ALLOWED_OWNERS.includes(owner))) {
|
|
1061
|
+
spinner.start('Fetching skills...');
|
|
1062
|
+
blobResult = await tryBlobInstall(ownerRepo, {
|
|
1063
|
+
subpath: parsed.subpath,
|
|
1064
|
+
skillFilter: parsed.skillFilter,
|
|
1065
|
+
ref: parsed.ref,
|
|
1066
|
+
getToken: getGitHubToken,
|
|
1067
|
+
includeInternal,
|
|
1068
|
+
});
|
|
1069
|
+
if (!blobResult) {
|
|
1070
|
+
spinner.stop(pc.dim('Falling back to clone...'));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (blobResult) {
|
|
1075
|
+
skills = blobResult.skills;
|
|
1076
|
+
spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
|
|
1077
|
+
} else {
|
|
1078
|
+
// Blob failed — fall back to git clone
|
|
1079
|
+
spinner.start('Cloning repository...');
|
|
1080
|
+
tempDir = await cloneRepo(parsed.url, parsed.ref);
|
|
1081
|
+
spinner.stop('Repository cloned');
|
|
1082
|
+
|
|
1083
|
+
spinner.start('Discovering skills...');
|
|
1084
|
+
skills = await discoverSkills(tempDir, parsed.subpath, {
|
|
1085
|
+
includeInternal,
|
|
1086
|
+
fullDepth: options.fullDepth,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
} else {
|
|
1090
|
+
// GitLab, git URL, or --full-depth: always clone
|
|
1091
|
+
spinner.start('Cloning repository...');
|
|
1092
|
+
tempDir = await cloneRepo(parsed.url, parsed.ref);
|
|
1093
|
+
spinner.stop('Repository cloned');
|
|
1094
|
+
|
|
1095
|
+
spinner.start('Discovering skills...');
|
|
1096
|
+
skills = await discoverSkills(tempDir, parsed.subpath, {
|
|
1097
|
+
includeInternal,
|
|
1098
|
+
fullDepth: options.fullDepth,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (skills.length === 0) {
|
|
1103
|
+
spinner.stop(pc.red('No skills found'));
|
|
1104
|
+
p.outro(
|
|
1105
|
+
pc.red('No valid skills found. Skills require a SKILL.md with name and description.')
|
|
1106
|
+
);
|
|
1107
|
+
await cleanup(tempDir);
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (!blobResult) {
|
|
1112
|
+
spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (options.list) {
|
|
1116
|
+
console.log();
|
|
1117
|
+
p.log.step(pc.bold('Available Skills'));
|
|
1118
|
+
|
|
1119
|
+
// Group available skills by plugin for list output
|
|
1120
|
+
const groupedSkills: Record<string, Skill[]> = {};
|
|
1121
|
+
const ungroupedSkills: Skill[] = [];
|
|
1122
|
+
|
|
1123
|
+
for (const skill of skills) {
|
|
1124
|
+
if (skill.pluginName) {
|
|
1125
|
+
const group = skill.pluginName;
|
|
1126
|
+
if (!groupedSkills[group]) groupedSkills[group] = [];
|
|
1127
|
+
groupedSkills[group].push(skill);
|
|
1128
|
+
} else {
|
|
1129
|
+
ungroupedSkills.push(skill);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Print groups
|
|
1134
|
+
const sortedGroups = Object.keys(groupedSkills).sort();
|
|
1135
|
+
for (const group of sortedGroups) {
|
|
1136
|
+
// Convert kebab-case to Title Case for display header
|
|
1137
|
+
const title = group
|
|
1138
|
+
.split('-')
|
|
1139
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1140
|
+
.join(' ');
|
|
1141
|
+
|
|
1142
|
+
console.log(pc.bold(title));
|
|
1143
|
+
for (const skill of groupedSkills[group]!) {
|
|
1144
|
+
p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
|
|
1145
|
+
p.log.message(` ${pc.dim(skill.description)}`);
|
|
1146
|
+
}
|
|
1147
|
+
console.log();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Print ungrouped
|
|
1151
|
+
if (ungroupedSkills.length > 0) {
|
|
1152
|
+
if (sortedGroups.length > 0) console.log(pc.bold('General'));
|
|
1153
|
+
for (const skill of ungroupedSkills) {
|
|
1154
|
+
p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
|
|
1155
|
+
p.log.message(` ${pc.dim(skill.description)}`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
console.log();
|
|
1160
|
+
p.outro('Use --skill <name> to install specific skills');
|
|
1161
|
+
await cleanup(tempDir);
|
|
1162
|
+
process.exit(0);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
let selectedSkills: Skill[];
|
|
1166
|
+
|
|
1167
|
+
if (options.skill?.includes('*')) {
|
|
1168
|
+
// --skill '*' selects all skills
|
|
1169
|
+
selectedSkills = skills;
|
|
1170
|
+
p.log.info(`Installing all ${skills.length} skills`);
|
|
1171
|
+
} else if (options.skill && options.skill.length > 0) {
|
|
1172
|
+
selectedSkills = filterSkills(skills, options.skill);
|
|
1173
|
+
|
|
1174
|
+
if (selectedSkills.length === 0) {
|
|
1175
|
+
p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);
|
|
1176
|
+
p.log.info('Available skills:');
|
|
1177
|
+
for (const s of skills) {
|
|
1178
|
+
p.log.message(` - ${getSkillDisplayName(s)}`);
|
|
1179
|
+
}
|
|
1180
|
+
await cleanup(tempDir);
|
|
1181
|
+
process.exit(1);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
p.log.info(
|
|
1185
|
+
`Selected ${selectedSkills.length} skill${selectedSkills.length !== 1 ? 's' : ''}: ${selectedSkills.map((s) => pc.cyan(getSkillDisplayName(s))).join(', ')}`
|
|
1186
|
+
);
|
|
1187
|
+
} else if (skills.length === 1) {
|
|
1188
|
+
selectedSkills = skills;
|
|
1189
|
+
const firstSkill = skills[0]!;
|
|
1190
|
+
p.log.info(`Skill: ${pc.cyan(getSkillDisplayName(firstSkill))}`);
|
|
1191
|
+
p.log.message(pc.dim(firstSkill.description));
|
|
1192
|
+
} else if (options.yes) {
|
|
1193
|
+
selectedSkills = skills;
|
|
1194
|
+
p.log.info(`Installing all ${skills.length} skills`);
|
|
1195
|
+
} else {
|
|
1196
|
+
// Sort skills by plugin name first, then by skill name
|
|
1197
|
+
const sortedSkills = [...skills].sort((a, b) => {
|
|
1198
|
+
if (a.pluginName && !b.pluginName) return -1;
|
|
1199
|
+
if (!a.pluginName && b.pluginName) return 1;
|
|
1200
|
+
if (a.pluginName && b.pluginName && a.pluginName !== b.pluginName) {
|
|
1201
|
+
return a.pluginName.localeCompare(b.pluginName);
|
|
1202
|
+
}
|
|
1203
|
+
return getSkillDisplayName(a).localeCompare(getSkillDisplayName(b));
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// Check if any skills have plugin grouping
|
|
1207
|
+
const hasGroups = sortedSkills.some((s) => s.pluginName);
|
|
1208
|
+
|
|
1209
|
+
// Pre-check which skills are already installed (project .agents/skills/ or global ~/.agents/skills/)
|
|
1210
|
+
// Uses the 'universal' agent which maps to the ADG canonical paths for both scopes.
|
|
1211
|
+
const alreadyInstalledSet = new Set<string>();
|
|
1212
|
+
{
|
|
1213
|
+
const checks = await Promise.all(
|
|
1214
|
+
sortedSkills.map(async (s) => {
|
|
1215
|
+
const name = getSkillDisplayName(s);
|
|
1216
|
+
const [projectInstalled, globalInstalled] = await Promise.all([
|
|
1217
|
+
isSkillInstalled(name, 'universal', { global: false }),
|
|
1218
|
+
isSkillInstalled(name, 'universal', { global: true }),
|
|
1219
|
+
]);
|
|
1220
|
+
return { name, installed: projectInstalled || globalInstalled };
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
for (const { name, installed } of checks) {
|
|
1224
|
+
if (installed) alreadyInstalledSet.add(name);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const skillLabel = (s: Skill): string => {
|
|
1229
|
+
const name = getSkillDisplayName(s);
|
|
1230
|
+
const badge = alreadyInstalledSet.has(name) ? pc.dim('[↑] ') : ' ';
|
|
1231
|
+
return `${badge}${name}`;
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
let selected: Skill[] | symbol;
|
|
1235
|
+
|
|
1236
|
+
if (hasGroups) {
|
|
1237
|
+
// Build grouped options for groupMultiselect
|
|
1238
|
+
const kebabToTitle = (s: string) =>
|
|
1239
|
+
s
|
|
1240
|
+
.split('-')
|
|
1241
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1242
|
+
.join(' ');
|
|
1243
|
+
|
|
1244
|
+
const grouped: Record<string, p.Option<Skill>[]> = {};
|
|
1245
|
+
for (const s of sortedSkills) {
|
|
1246
|
+
const groupName = s.pluginName ? kebabToTitle(s.pluginName) : 'Other';
|
|
1247
|
+
if (!grouped[groupName]) grouped[groupName] = [];
|
|
1248
|
+
grouped[groupName]!.push({
|
|
1249
|
+
value: s,
|
|
1250
|
+
label: skillLabel(s),
|
|
1251
|
+
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
selected = await p.groupMultiselect({
|
|
1256
|
+
message: `Select skills to install ${pc.dim('(space to toggle)')}`,
|
|
1257
|
+
options: grouped,
|
|
1258
|
+
required: true,
|
|
1259
|
+
});
|
|
1260
|
+
} else {
|
|
1261
|
+
const skillChoices = sortedSkills.map((s) => ({
|
|
1262
|
+
value: s,
|
|
1263
|
+
label: skillLabel(s),
|
|
1264
|
+
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
|
|
1265
|
+
}));
|
|
1266
|
+
|
|
1267
|
+
selected = await multiselect({
|
|
1268
|
+
message: 'Select skills to install',
|
|
1269
|
+
options: skillChoices,
|
|
1270
|
+
required: true,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (p.isCancel(selected)) {
|
|
1275
|
+
p.cancel('Installation cancelled');
|
|
1276
|
+
await cleanup(tempDir);
|
|
1277
|
+
process.exit(0);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
selectedSkills = selected as Skill[];
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Kick off security audit fetch early (non-blocking) so it runs
|
|
1284
|
+
// in parallel with agent selection, scope, and mode prompts.
|
|
1285
|
+
const ownerRepoForAudit = getOwnerRepo(parsed);
|
|
1286
|
+
const auditPromise = ownerRepoForAudit
|
|
1287
|
+
? fetchAuditData(
|
|
1288
|
+
ownerRepoForAudit,
|
|
1289
|
+
selectedSkills.map((s) => getSkillDisplayName(s))
|
|
1290
|
+
)
|
|
1291
|
+
: Promise.resolve(null);
|
|
1292
|
+
|
|
1293
|
+
let targetAgents: AgentType[];
|
|
1294
|
+
const validAgents = Object.keys(agents);
|
|
1295
|
+
|
|
1296
|
+
if (options.agent?.includes('*')) {
|
|
1297
|
+
// --agent '*' selects all agents
|
|
1298
|
+
targetAgents = validAgents as AgentType[];
|
|
1299
|
+
p.log.info(`Installing to all ${targetAgents.length} agents`);
|
|
1300
|
+
} else if (options.agent && options.agent.length > 0) {
|
|
1301
|
+
const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));
|
|
1302
|
+
|
|
1303
|
+
if (invalidAgents.length > 0) {
|
|
1304
|
+
p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);
|
|
1305
|
+
p.log.info(`Valid agents: ${validAgents.join(', ')}`);
|
|
1306
|
+
await cleanup(tempDir);
|
|
1307
|
+
process.exit(1);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
targetAgents = options.agent as AgentType[];
|
|
1311
|
+
} else {
|
|
1312
|
+
spinner.start('Loading agents...');
|
|
1313
|
+
const installedAgents = await detectInstalledAgents();
|
|
1314
|
+
const totalAgents = Object.keys(agents).length;
|
|
1315
|
+
spinner.stop(`${totalAgents} agents`);
|
|
1316
|
+
|
|
1317
|
+
if (installedAgents.length === 0) {
|
|
1318
|
+
if (options.yes) {
|
|
1319
|
+
targetAgents = validAgents as AgentType[];
|
|
1320
|
+
p.log.info('Installing to all agents');
|
|
1321
|
+
} else {
|
|
1322
|
+
p.log.info('Select agents to install skills to');
|
|
1323
|
+
|
|
1324
|
+
const allAgentChoices = Object.entries(agents).map(([key, config]) => ({
|
|
1325
|
+
value: key as AgentType,
|
|
1326
|
+
label: config.displayName,
|
|
1327
|
+
}));
|
|
1328
|
+
|
|
1329
|
+
// Use helper to prompt with search
|
|
1330
|
+
const selected = await promptForAgents(
|
|
1331
|
+
'Which agents do you want to install to?',
|
|
1332
|
+
allAgentChoices
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
if (p.isCancel(selected)) {
|
|
1336
|
+
p.cancel('Installation cancelled');
|
|
1337
|
+
await cleanup(tempDir);
|
|
1338
|
+
process.exit(0);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
targetAgents = selected as AgentType[];
|
|
1342
|
+
}
|
|
1343
|
+
} else if (installedAgents.length === 1 || options.yes) {
|
|
1344
|
+
// Auto-select detected agents + ensure universal agents are included
|
|
1345
|
+
targetAgents = ensureUniversalAgents(installedAgents);
|
|
1346
|
+
if (installedAgents.length === 1) {
|
|
1347
|
+
const firstAgent = installedAgents[0]!;
|
|
1348
|
+
p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);
|
|
1349
|
+
} else {
|
|
1350
|
+
p.log.info(
|
|
1351
|
+
`Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
} else {
|
|
1355
|
+
const selected = await selectAgentsInteractive({ global: options.global });
|
|
1356
|
+
|
|
1357
|
+
if (p.isCancel(selected)) {
|
|
1358
|
+
p.cancel('Installation cancelled');
|
|
1359
|
+
await cleanup(tempDir);
|
|
1360
|
+
process.exit(0);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
targetAgents = selected as AgentType[];
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
let installGlobally = options.global ?? false;
|
|
1368
|
+
|
|
1369
|
+
// Check if any selected agents support global installation
|
|
1370
|
+
const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);
|
|
1371
|
+
|
|
1372
|
+
if (options.global === undefined && !options.yes && supportsGlobal) {
|
|
1373
|
+
const scope = await p.select({
|
|
1374
|
+
message: 'Installation scope',
|
|
1375
|
+
options: [
|
|
1376
|
+
{
|
|
1377
|
+
value: false,
|
|
1378
|
+
label: 'Project',
|
|
1379
|
+
hint: 'Install in current directory (committed with your project)',
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
value: true,
|
|
1383
|
+
label: 'Global',
|
|
1384
|
+
hint: 'Install in home directory (available across all projects)',
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
if (p.isCancel(scope)) {
|
|
1390
|
+
p.cancel('Installation cancelled');
|
|
1391
|
+
await cleanup(tempDir);
|
|
1392
|
+
process.exit(0);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
installGlobally = scope as boolean;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Determine install mode (symlink vs copy)
|
|
1399
|
+
let installMode: InstallMode = options.copy ? 'copy' : 'symlink';
|
|
1400
|
+
|
|
1401
|
+
// Only prompt for install mode when there are multiple unique target directories.
|
|
1402
|
+
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
|
|
1403
|
+
const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));
|
|
1404
|
+
|
|
1405
|
+
if (!options.copy && !options.yes && uniqueDirs.size > 1) {
|
|
1406
|
+
const modeChoice = await p.select({
|
|
1407
|
+
message: 'Installation method',
|
|
1408
|
+
options: [
|
|
1409
|
+
{
|
|
1410
|
+
value: 'symlink',
|
|
1411
|
+
label: 'Symlink (Recommended)',
|
|
1412
|
+
hint: 'Single source of truth, easy updates',
|
|
1413
|
+
},
|
|
1414
|
+
{ value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },
|
|
1415
|
+
],
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
if (p.isCancel(modeChoice)) {
|
|
1419
|
+
p.cancel('Installation cancelled');
|
|
1420
|
+
await cleanup(tempDir);
|
|
1421
|
+
process.exit(0);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
installMode = modeChoice as InstallMode;
|
|
1425
|
+
} else if (uniqueDirs.size <= 1) {
|
|
1426
|
+
// Single target directory — default to copy (no symlink needed)
|
|
1427
|
+
installMode = 'copy';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const cwd = process.cwd();
|
|
1431
|
+
|
|
1432
|
+
// Build installation summary
|
|
1433
|
+
const summaryLines: string[] = [];
|
|
1434
|
+
const agentNames = targetAgents.map((a) => agents[a].displayName);
|
|
1435
|
+
|
|
1436
|
+
// Check if any skill will be overwritten (parallel)
|
|
1437
|
+
const overwriteChecks = await Promise.all(
|
|
1438
|
+
selectedSkills.flatMap((skill) =>
|
|
1439
|
+
targetAgents.map(async (agent) => ({
|
|
1440
|
+
skillName: skill.name,
|
|
1441
|
+
agent,
|
|
1442
|
+
installed: await isSkillInstalled(skill.name, agent, { global: installGlobally }),
|
|
1443
|
+
}))
|
|
1444
|
+
)
|
|
1445
|
+
);
|
|
1446
|
+
const overwriteStatus = new Map<string, Map<string, boolean>>();
|
|
1447
|
+
for (const { skillName, agent, installed } of overwriteChecks) {
|
|
1448
|
+
if (!overwriteStatus.has(skillName)) {
|
|
1449
|
+
overwriteStatus.set(skillName, new Map());
|
|
1450
|
+
}
|
|
1451
|
+
overwriteStatus.get(skillName)!.set(agent, installed);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Group selected skills for summary
|
|
1455
|
+
const groupedSummary: Record<string, Skill[]> = {};
|
|
1456
|
+
const ungroupedSummary: Skill[] = [];
|
|
1457
|
+
|
|
1458
|
+
for (const skill of selectedSkills) {
|
|
1459
|
+
if (skill.pluginName) {
|
|
1460
|
+
const group = skill.pluginName;
|
|
1461
|
+
if (!groupedSummary[group]) groupedSummary[group] = [];
|
|
1462
|
+
groupedSummary[group].push(skill);
|
|
1463
|
+
} else {
|
|
1464
|
+
ungroupedSummary.push(skill);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Helper to print summary lines for a list of skills
|
|
1469
|
+
const printSkillSummary = (skills: Skill[]) => {
|
|
1470
|
+
for (const skill of skills) {
|
|
1471
|
+
if (summaryLines.length > 0) summaryLines.push('');
|
|
1472
|
+
|
|
1473
|
+
const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally });
|
|
1474
|
+
const shortCanonical = shortenPath(canonicalPath, cwd);
|
|
1475
|
+
summaryLines.push(`${pc.cyan(shortCanonical)}`);
|
|
1476
|
+
summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));
|
|
1477
|
+
|
|
1478
|
+
const skillOverwrites = overwriteStatus.get(skill.name);
|
|
1479
|
+
const overwriteAgents = targetAgents
|
|
1480
|
+
.filter((a) => skillOverwrites?.get(a))
|
|
1481
|
+
.map((a) => agents[a].displayName);
|
|
1482
|
+
|
|
1483
|
+
if (overwriteAgents.length > 0) {
|
|
1484
|
+
summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
// Build grouped summary
|
|
1490
|
+
const sortedGroups = Object.keys(groupedSummary).sort();
|
|
1491
|
+
|
|
1492
|
+
for (const group of sortedGroups) {
|
|
1493
|
+
const title = group
|
|
1494
|
+
.split('-')
|
|
1495
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1496
|
+
.join(' ');
|
|
1497
|
+
|
|
1498
|
+
summaryLines.push('');
|
|
1499
|
+
summaryLines.push(pc.bold(title));
|
|
1500
|
+
printSkillSummary(groupedSummary[group]!);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (ungroupedSummary.length > 0) {
|
|
1504
|
+
if (sortedGroups.length > 0) {
|
|
1505
|
+
summaryLines.push('');
|
|
1506
|
+
summaryLines.push(pc.bold('General'));
|
|
1507
|
+
}
|
|
1508
|
+
printSkillSummary(ungroupedSummary);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
console.log();
|
|
1512
|
+
p.note(summaryLines.join('\n'), 'Installation Summary');
|
|
1513
|
+
|
|
1514
|
+
// Await and display security audit results (started earlier in parallel)
|
|
1515
|
+
// Wrapped in try/catch so a failed audit fetch never blocks installation.
|
|
1516
|
+
try {
|
|
1517
|
+
const auditData = await auditPromise;
|
|
1518
|
+
if (auditData && ownerRepoForAudit) {
|
|
1519
|
+
const securityLines = buildSecurityLines(
|
|
1520
|
+
auditData,
|
|
1521
|
+
selectedSkills.map((s) => ({
|
|
1522
|
+
slug: getSkillDisplayName(s),
|
|
1523
|
+
displayName: getSkillDisplayName(s),
|
|
1524
|
+
})),
|
|
1525
|
+
ownerRepoForAudit
|
|
1526
|
+
);
|
|
1527
|
+
if (securityLines.length > 0) {
|
|
1528
|
+
p.note(securityLines.join('\n'), 'Security Risk Assessments');
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
} catch {
|
|
1532
|
+
// Silently skip — security info is advisory only
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (!options.yes) {
|
|
1536
|
+
const confirmed = await p.confirm({ message: 'Proceed with installation?' });
|
|
1537
|
+
|
|
1538
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1539
|
+
p.cancel('Installation cancelled');
|
|
1540
|
+
await cleanup(tempDir);
|
|
1541
|
+
process.exit(0);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
spinner.start('Installing skills...');
|
|
1546
|
+
|
|
1547
|
+
const results: {
|
|
1548
|
+
skill: string;
|
|
1549
|
+
agent: string;
|
|
1550
|
+
success: boolean;
|
|
1551
|
+
path: string;
|
|
1552
|
+
canonicalPath?: string;
|
|
1553
|
+
mode: InstallMode;
|
|
1554
|
+
symlinkFailed?: boolean;
|
|
1555
|
+
error?: string;
|
|
1556
|
+
pluginName?: string;
|
|
1557
|
+
}[] = [];
|
|
1558
|
+
|
|
1559
|
+
for (const skill of selectedSkills) {
|
|
1560
|
+
for (const agent of targetAgents) {
|
|
1561
|
+
let result;
|
|
1562
|
+
if (blobResult && 'files' in skill) {
|
|
1563
|
+
// Blob-based install: write files from snapshot
|
|
1564
|
+
const blobSkill = skill as BlobSkill;
|
|
1565
|
+
result = await installBlobSkillForAgent(
|
|
1566
|
+
{ installName: blobSkill.name, files: blobSkill.files },
|
|
1567
|
+
agent,
|
|
1568
|
+
{ global: installGlobally, mode: installMode }
|
|
1569
|
+
);
|
|
1570
|
+
} else {
|
|
1571
|
+
// Disk-based install: copy from cloned/local directory
|
|
1572
|
+
result = await installSkillForAgent(skill, agent, {
|
|
1573
|
+
global: installGlobally,
|
|
1574
|
+
mode: installMode,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
results.push({
|
|
1578
|
+
skill: getSkillDisplayName(skill),
|
|
1579
|
+
agent: agents[agent].displayName,
|
|
1580
|
+
pluginName: skill.pluginName,
|
|
1581
|
+
...result,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
spinner.stop('Installation complete');
|
|
1587
|
+
|
|
1588
|
+
console.log();
|
|
1589
|
+
const successful = results.filter((r) => r.success);
|
|
1590
|
+
const failed = results.filter((r) => !r.success);
|
|
1591
|
+
// Track installation result
|
|
1592
|
+
// Build skillFiles map: { skillName: relative path to SKILL.md from repo root }
|
|
1593
|
+
const skillFiles: Record<string, string> = {};
|
|
1594
|
+
for (const skill of selectedSkills) {
|
|
1595
|
+
if (blobResult && 'repoPath' in skill) {
|
|
1596
|
+
// Blob-based: repoPath is already the repo-relative path (e.g., "skills/react/SKILL.md")
|
|
1597
|
+
skillFiles[skill.name] = (skill as BlobSkill).repoPath;
|
|
1598
|
+
} else if (tempDir && skill.path === tempDir) {
|
|
1599
|
+
// Skill is at root level of repo
|
|
1600
|
+
skillFiles[skill.name] = 'SKILL.md';
|
|
1601
|
+
} else if (tempDir && skill.path.startsWith(tempDir + sep)) {
|
|
1602
|
+
// Compute path relative to repo root (tempDir), not search path
|
|
1603
|
+
// Use forward slashes for telemetry (URL-style paths)
|
|
1604
|
+
skillFiles[skill.name] =
|
|
1605
|
+
skill.path
|
|
1606
|
+
.slice(tempDir.length + 1)
|
|
1607
|
+
.split(sep)
|
|
1608
|
+
.join('/') + '/SKILL.md';
|
|
1609
|
+
} else {
|
|
1610
|
+
// Local path - skip telemetry for local installs
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Normalize source to owner/repo format for telemetry
|
|
1616
|
+
const normalizedSource = getOwnerRepo(parsed);
|
|
1617
|
+
|
|
1618
|
+
const lockSource = getLockSource(parsed.url, normalizedSource);
|
|
1619
|
+
|
|
1620
|
+
// Only track if we have a valid remote source and it's not a private repo.
|
|
1621
|
+
// repoPrivacyPromise was started early (right after parsing) so it has
|
|
1622
|
+
// already been running in parallel with the entire install — no stall here.
|
|
1623
|
+
if (normalizedSource) {
|
|
1624
|
+
const ownerRepo = parseOwnerRepo(normalizedSource);
|
|
1625
|
+
if (ownerRepo) {
|
|
1626
|
+
const isPrivate = await repoPrivacyPromise;
|
|
1627
|
+
// Only send telemetry if repo is public (isPrivate === false)
|
|
1628
|
+
// If we can't determine (null), err on the side of caution and skip telemetry
|
|
1629
|
+
if (isPrivate === false) {
|
|
1630
|
+
track({
|
|
1631
|
+
event: 'install',
|
|
1632
|
+
source: normalizedSource,
|
|
1633
|
+
skills: selectedSkills.map((s) => s.name).join(','),
|
|
1634
|
+
agents: targetAgents.join(','),
|
|
1635
|
+
...(installGlobally && { global: '1' }),
|
|
1636
|
+
skillFiles: JSON.stringify(skillFiles),
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
// If we can't parse owner/repo, still send telemetry (for non-GitHub sources)
|
|
1641
|
+
track({
|
|
1642
|
+
event: 'install',
|
|
1643
|
+
source: normalizedSource,
|
|
1644
|
+
skills: selectedSkills.map((s) => s.name).join(','),
|
|
1645
|
+
agents: targetAgents.join(','),
|
|
1646
|
+
...(installGlobally && { global: '1' }),
|
|
1647
|
+
skillFiles: JSON.stringify(skillFiles),
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Add to skill lock file for update tracking (only for global installs)
|
|
1653
|
+
if (successful.length > 0 && installGlobally && normalizedSource) {
|
|
1654
|
+
const successfulSkillNames = new Set(successful.map((r) => r.skill));
|
|
1655
|
+
|
|
1656
|
+
// For GitHub clone installs, fetch the repo tree once and reuse it
|
|
1657
|
+
// for all skills — avoids N sequential API calls that take ~400ms each.
|
|
1658
|
+
let cachedTree: Awaited<ReturnType<typeof fetchRepoTree>> | undefined;
|
|
1659
|
+
if (parsed.type === 'github' && !blobResult) {
|
|
1660
|
+
cachedTree = await fetchRepoTree(normalizedSource, parsed.ref, getGitHubToken);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
for (const skill of selectedSkills) {
|
|
1664
|
+
const skillDisplayName = getSkillDisplayName(skill);
|
|
1665
|
+
if (successfulSkillNames.has(skillDisplayName)) {
|
|
1666
|
+
try {
|
|
1667
|
+
let skillFolderHash = '';
|
|
1668
|
+
const skillPathValue = skillFiles[skill.name];
|
|
1669
|
+
|
|
1670
|
+
if (blobResult && skillPathValue) {
|
|
1671
|
+
const hash = getSkillFolderHashFromTree(blobResult.tree, skillPathValue);
|
|
1672
|
+
if (hash) skillFolderHash = hash;
|
|
1673
|
+
} else if (parsed.type === 'github' && skillPathValue && cachedTree) {
|
|
1674
|
+
const hash = getSkillFolderHashFromTree(cachedTree, skillPathValue);
|
|
1675
|
+
if (hash) skillFolderHash = hash;
|
|
1676
|
+
} else if (skillPathValue && tempDir) {
|
|
1677
|
+
const folder = dirname(skillPathValue);
|
|
1678
|
+
if (parsed.type === 'github') {
|
|
1679
|
+
// ADG patch: github sources are compared against the git tree SHA
|
|
1680
|
+
// at update time. When the tree API failed and we fell back to a
|
|
1681
|
+
// clone, derive that same tree SHA from the clone instead of a
|
|
1682
|
+
// sha256 content hash — otherwise every update perpetually
|
|
1683
|
+
// re-flags this skill (hash schemes can never match).
|
|
1684
|
+
const treeSha = await gitTreeShaForFolder(tempDir, folder === '.' ? '' : folder);
|
|
1685
|
+
if (treeSha) skillFolderHash = treeSha;
|
|
1686
|
+
} else {
|
|
1687
|
+
const skillDir = join(tempDir, folder);
|
|
1688
|
+
const hash = await computeSkillFolderHash(skillDir);
|
|
1689
|
+
if (hash) skillFolderHash = hash;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
await addSkillToLock(skill.name, {
|
|
1694
|
+
source: lockSource || normalizedSource,
|
|
1695
|
+
sourceType: parsed.type,
|
|
1696
|
+
sourceUrl: parsed.url,
|
|
1697
|
+
ref: parsed.ref,
|
|
1698
|
+
skillPath: skillPathValue,
|
|
1699
|
+
skillFolderHash,
|
|
1700
|
+
pluginName: skill.pluginName,
|
|
1701
|
+
});
|
|
1702
|
+
} catch {
|
|
1703
|
+
// Don't fail installation if lock file update fails
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Add to local lock file for project-scoped installs
|
|
1710
|
+
if (successful.length > 0 && !installGlobally) {
|
|
1711
|
+
const successfulSkillNames = new Set(successful.map((r) => r.skill));
|
|
1712
|
+
for (const skill of selectedSkills) {
|
|
1713
|
+
const skillDisplayName = getSkillDisplayName(skill);
|
|
1714
|
+
if (successfulSkillNames.has(skillDisplayName)) {
|
|
1715
|
+
try {
|
|
1716
|
+
// For blob skills, use the snapshot hash; for disk skills, compute from files
|
|
1717
|
+
const computedHash =
|
|
1718
|
+
blobResult && 'snapshotHash' in skill
|
|
1719
|
+
? (skill as BlobSkill).snapshotHash
|
|
1720
|
+
: await computeSkillFolderHash(skill.path);
|
|
1721
|
+
const skillPathValue = skillFiles[skill.name];
|
|
1722
|
+
await addSkillToLocalLock(
|
|
1723
|
+
skill.name,
|
|
1724
|
+
{
|
|
1725
|
+
source: lockSource || parsed.url,
|
|
1726
|
+
ref: parsed.ref,
|
|
1727
|
+
sourceType: parsed.type,
|
|
1728
|
+
...(skillPathValue && { skillPath: skillPathValue }),
|
|
1729
|
+
computedHash,
|
|
1730
|
+
},
|
|
1731
|
+
cwd
|
|
1732
|
+
);
|
|
1733
|
+
} catch {
|
|
1734
|
+
// Don't fail installation if lock file update fails
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (successful.length > 0) {
|
|
1741
|
+
const bySkill = new Map<string, typeof results>();
|
|
1742
|
+
|
|
1743
|
+
// Group results by plugin name
|
|
1744
|
+
const groupedResults: Record<string, typeof results> = {};
|
|
1745
|
+
const ungroupedResults: typeof results = [];
|
|
1746
|
+
|
|
1747
|
+
for (const r of successful) {
|
|
1748
|
+
const skillResults = bySkill.get(r.skill) || [];
|
|
1749
|
+
skillResults.push(r);
|
|
1750
|
+
bySkill.set(r.skill, skillResults);
|
|
1751
|
+
|
|
1752
|
+
// We only need to group once per skill (take the first result for that skill)
|
|
1753
|
+
if (skillResults.length === 1) {
|
|
1754
|
+
if (r.pluginName) {
|
|
1755
|
+
const group = r.pluginName;
|
|
1756
|
+
if (!groupedResults[group]) groupedResults[group] = [];
|
|
1757
|
+
// We'll store just one entry per skill here to drive the loop
|
|
1758
|
+
groupedResults[group].push(r);
|
|
1759
|
+
} else {
|
|
1760
|
+
ungroupedResults.push(r);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const skillCount = bySkill.size;
|
|
1766
|
+
const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);
|
|
1767
|
+
const copiedAgents = symlinkFailures.map((r) => r.agent);
|
|
1768
|
+
const resultLines: string[] = [];
|
|
1769
|
+
|
|
1770
|
+
const printSkillResults = (entries: typeof results) => {
|
|
1771
|
+
for (const entry of entries) {
|
|
1772
|
+
const skillResults = bySkill.get(entry.skill) || [];
|
|
1773
|
+
const firstResult = skillResults[0]!;
|
|
1774
|
+
|
|
1775
|
+
if (firstResult.mode === 'copy') {
|
|
1776
|
+
// Copy mode: show skill name and list all agent paths
|
|
1777
|
+
resultLines.push(`${pc.green('✓')} ${entry.skill} ${pc.dim('(copied)')}`);
|
|
1778
|
+
for (const r of skillResults) {
|
|
1779
|
+
const shortPath = shortenPath(r.path, cwd);
|
|
1780
|
+
resultLines.push(` ${pc.dim('→')} ${shortPath}`);
|
|
1781
|
+
}
|
|
1782
|
+
} else {
|
|
1783
|
+
// Symlink mode: show canonical path and universal/symlinked agents
|
|
1784
|
+
if (firstResult.canonicalPath) {
|
|
1785
|
+
const shortPath = shortenPath(firstResult.canonicalPath, cwd);
|
|
1786
|
+
resultLines.push(`${pc.green('✓')} ${shortPath}`);
|
|
1787
|
+
} else {
|
|
1788
|
+
resultLines.push(`${pc.green('✓')} ${entry.skill}`);
|
|
1789
|
+
}
|
|
1790
|
+
resultLines.push(...buildResultLines(skillResults, targetAgents));
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
// Print grouped results
|
|
1796
|
+
const sortedResultGroups = Object.keys(groupedResults).sort();
|
|
1797
|
+
|
|
1798
|
+
for (const group of sortedResultGroups) {
|
|
1799
|
+
const title = group
|
|
1800
|
+
.split('-')
|
|
1801
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1802
|
+
.join(' ');
|
|
1803
|
+
|
|
1804
|
+
resultLines.push('');
|
|
1805
|
+
resultLines.push(pc.bold(title));
|
|
1806
|
+
printSkillResults(groupedResults[group]!);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (ungroupedResults.length > 0) {
|
|
1810
|
+
if (sortedResultGroups.length > 0) {
|
|
1811
|
+
resultLines.push('');
|
|
1812
|
+
resultLines.push(pc.bold('General'));
|
|
1813
|
+
}
|
|
1814
|
+
printSkillResults(ungroupedResults);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);
|
|
1818
|
+
p.note(resultLines.join('\n'), title);
|
|
1819
|
+
|
|
1820
|
+
// Show symlink failure warning (only for symlink mode)
|
|
1821
|
+
if (symlinkFailures.length > 0) {
|
|
1822
|
+
p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));
|
|
1823
|
+
p.log.message(
|
|
1824
|
+
pc.dim(
|
|
1825
|
+
' Files were copied instead. On Windows, enable Developer Mode for symlink support.'
|
|
1826
|
+
)
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (failed.length > 0) {
|
|
1832
|
+
console.log();
|
|
1833
|
+
p.log.error(pc.red(`Failed to install ${failed.length}`));
|
|
1834
|
+
for (const r of failed) {
|
|
1835
|
+
p.log.message(` ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
console.log();
|
|
1840
|
+
p.outro(
|
|
1841
|
+
pc.green('Done!') +
|
|
1842
|
+
pc.dim(' Review skills before use; they run with full agent permissions.')
|
|
1843
|
+
);
|
|
1844
|
+
|
|
1845
|
+
// Prompt for find-skills after successful install
|
|
1846
|
+
await promptForFindSkills(options, targetAgents);
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
if (error instanceof GitCloneError) {
|
|
1849
|
+
p.log.error(pc.red('Failed to clone repository'));
|
|
1850
|
+
// Print each line of the error message separately for better formatting
|
|
1851
|
+
for (const line of error.message.split('\n')) {
|
|
1852
|
+
p.log.message(pc.dim(line));
|
|
1853
|
+
}
|
|
1854
|
+
} else {
|
|
1855
|
+
p.log.error(error instanceof Error ? error.message : 'Unknown error occurred');
|
|
1856
|
+
}
|
|
1857
|
+
showInstallTip();
|
|
1858
|
+
p.outro(pc.red('Installation failed'));
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
} finally {
|
|
1861
|
+
await cleanup(tempDir);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Cleanup helper
|
|
1866
|
+
async function cleanup(tempDir: string | null) {
|
|
1867
|
+
if (tempDir) {
|
|
1868
|
+
try {
|
|
1869
|
+
await cleanupTempDir(tempDir);
|
|
1870
|
+
} catch {
|
|
1871
|
+
// Ignore cleanup errors
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Prompt user to install the find-skills skill after their first installation.
|
|
1878
|
+
*/
|
|
1879
|
+
async function promptForFindSkills(
|
|
1880
|
+
options?: AddOptions,
|
|
1881
|
+
targetAgents?: AgentType[]
|
|
1882
|
+
): Promise<void> {
|
|
1883
|
+
// Skip if already dismissed or not in interactive mode
|
|
1884
|
+
if (!process.stdin.isTTY) return;
|
|
1885
|
+
if (options?.yes) return;
|
|
1886
|
+
|
|
1887
|
+
try {
|
|
1888
|
+
const dismissed = await isPromptDismissed('findSkillsPrompt');
|
|
1889
|
+
if (dismissed) return;
|
|
1890
|
+
|
|
1891
|
+
// Check if find-skills is already installed
|
|
1892
|
+
const findSkillsInstalled = await isSkillInstalled('find-skills', 'claude-code', {
|
|
1893
|
+
global: true,
|
|
1894
|
+
});
|
|
1895
|
+
if (findSkillsInstalled) {
|
|
1896
|
+
// Mark as dismissed so we don't check again
|
|
1897
|
+
await dismissPrompt('findSkillsPrompt');
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
console.log();
|
|
1902
|
+
p.log.message(pc.dim("One-time prompt - you won't be asked again if you dismiss."));
|
|
1903
|
+
const install = await p.confirm({
|
|
1904
|
+
message: `Install the ${pc.cyan('find-skills')} skill? It helps your agent discover and suggest skills.`,
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
if (p.isCancel(install)) {
|
|
1908
|
+
await dismissPrompt('findSkillsPrompt');
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
if (install) {
|
|
1913
|
+
// Install find-skills to the same agents the user selected, excluding replit
|
|
1914
|
+
await dismissPrompt('findSkillsPrompt');
|
|
1915
|
+
|
|
1916
|
+
// Filter out replit from target agents
|
|
1917
|
+
const findSkillsAgents = targetAgents?.filter((a) => a !== 'replit');
|
|
1918
|
+
|
|
1919
|
+
// Skip if no valid agents remain after filtering
|
|
1920
|
+
if (!findSkillsAgents || findSkillsAgents.length === 0) {
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
console.log();
|
|
1925
|
+
p.log.step('Installing find-skills skill...');
|
|
1926
|
+
|
|
1927
|
+
try {
|
|
1928
|
+
// Call runAdd directly
|
|
1929
|
+
await runAdd(['vercel-labs/skills'], {
|
|
1930
|
+
skill: ['find-skills'],
|
|
1931
|
+
global: true,
|
|
1932
|
+
yes: true,
|
|
1933
|
+
agent: findSkillsAgents,
|
|
1934
|
+
});
|
|
1935
|
+
} catch {
|
|
1936
|
+
p.log.warn('Failed to install find-skills. You can try again with:');
|
|
1937
|
+
p.log.message(pc.dim(' npx skills add vercel-labs/skills@find-skills -g -y --all'));
|
|
1938
|
+
}
|
|
1939
|
+
} else {
|
|
1940
|
+
// User declined - dismiss the prompt
|
|
1941
|
+
await dismissPrompt('findSkillsPrompt');
|
|
1942
|
+
p.log.message(
|
|
1943
|
+
pc.dim('You can install it later with: npx skills add vercel-labs/skills@find-skills')
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
} catch {
|
|
1947
|
+
// Don't fail the main installation if prompt fails
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Parse command line options from args array
|
|
1952
|
+
export function parseAddOptions(args: string[]): { source: string[]; options: AddOptions } {
|
|
1953
|
+
const options: AddOptions = {};
|
|
1954
|
+
const source: string[] = [];
|
|
1955
|
+
|
|
1956
|
+
for (let i = 0; i < args.length; i++) {
|
|
1957
|
+
const arg = args[i];
|
|
1958
|
+
|
|
1959
|
+
if (arg === '-g' || arg === '--global') {
|
|
1960
|
+
options.global = true;
|
|
1961
|
+
} else if (arg === '-y' || arg === '--yes') {
|
|
1962
|
+
options.yes = true;
|
|
1963
|
+
} else if (arg === '-l' || arg === '--list') {
|
|
1964
|
+
options.list = true;
|
|
1965
|
+
} else if (arg === '--all') {
|
|
1966
|
+
options.all = true;
|
|
1967
|
+
} else if (arg === '-a' || arg === '--agent') {
|
|
1968
|
+
options.agent = options.agent || [];
|
|
1969
|
+
i++;
|
|
1970
|
+
let nextArg = args[i];
|
|
1971
|
+
while (i < args.length && nextArg && !nextArg.startsWith('-')) {
|
|
1972
|
+
options.agent.push(nextArg);
|
|
1973
|
+
i++;
|
|
1974
|
+
nextArg = args[i];
|
|
1975
|
+
}
|
|
1976
|
+
i--; // Back up one since the loop will increment
|
|
1977
|
+
} else if (arg === '-s' || arg === '--skill') {
|
|
1978
|
+
options.skill = options.skill || [];
|
|
1979
|
+
i++;
|
|
1980
|
+
let nextArg = args[i];
|
|
1981
|
+
while (i < args.length && nextArg && !nextArg.startsWith('-')) {
|
|
1982
|
+
options.skill.push(nextArg);
|
|
1983
|
+
i++;
|
|
1984
|
+
nextArg = args[i];
|
|
1985
|
+
}
|
|
1986
|
+
i--; // Back up one since the loop will increment
|
|
1987
|
+
} else if (arg === '--full-depth') {
|
|
1988
|
+
options.fullDepth = true;
|
|
1989
|
+
} else if (arg === '--copy') {
|
|
1990
|
+
options.copy = true;
|
|
1991
|
+
} else if (arg === '--dangerously-accept-openclaw-risks') {
|
|
1992
|
+
options.dangerouslyAcceptOpenclawRisks = true;
|
|
1993
|
+
} else if (arg && !arg.startsWith('-')) {
|
|
1994
|
+
source.push(arg);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
return { source, options };
|
|
1999
|
+
}
|