@researchcomputer/pista 0.1.2

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.
@@ -0,0 +1,673 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { describeConfig, parseCompatApiStyle, parsePermissionMode, parseThinkingLevel, } from './config.js';
3
+ import { normalizeAgentName, normalizeEndpoint, errorMessage, saveApiKey, getApiKeyPath, getStoredApiKey, loadStoredPreferences, saveStoredPreferences, resolveConfiguredSkills, fetchGitHubSkill, fetchGitHubSkillIndex, } from './utils.js';
4
+ const SLASH_COMMANDS = [
5
+ { id: 'help', usage: '/help', description: 'Show this help message', allowWhileRunning: true, section: 'commands' },
6
+ { id: 'init', usage: '/init', description: 'Analyze codebase and generate AGENTS.md', section: 'commands' },
7
+ { id: 'name', usage: '/name [name]', description: 'Show or change agent name', section: 'commands' },
8
+ { id: 'abort', usage: '/abort', description: 'Stop current agent execution', allowWhileRunning: true, section: 'commands' },
9
+ { id: 'clear', usage: '/clear', description: 'Clear the terminal transcript', section: 'commands' },
10
+ { id: 'reset', usage: '/reset', description: 'Reset agent conversation state', section: 'commands' },
11
+ { id: 'cost', usage: '/cost', description: 'Show current session token usage', section: 'commands' },
12
+ { id: 'key', usage: '/key', description: 'Update your stored API key', section: 'commands' },
13
+ { id: 'jump', usage: '/jump <target>', description: 'Jump transcript to latest | error | tool', insertText: '/jump ', section: 'commands' },
14
+ { id: 'permissions', usage: '/permissions', description: 'Change tool permission mode', aliases: ['permission-mode'], section: 'commands' },
15
+ { id: 'thinking', usage: '/thinking', description: 'Change reasoning intensity', section: 'commands' },
16
+ { id: 'quit', usage: '/quit', description: 'Exit the application', aliases: ['exit'], section: 'commands' },
17
+ { id: 'plugins', usage: '/plugins', description: 'List installed plugins', aliases: ['skills'], section: 'plugins' },
18
+ { id: 'plugins add', usage: '/plugins add', description: 'Add a new plugin (prompt or MCP)', insertText: '/plugins add ', section: 'plugins' },
19
+ { id: 'plugins install', usage: '/plugins install', description: 'Install from GitHub (owner/repo/path)', insertText: '/plugins install ', aliases: ['plugins add-github', 'plugins github'], section: 'plugins' },
20
+ { id: 'plugins browse', usage: '/plugins browse', description: 'Browse a GitHub skill repo', insertText: '/plugins browse ', section: 'plugins' },
21
+ { id: 'plugins remove', usage: '/plugins remove', description: 'Remove a plugin', insertText: '/plugins remove ', aliases: ['plugins rm'], section: 'plugins' },
22
+ { id: 'plugins enable', usage: '/plugins enable', description: 'Enable a disabled plugin', insertText: '/plugins enable ', section: 'plugins' },
23
+ { id: 'plugins disable', usage: '/plugins disable', description: 'Disable a plugin', insertText: '/plugins disable ', section: 'plugins' },
24
+ { id: 'session', usage: '/session [new]', description: 'Show session info or start a fresh one', section: 'session' },
25
+ { id: 'config', usage: '/config', description: 'Show current configuration', section: 'session' },
26
+ { id: 'model', usage: '/model [id]', description: 'Change model id', section: 'model' },
27
+ { id: 'endpoint', usage: '/endpoint [url]', description: 'Change API endpoint URL', section: 'model' },
28
+ { id: 'api', usage: '/api [style]', description: 'Set API style (chat or responses)', aliases: ['api-style'], section: 'model' },
29
+ ];
30
+ const SLASH_COMMAND_SECTION_TITLES = {
31
+ commands: 'COMMANDS',
32
+ plugins: 'PLUGINS',
33
+ session: 'SESSION & CONFIG',
34
+ model: 'MODEL & ENDPOINT',
35
+ };
36
+ export function parseSlashCommand(input) {
37
+ const withoutSlash = input.slice(1).trim();
38
+ const [command = '', ...args] = withoutSlash.split(/\s+/);
39
+ return { command: command.toLowerCase(), args, rest: args.join(' ').trim() };
40
+ }
41
+ export function getSlashCommandSuggestions(input, running, limit = 6) {
42
+ const query = getSlashSuggestionQuery(input);
43
+ if (query === null)
44
+ return [];
45
+ const normalizedQuery = query.toLowerCase();
46
+ const candidates = SLASH_COMMANDS
47
+ .filter((item) => !running || item.allowWhileRunning)
48
+ .map((item, index) => {
49
+ const aliases = item.aliases ?? [];
50
+ const searchTerms = [
51
+ item.id,
52
+ item.usage.replace(/^\//, ''),
53
+ ...aliases,
54
+ ].map((term) => term.toLowerCase());
55
+ let rank = normalizedQuery ? 3 : 1;
56
+ if (normalizedQuery) {
57
+ if (searchTerms.some((term) => term === normalizedQuery)) {
58
+ rank = 0;
59
+ }
60
+ else if (searchTerms.some((term) => term.startsWith(normalizedQuery))) {
61
+ rank = 1;
62
+ }
63
+ else if (searchTerms.some((term) => term.includes(normalizedQuery))) {
64
+ rank = 2;
65
+ }
66
+ else {
67
+ return null;
68
+ }
69
+ }
70
+ return {
71
+ index,
72
+ rank,
73
+ suggestion: {
74
+ id: item.id,
75
+ label: item.usage,
76
+ description: item.description,
77
+ insertText: item.insertText ?? getCommandText(item),
78
+ commandText: getCommandText(item),
79
+ },
80
+ };
81
+ })
82
+ .filter((item) => Boolean(item))
83
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
84
+ .slice(0, limit)
85
+ .map((item) => item.suggestion);
86
+ return candidates;
87
+ }
88
+ function getSlashSuggestionQuery(input) {
89
+ const trimmedStart = input.trimStart();
90
+ if (!trimmedStart.startsWith('/'))
91
+ return null;
92
+ if (trimmedStart.includes('\n'))
93
+ return null;
94
+ if (/\s$/.test(trimmedStart))
95
+ return null;
96
+ return trimmedStart.slice(1).trimStart();
97
+ }
98
+ function getCommandText(item) {
99
+ return item.usage
100
+ .split(' ')
101
+ .filter((token) => !token.startsWith('<') && !token.startsWith('['))
102
+ .join(' ');
103
+ }
104
+ export async function handleSlashCommand(input, ctx) {
105
+ const { command, args, rest } = parseSlashCommand(input);
106
+ const { config } = ctx;
107
+ if (command !== 'abort' && command !== 'help' && ctx.running) {
108
+ ctx.appendLog('error', 'Agent busy', 'Only /abort and /help are available while the agent is running.');
109
+ return;
110
+ }
111
+ switch (command) {
112
+ case 'help':
113
+ ctx.appendLog('system', 'Commands', helpText());
114
+ return;
115
+ case 'abort':
116
+ if (!ctx.running) {
117
+ ctx.appendLog('system', 'Abort', 'No active run to abort.');
118
+ return;
119
+ }
120
+ ctx.cancelPendingRequest();
121
+ ctx.abortAgent();
122
+ ctx.appendLog('system', 'Abort requested', 'Waiting for the current run to stop.');
123
+ return;
124
+ case 'clear':
125
+ ctx.clearLogs();
126
+ ctx.appendLog('system', 'Transcript cleared', 'Conversation state is preserved. Use /reset to clear the agent context too.');
127
+ return;
128
+ case 'reset':
129
+ ctx.resetAgent();
130
+ ctx.appendLog('system', 'Conversation reset', 'Agent messages were cleared for the current session.');
131
+ return;
132
+ case 'cost': {
133
+ const summary = ctx.getCostSummary();
134
+ if (!summary) {
135
+ ctx.appendLog('error', 'No agent', 'The agent has not been created yet.');
136
+ return;
137
+ }
138
+ ctx.appendLog('system', 'Usage', `Tokens ${summary.tokens}\nCost $${summary.cost.toFixed(4)}`);
139
+ return;
140
+ }
141
+ case 'session':
142
+ if (rest === 'new') {
143
+ const nextConfig = { ...config, sessionId: randomUUID() };
144
+ await ctx.rebuildAgent(nextConfig, `Started a new session: ${nextConfig.sessionId}`);
145
+ return;
146
+ }
147
+ ctx.appendLog('system', 'Session', [
148
+ `ID ${config.sessionId}`,
149
+ `CWD ${config.cwd}`,
150
+ `Permissions ${config.permissionMode}`,
151
+ `Thinking ${config.thinkingLevel}`,
152
+ ].join('\n'));
153
+ return;
154
+ case 'config':
155
+ ctx.appendLog('system', 'Configuration', describeConfig(config));
156
+ return;
157
+ case 'jump': {
158
+ const target = args[0]?.toLowerCase();
159
+ if (target !== 'latest' && target !== 'error' && target !== 'tool') {
160
+ ctx.appendLog('error', 'Unknown jump target', 'Use /jump latest, /jump error, or /jump tool.');
161
+ return;
162
+ }
163
+ const jumped = ctx.jumpTranscript(target);
164
+ ctx.appendLog(jumped ? 'system' : 'error', jumped ? 'Transcript jumped' : 'Transcript target missing', jumped ? `Moved transcript view to the latest ${target} entry.` : `No ${target} entry is available yet.`);
165
+ return;
166
+ }
167
+ case 'permissions':
168
+ case 'permission-mode': {
169
+ const nextMode = args[0] ?? await ctx.openPicker({
170
+ title: 'Permission mode',
171
+ description: 'Choose how aggressively the agent may run tools.',
172
+ items: [
173
+ { id: 'default', label: 'default', description: 'Ask when the SDK needs approval.' },
174
+ { id: 'allowAll', label: 'allowAll', description: 'Allow all tool calls without prompts.' },
175
+ { id: 'rulesOnly', label: 'rulesOnly', description: 'Allow only rules-backed tool calls, deny the rest.' },
176
+ ],
177
+ selectedId: config.permissionMode,
178
+ });
179
+ if (!nextMode)
180
+ return;
181
+ const parsedMode = parsePermissionMode(nextMode);
182
+ if (!parsedMode) {
183
+ ctx.appendLog('error', 'Unknown permission mode', nextMode);
184
+ return;
185
+ }
186
+ await ctx.rebuildAgent({ ...config, permissionMode: parsedMode }, `Permission mode set to ${parsedMode}.`);
187
+ return;
188
+ }
189
+ case 'thinking': {
190
+ const nextLevel = args[0] ?? await ctx.openPicker({
191
+ title: 'Thinking level',
192
+ description: 'Higher levels may improve answers, but they usually cost more and respond slower.',
193
+ items: [
194
+ { id: 'off', label: 'off', description: 'No extra reasoning budget.' },
195
+ { id: 'minimal', label: 'minimal', description: 'Small reasoning budget.' },
196
+ { id: 'low', label: 'low', description: 'Low reasoning budget.' },
197
+ { id: 'medium', label: 'medium', description: 'Balanced reasoning budget.' },
198
+ { id: 'high', label: 'high', description: 'More deliberate reasoning.' },
199
+ { id: 'xhigh', label: 'xhigh', description: 'Maximum reasoning budget.' },
200
+ ],
201
+ selectedId: config.thinkingLevel,
202
+ });
203
+ if (!nextLevel)
204
+ return;
205
+ const parsedLevel = parseThinkingLevel(nextLevel);
206
+ if (!parsedLevel) {
207
+ ctx.appendLog('error', 'Unknown thinking level', nextLevel);
208
+ return;
209
+ }
210
+ await ctx.rebuildAgent({ ...config, thinkingLevel: parsedLevel }, `Thinking level set to ${parsedLevel}.`);
211
+ return;
212
+ }
213
+ case 'name': {
214
+ const nextName = rest || await ctx.openPrompt({
215
+ title: 'Agent name',
216
+ description: 'Pick a short and cute name for this coding agent.',
217
+ initialValue: config.agentName,
218
+ placeholder: 'Example: Pista',
219
+ });
220
+ if (!nextName)
221
+ return;
222
+ const normalizedName = normalizeAgentName(nextName);
223
+ ctx.setConfig((current) => ({ ...current, agentName: normalizedName }));
224
+ ctx.appendLog('system', 'Agent renamed', `${normalizedName} is now your coding agent.`);
225
+ return;
226
+ }
227
+ case 'model': {
228
+ let nextModel = rest;
229
+ if (!nextModel) {
230
+ try {
231
+ const apiKey = getStoredApiKey();
232
+ const response = await fetch(`${config.selection.endpoint}/models`, {
233
+ headers: apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {},
234
+ });
235
+ if (response.ok) {
236
+ const data = await response.json();
237
+ const models = data.data.map(m => m.id);
238
+ if (models.length > 0) {
239
+ nextModel = await ctx.openPicker({
240
+ title: 'Select Model',
241
+ items: models.map(id => ({ id, label: id })),
242
+ selectedId: config.selection.model,
243
+ emptyMessage: 'No models returned from endpoint.',
244
+ }) || '';
245
+ }
246
+ }
247
+ }
248
+ catch (error) {
249
+ ctx.appendLog('error', 'Fetch models failed', errorMessage(error));
250
+ }
251
+ }
252
+ if (!nextModel && !rest) {
253
+ const prompted = await ctx.openPrompt({
254
+ title: 'Model id',
255
+ description: 'Enter the model id exposed by the endpoint.',
256
+ initialValue: config.selection.model,
257
+ });
258
+ nextModel = prompted || '';
259
+ }
260
+ if (!nextModel)
261
+ return;
262
+ await ctx.rebuildAgent({
263
+ ...config,
264
+ selection: {
265
+ ...config.selection,
266
+ model: nextModel.trim(),
267
+ },
268
+ }, `Using model ${nextModel.trim()}.`);
269
+ return;
270
+ }
271
+ case 'endpoint': {
272
+ const nextEndpoint = rest || await ctx.openPrompt({
273
+ title: 'API endpoint',
274
+ description: 'Example: https://api.research.computer/v1',
275
+ initialValue: config.selection.endpoint,
276
+ });
277
+ if (!nextEndpoint)
278
+ return;
279
+ try {
280
+ const normalizedEndpoint = normalizeEndpoint(nextEndpoint);
281
+ const nextSelection = {
282
+ ...config.selection,
283
+ endpoint: normalizedEndpoint,
284
+ };
285
+ await ctx.rebuildAgent({ ...config, selection: nextSelection }, `Connected to ${normalizedEndpoint}.`);
286
+ }
287
+ catch (error) {
288
+ ctx.appendLog('error', 'Invalid endpoint', errorMessage(error));
289
+ }
290
+ return;
291
+ }
292
+ case 'api':
293
+ case 'api-style': {
294
+ const nextStyle = args[0] ?? await ctx.openPicker({
295
+ title: 'Pick API style',
296
+ items: [
297
+ { id: 'chat', label: 'chat', description: 'OpenAI-compatible chat/completions API' },
298
+ { id: 'responses', label: 'responses', description: 'OpenAI-compatible responses API' },
299
+ ],
300
+ selectedId: config.selection.apiStyle,
301
+ });
302
+ if (!nextStyle)
303
+ return;
304
+ const parsedStyle = parseCompatApiStyle(nextStyle);
305
+ if (!parsedStyle) {
306
+ ctx.appendLog('error', 'Unknown API style', nextStyle);
307
+ return;
308
+ }
309
+ const nextSelection = {
310
+ ...config.selection,
311
+ apiStyle: parsedStyle,
312
+ };
313
+ await ctx.rebuildAgent({ ...config, selection: nextSelection }, `Using ${nextStyle} API style.`);
314
+ return;
315
+ }
316
+ case 'key': {
317
+ const nextKey = await ctx.openPrompt({
318
+ title: 'Update API Key',
319
+ description: `Enter your new API key. It will be saved to ${getApiKeyPath()}.`,
320
+ placeholder: 'Paste your API key here',
321
+ secret: true,
322
+ });
323
+ if (!nextKey)
324
+ return;
325
+ saveApiKey(nextKey);
326
+ ctx.appendLog('system', 'API Key Updated', `Key has been saved to ${getApiKeyPath()}. Rebuilding agent...`);
327
+ await ctx.rebuildAgent(config, 'Agent rebuilt with new API key.');
328
+ return;
329
+ }
330
+ case 'init': {
331
+ ctx.appendLog('system', 'Initializing project', 'Asking the agent to analyze the codebase and generate AGENTS.md...');
332
+ const initPrompt = [
333
+ 'Analyze this codebase and create an AGENTS.md file in the project root.',
334
+ 'Follow these steps iteratively:',
335
+ '',
336
+ '1. Read the top-level directory structure and any existing README, package.json, or config files.',
337
+ '2. Identify the primary language(s), frameworks, build system, and project layout.',
338
+ '3. Explore the main source directories to understand the architecture — key modules, entry points, data flow.',
339
+ '4. Look at test setup, CI config, and dev tooling if present.',
340
+ '5. Check for existing contribution guidelines, coding conventions, or linter configs.',
341
+ '',
342
+ 'Then write AGENTS.md with these sections:',
343
+ '',
344
+ '- **Project Overview**: What this project does, in 2-3 sentences.',
345
+ '- **Architecture**: High-level structure, key directories, and how components connect.',
346
+ '- **Tech Stack**: Languages, frameworks, major dependencies.',
347
+ '- **Build & Run**: How to install dependencies, build, run, and test.',
348
+ '- **Code Conventions**: Naming, formatting, patterns observed in the code.',
349
+ '- **Key Files**: The most important files an agent or new contributor should read first.',
350
+ '- **Common Tasks**: How to add a feature, fix a bug, or run specific workflows.',
351
+ '',
352
+ 'Be concise and factual. Only include what you can verify from the code.',
353
+ 'Do NOT ask me questions — explore the codebase yourself and make your best judgment.',
354
+ ].join('\n');
355
+ await ctx.promptAgent(initPrompt);
356
+ return;
357
+ }
358
+ case 'plugins':
359
+ case 'skills': {
360
+ const subcommand = args[0]?.toLowerCase();
361
+ if (subcommand === 'list' || !subcommand) {
362
+ const storedPrefs = loadStoredPreferences();
363
+ const projectPrefs = loadStoredPreferences(config.cwd);
364
+ const allSkills = [
365
+ ...(storedPrefs.skills ?? []).map((s) => ({ ...s, scope: 'global' })),
366
+ ...(projectPrefs.skills ?? []).map((s) => ({ ...s, scope: 'project' })),
367
+ ];
368
+ if (allSkills.length === 0) {
369
+ ctx.appendLog('system', 'Plugins', 'No plugins installed. Use /plugins add to add one.');
370
+ return;
371
+ }
372
+ const lines = allSkills.map((s) => {
373
+ const status = s.enabled === false ? '(disabled)' : '(enabled)';
374
+ const scope = `[${s.scope}]`;
375
+ const desc = s.description ? ` — ${s.description}` : '';
376
+ return ` ${s.id} ${status} ${scope}${desc}`;
377
+ });
378
+ ctx.appendLog('system', 'Plugins', lines.join('\n'));
379
+ return;
380
+ }
381
+ if (subcommand === 'add') {
382
+ const id = rest.slice(4).trim() || await ctx.openPrompt({
383
+ title: 'Plugin ID',
384
+ description: 'A unique identifier for this plugin (e.g. "typescript-style", "my-mcp-server").',
385
+ placeholder: 'my-plugin',
386
+ });
387
+ if (!id)
388
+ return;
389
+ const description = await ctx.openPrompt({
390
+ title: 'Description',
391
+ description: `Describe what "${id}" does.`,
392
+ placeholder: 'Optional description',
393
+ });
394
+ const promptText = await ctx.openPrompt({
395
+ title: 'Prompt sections',
396
+ description: 'System prompt instructions for this plugin. Use \\n for multiple lines. Leave empty to skip.',
397
+ placeholder: 'e.g. Always use strict TypeScript',
398
+ });
399
+ const mcpChoice = await ctx.openPicker({
400
+ title: 'Add an MCP server?',
401
+ items: [
402
+ { id: 'no', label: 'No', description: 'Just a prompt-based plugin' },
403
+ { id: 'stdio', label: 'stdio', description: 'Local command (e.g. npx, python)' },
404
+ { id: 'sse', label: 'sse', description: 'SSE endpoint URL' },
405
+ { id: 'http', label: 'http', description: 'HTTP endpoint URL' },
406
+ ],
407
+ });
408
+ if (!mcpChoice)
409
+ return;
410
+ const skill = { id: id.trim() };
411
+ if (description)
412
+ skill.description = description;
413
+ if (promptText) {
414
+ skill.promptSections = promptText.split('\\n').map((s) => s.trim()).filter(Boolean);
415
+ }
416
+ if (mcpChoice !== 'no') {
417
+ const transport = mcpChoice;
418
+ if (transport === 'stdio') {
419
+ const command = await ctx.openPrompt({
420
+ title: 'Command',
421
+ description: 'The command to run (e.g. "npx", "python").',
422
+ placeholder: 'npx',
423
+ });
424
+ if (!command)
425
+ return;
426
+ const argsStr = await ctx.openPrompt({
427
+ title: 'Arguments',
428
+ description: 'Space-separated arguments for the command.',
429
+ placeholder: '-y @modelcontextprotocol/server-filesystem /',
430
+ });
431
+ skill.mcpServers = [{
432
+ name: id.trim(),
433
+ transport,
434
+ command,
435
+ args: argsStr ? argsStr.split(/\s+/) : [],
436
+ }];
437
+ }
438
+ else {
439
+ const url = await ctx.openPrompt({
440
+ title: 'Server URL',
441
+ description: `The ${transport.toUpperCase()} endpoint URL.`,
442
+ placeholder: 'https://example.com/mcp',
443
+ });
444
+ if (!url)
445
+ return;
446
+ skill.mcpServers = [{ name: id.trim(), transport, url }];
447
+ }
448
+ }
449
+ const scope = await ctx.openPicker({
450
+ title: 'Scope',
451
+ description: 'Where to save this plugin.',
452
+ items: [
453
+ { id: 'global', label: 'Global', description: 'Available in all projects (~/.pista/preferences.json)' },
454
+ { id: 'project', label: 'Project', description: 'Only this project (<cwd>/.pista/preferences.json)' },
455
+ ],
456
+ });
457
+ if (!scope)
458
+ return;
459
+ const baseDir = scope === 'project' ? config.cwd : undefined;
460
+ const prefs = loadStoredPreferences(baseDir);
461
+ const updatedSkills = [...(prefs.skills ?? []), skill];
462
+ saveStoredPreferences({ ...prefs, skills: updatedSkills }, baseDir);
463
+ await ctx.rebuildAgent({ ...config, skills: resolveConfiguredSkills(updatedSkills) }, `Plugin "${id.trim()}" added (${scope}).`);
464
+ return;
465
+ }
466
+ if (subcommand === 'add-github' || subcommand === 'github' || subcommand === 'install') {
467
+ const source = rest.slice(subcommand.length + 1).trim() || await ctx.openPrompt({
468
+ title: 'GitHub source',
469
+ description: 'Enter owner/repo or owner/repo/path/to/skill (e.g. "anthropics/skills/skills/pdf").',
470
+ placeholder: 'anthropics/skills/skills/pdf',
471
+ });
472
+ if (!source)
473
+ return;
474
+ ctx.appendLog('system', 'Fetching plugin', `Downloading SKILL.md from ${source}...`);
475
+ try {
476
+ const skill = await fetchGitHubSkill(source);
477
+ const scope = await ctx.openPicker({
478
+ title: 'Scope',
479
+ description: `Install "${skill.id}" to:`,
480
+ items: [
481
+ { id: 'global', label: 'Global', description: 'Available in all projects' },
482
+ { id: 'project', label: 'Project', description: 'Only this project' },
483
+ ],
484
+ });
485
+ if (!scope)
486
+ return;
487
+ const baseDir = scope === 'project' ? config.cwd : undefined;
488
+ const prefs = loadStoredPreferences(baseDir);
489
+ const existing = (prefs.skills ?? []).filter((s) => s.id !== skill.id);
490
+ const updatedSkills = [...existing, skill];
491
+ saveStoredPreferences({ ...prefs, skills: updatedSkills }, baseDir);
492
+ const mergedPrefs = loadStoredPreferences();
493
+ const mergedProjectPrefs = loadStoredPreferences(config.cwd);
494
+ const finalSkills = resolveConfiguredSkills([
495
+ ...(mergedPrefs.skills ?? []),
496
+ ...(mergedProjectPrefs.skills ?? []),
497
+ ]);
498
+ await ctx.rebuildAgent({ ...config, skills: finalSkills }, `Plugin "${skill.id}" installed from GitHub (${scope}).${skill.description ? '\n' + skill.description : ''}`);
499
+ }
500
+ catch (error) {
501
+ ctx.appendLog('error', 'Install failed', errorMessage(error));
502
+ }
503
+ return;
504
+ }
505
+ if (subcommand === 'browse') {
506
+ const source = rest.slice(7).trim() || await ctx.openPrompt({
507
+ title: 'GitHub repository',
508
+ description: 'Enter owner/repo to browse available skills (e.g. "anthropics/skills").',
509
+ placeholder: 'anthropics/skills',
510
+ });
511
+ if (!source)
512
+ return;
513
+ const parts = source.replace(/https?:\/\/github\.com\//, '').split('/');
514
+ const owner = parts[0];
515
+ const repo = parts[1]?.replace(/\.git$/, '');
516
+ if (!owner || !repo) {
517
+ ctx.appendLog('error', 'Invalid source', 'Expected owner/repo format.');
518
+ return;
519
+ }
520
+ ctx.appendLog('system', 'Browsing', `Fetching skill index from ${owner}/${repo}...`);
521
+ try {
522
+ const plugins = await fetchGitHubSkillIndex(owner, repo);
523
+ if (!plugins || plugins.length === 0) {
524
+ ctx.appendLog('error', 'No index found', `No .claude-plugin/marketplace.json found in ${owner}/${repo}. Try /plugins install ${owner}/${repo}/path/to/skill instead.`);
525
+ return;
526
+ }
527
+ const allSkills = plugins.flatMap((p) => p.skills.map((skillPath) => {
528
+ const cleanPath = skillPath.replace(/^\.\//, '');
529
+ const label = cleanPath.split('/').pop() || cleanPath;
530
+ return {
531
+ id: `${owner}/${repo}/${cleanPath}`,
532
+ label,
533
+ description: `${p.name} — ${cleanPath}`,
534
+ };
535
+ }));
536
+ const selected = await ctx.openPicker({
537
+ title: `Skills in ${owner}/${repo}`,
538
+ description: 'Choose a skill to install.',
539
+ items: allSkills,
540
+ emptyMessage: 'No skills found in the marketplace manifest.',
541
+ });
542
+ if (!selected)
543
+ return;
544
+ ctx.appendLog('system', 'Fetching plugin', `Downloading SKILL.md from ${selected}...`);
545
+ const skill = await fetchGitHubSkill(selected);
546
+ const scope = await ctx.openPicker({
547
+ title: 'Scope',
548
+ description: `Install "${skill.id}" to:`,
549
+ items: [
550
+ { id: 'global', label: 'Global', description: 'Available in all projects' },
551
+ { id: 'project', label: 'Project', description: 'Only this project' },
552
+ ],
553
+ });
554
+ if (!scope)
555
+ return;
556
+ const baseDir = scope === 'project' ? config.cwd : undefined;
557
+ const prefs = loadStoredPreferences(baseDir);
558
+ const existing = (prefs.skills ?? []).filter((s) => s.id !== skill.id);
559
+ const updatedSkills = [...existing, skill];
560
+ saveStoredPreferences({ ...prefs, skills: updatedSkills }, baseDir);
561
+ const mergedPrefs = loadStoredPreferences();
562
+ const mergedProjectPrefs = loadStoredPreferences(config.cwd);
563
+ const finalSkills = resolveConfiguredSkills([
564
+ ...(mergedPrefs.skills ?? []),
565
+ ...(mergedProjectPrefs.skills ?? []),
566
+ ]);
567
+ await ctx.rebuildAgent({ ...config, skills: finalSkills }, `Plugin "${skill.id}" installed from GitHub (${scope}).${skill.description ? '\n' + skill.description : ''}`);
568
+ }
569
+ catch (error) {
570
+ ctx.appendLog('error', 'Browse failed', errorMessage(error));
571
+ }
572
+ return;
573
+ }
574
+ if (subcommand === 'remove' || subcommand === 'rm') {
575
+ const globalPrefs = loadStoredPreferences();
576
+ const projectPrefs = loadStoredPreferences(config.cwd);
577
+ const allSkills = [
578
+ ...(globalPrefs.skills ?? []).map((s) => ({ ...s, scope: 'global' })),
579
+ ...(projectPrefs.skills ?? []).map((s) => ({ ...s, scope: 'project' })),
580
+ ];
581
+ if (allSkills.length === 0) {
582
+ ctx.appendLog('system', 'Plugins', 'No plugins to remove.');
583
+ return;
584
+ }
585
+ const targetId = args[1] || await ctx.openPicker({
586
+ title: 'Remove plugin',
587
+ items: allSkills.map((s) => ({
588
+ id: `${s.scope}:${s.id}`,
589
+ label: `${s.id} [${s.scope}]`,
590
+ description: s.description,
591
+ })),
592
+ });
593
+ if (!targetId)
594
+ return;
595
+ const [targetScope, ...idParts] = targetId.split(':');
596
+ const skillId = idParts.join(':');
597
+ const baseDir = targetScope === 'project' ? config.cwd : undefined;
598
+ const prefs = loadStoredPreferences(baseDir);
599
+ const updatedSkills = (prefs.skills ?? []).filter((s) => s.id !== skillId);
600
+ saveStoredPreferences({ ...prefs, skills: updatedSkills }, baseDir);
601
+ const mergedPrefs = loadStoredPreferences();
602
+ const mergedProjectPrefs = loadStoredPreferences(config.cwd);
603
+ const finalSkills = resolveConfiguredSkills([
604
+ ...(mergedPrefs.skills ?? []),
605
+ ...(mergedProjectPrefs.skills ?? []),
606
+ ]);
607
+ await ctx.rebuildAgent({ ...config, skills: finalSkills }, `Plugin "${skillId}" removed.`);
608
+ return;
609
+ }
610
+ if (subcommand === 'enable' || subcommand === 'disable') {
611
+ const enabling = subcommand === 'enable';
612
+ const globalPrefs = loadStoredPreferences();
613
+ const projectPrefs = loadStoredPreferences(config.cwd);
614
+ const allSkills = [
615
+ ...(globalPrefs.skills ?? []).map((s) => ({ ...s, scope: 'global' })),
616
+ ...(projectPrefs.skills ?? []).map((s) => ({ ...s, scope: 'project' })),
617
+ ];
618
+ const filtered = allSkills.filter((s) => enabling ? s.enabled === false : s.enabled !== false);
619
+ if (filtered.length === 0) {
620
+ ctx.appendLog('system', 'Plugins', `No plugins to ${subcommand}.`);
621
+ return;
622
+ }
623
+ const targetId = args[1] || await ctx.openPicker({
624
+ title: `${enabling ? 'Enable' : 'Disable'} plugin`,
625
+ items: filtered.map((s) => ({
626
+ id: `${s.scope}:${s.id}`,
627
+ label: `${s.id} [${s.scope}]`,
628
+ description: s.description,
629
+ })),
630
+ });
631
+ if (!targetId)
632
+ return;
633
+ const [targetScope, ...idParts] = targetId.split(':');
634
+ const skillId = idParts.join(':');
635
+ const baseDir = targetScope === 'project' ? config.cwd : undefined;
636
+ const prefs = loadStoredPreferences(baseDir);
637
+ const updatedSkills = (prefs.skills ?? []).map((s) => s.id === skillId ? { ...s, enabled: enabling ? undefined : false } : s);
638
+ saveStoredPreferences({ ...prefs, skills: updatedSkills }, baseDir);
639
+ const mergedPrefs = loadStoredPreferences();
640
+ const mergedProjectPrefs = loadStoredPreferences(config.cwd);
641
+ const finalSkills = resolveConfiguredSkills([
642
+ ...(mergedPrefs.skills ?? []),
643
+ ...(mergedProjectPrefs.skills ?? []),
644
+ ]);
645
+ await ctx.rebuildAgent({ ...config, skills: finalSkills }, `Plugin "${skillId}" ${enabling ? 'enabled' : 'disabled'}.`);
646
+ return;
647
+ }
648
+ ctx.appendLog('error', 'Unknown subcommand', `Usage: /plugins [list|add|install|browse|remove|enable|disable]`);
649
+ return;
650
+ }
651
+ case 'quit':
652
+ case 'exit':
653
+ ctx.exit();
654
+ return;
655
+ default:
656
+ ctx.appendLog('error', 'Unknown command', command);
657
+ }
658
+ }
659
+ function helpText() {
660
+ const sectionOrder = ['commands', 'plugins', 'session', 'model'];
661
+ const lines = [];
662
+ for (const section of sectionOrder) {
663
+ if (lines.length > 0)
664
+ lines.push('');
665
+ lines.push(SLASH_COMMAND_SECTION_TITLES[section]);
666
+ const sectionCommands = SLASH_COMMANDS.filter((item) => item.section === section);
667
+ const maxUsageLength = sectionCommands.reduce((max, item) => Math.max(max, item.usage.length), 0);
668
+ for (const item of sectionCommands) {
669
+ lines.push(` ${item.usage.padEnd(maxUsageLength)} ${item.description}`);
670
+ }
671
+ }
672
+ return lines.join('\n');
673
+ }