my-pi 0.0.2 → 0.0.4

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.
@@ -1,27 +1,194 @@
1
1
  import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
- import { create_skills_manager } from '../skills/manager.js';
2
+ import {
3
+ Container,
4
+ SettingsList,
5
+ Text,
6
+ type SettingItem,
7
+ } from '@mariozechner/pi-tui';
8
+ import {
9
+ create_skills_manager,
10
+ type ManagedSkill,
11
+ } from '../skills/manager.js';
12
+
13
+ const ENABLED = '[x]';
14
+ const DISABLED = '[ ]';
15
+ const SYNC = '[~]';
16
+ const IMPORTED_LABEL = '[=]';
17
+
18
+ function sort_skills(skills: ManagedSkill[]): ManagedSkill[] {
19
+ return [...skills].sort((a, b) => {
20
+ const by_name = a.name.localeCompare(b.name);
21
+ if (by_name !== 0) return by_name;
22
+ const by_source = a.source.localeCompare(b.source);
23
+ if (by_source !== 0) return by_source;
24
+ return a.key.localeCompare(b.key);
25
+ });
26
+ }
27
+
28
+ function find_matching_imported_skill(
29
+ managed_skills: ManagedSkill[],
30
+ skill: ManagedSkill,
31
+ ): ManagedSkill | undefined {
32
+ const exact_match = managed_skills.find(
33
+ (candidate) =>
34
+ candidate.import_meta?.source === skill.source &&
35
+ (candidate.import_meta.upstream_skill_path ===
36
+ skill.skillPath ||
37
+ candidate.import_meta.upstream_base_dir === skill.baseDir),
38
+ );
39
+ if (exact_match) return exact_match;
40
+
41
+ return managed_skills.find(
42
+ (candidate) =>
43
+ candidate.import_meta?.source === skill.source &&
44
+ candidate.name === skill.name,
45
+ );
46
+ }
47
+
48
+ function get_importable_state(
49
+ managed_skills: ManagedSkill[],
50
+ skill: ManagedSkill,
51
+ ): {
52
+ label: string;
53
+ detail: string;
54
+ action: 'import' | 'sync' | null;
55
+ } {
56
+ const imported = find_matching_imported_skill(
57
+ managed_skills,
58
+ skill,
59
+ );
60
+ if (imported?.import_meta) {
61
+ const version_changed = Boolean(
62
+ skill.plugin?.version &&
63
+ imported.import_meta.upstream_version &&
64
+ skill.plugin.version !== imported.import_meta.upstream_version,
65
+ );
66
+ const sha_changed = Boolean(
67
+ skill.plugin?.gitCommitSha &&
68
+ imported.import_meta.upstream_git_commit_sha &&
69
+ skill.plugin.gitCommitSha !==
70
+ imported.import_meta.upstream_git_commit_sha,
71
+ );
72
+
73
+ if (version_changed || sha_changed) {
74
+ return {
75
+ label: 'sync',
76
+ detail: 'Press Enter to sync the imported copy and reload',
77
+ action: 'sync',
78
+ };
79
+ }
80
+
81
+ return {
82
+ label: 'imported',
83
+ detail: `Already imported to ${imported.baseDir}`,
84
+ action: null,
85
+ };
86
+ }
87
+
88
+ const managed_conflict = managed_skills.find(
89
+ (candidate) => candidate.name === skill.name,
90
+ );
91
+ if (managed_conflict) {
92
+ return {
93
+ label: 'managed',
94
+ detail: `Already managed at ${managed_conflict.baseDir}`,
95
+ action: null,
96
+ };
97
+ }
98
+
99
+ return {
100
+ label: 'import',
101
+ detail: 'Press Enter to import into pi-native skills and reload',
102
+ action: 'import',
103
+ };
104
+ }
105
+
106
+ function to_setting_item(skill: ManagedSkill): SettingItem {
107
+ const detail_lines = [
108
+ `${skill.source} • ${skill.key}`,
109
+ skill.description,
110
+ skill.baseDir,
111
+ ];
112
+ if (skill.import_meta?.upstream_version) {
113
+ detail_lines.push(
114
+ `upstream: ${skill.import_meta.upstream_version}${skill.import_meta.upstream_git_commit_sha ? ` • ${skill.import_meta.upstream_git_commit_sha.slice(0, 12)}` : ''}`,
115
+ );
116
+ }
117
+
118
+ return {
119
+ id: skill.key,
120
+ label: skill.name,
121
+ description: detail_lines.join('\n'),
122
+ currentValue: skill.enabled ? ENABLED : DISABLED,
123
+ values: [ENABLED, DISABLED],
124
+ };
125
+ }
126
+
127
+ function to_importable_setting_item(
128
+ managed_skills: ManagedSkill[],
129
+ skill: ManagedSkill,
130
+ ): SettingItem {
131
+ const state = get_importable_state(managed_skills, skill);
132
+ const detail_lines = [
133
+ `${skill.source} • ${skill.key}`,
134
+ skill.description,
135
+ skill.baseDir,
136
+ ];
137
+ if (skill.plugin?.version) {
138
+ detail_lines.push(
139
+ `plugin: ${skill.plugin.version}${skill.plugin.gitCommitSha ? ` • ${skill.plugin.gitCommitSha.slice(0, 12)}` : ''}`,
140
+ );
141
+ }
142
+
143
+ if (state.action === 'import') {
144
+ return {
145
+ id: skill.key,
146
+ label: skill.name,
147
+ description: detail_lines.join('\n'),
148
+ currentValue: DISABLED,
149
+ values: [ENABLED, DISABLED],
150
+ };
151
+ }
152
+
153
+ if (state.action === 'sync') {
154
+ detail_lines.push('enter to sync');
155
+ return {
156
+ id: skill.key,
157
+ label: skill.name,
158
+ description: detail_lines.join('\n'),
159
+ currentValue: SYNC,
160
+ values: [SYNC],
161
+ };
162
+ }
163
+
164
+ detail_lines.push(state.detail);
165
+ return {
166
+ id: skill.key,
167
+ label: skill.name,
168
+ description: detail_lines.join('\n'),
169
+ currentValue: IMPORTED_LABEL,
170
+ };
171
+ }
172
+
173
+ function sets_equal(
174
+ a: ReadonlySet<string>,
175
+ b: ReadonlySet<string>,
176
+ ): boolean {
177
+ if (a.size !== b.size) return false;
178
+ for (const value of a) {
179
+ if (!b.has(value)) return false;
180
+ }
181
+ return true;
182
+ }
3
183
 
4
184
  // Default export for Pi Package / additionalExtensionPaths loading
5
185
  export default async function skills(pi: ExtensionAPI) {
6
186
  const mgr = create_skills_manager();
7
187
 
8
- // Feed only explicitly enabled skills into pi
9
- pi.on('resources_discover', () => ({
10
- skillPaths: mgr.get_enabled_skill_paths(),
11
- }));
12
-
13
- const subs = [
14
- 'discover',
15
- 'enable',
16
- 'disable',
17
- 'toggle',
18
- 'search',
19
- 'refresh',
20
- 'defaults',
21
- ];
188
+ const subs = ['import', 'sync', 'refresh', 'defaults'];
22
189
 
23
190
  pi.registerCommand('skills', {
24
- description: 'Discover and manage skills',
191
+ description: 'Manage pi-native skills and import external skills',
25
192
  getArgumentCompletions: (prefix) => {
26
193
  const parts = prefix.trim().split(/\s+/);
27
194
  if (parts.length <= 1) {
@@ -29,94 +196,348 @@ export default async function skills(pi: ExtensionAPI) {
29
196
  .filter((s) => s.startsWith(parts[0] || ''))
30
197
  .map((s) => ({ value: s, label: s }));
31
198
  }
32
- if (['enable', 'disable', 'toggle'].includes(parts[0])) {
199
+
200
+ if (parts[0] === 'import') {
33
201
  const q = parts.slice(1).join(' ').toLowerCase();
34
- return mgr
35
- .discover()
36
- .filter((s) => s.key.toLowerCase().includes(q))
202
+ return sort_skills(mgr.discover_importable())
203
+ .filter(
204
+ (s) =>
205
+ s.key.toLowerCase().includes(q) ||
206
+ s.name.toLowerCase().includes(q),
207
+ )
37
208
  .slice(0, 20)
38
209
  .map((s) => ({
39
210
  value: `${parts[0]} ${s.key}`,
40
- label: `${s.key} ${s.enabled ? '[on]' : '[off]'}`,
211
+ label: s.key,
41
212
  }));
42
213
  }
214
+
215
+ if (parts[0] === 'sync') {
216
+ const q = parts.slice(1).join(' ').toLowerCase();
217
+ return sort_skills(
218
+ mgr
219
+ .discover()
220
+ .filter((skill) => Boolean(skill.import_meta)),
221
+ )
222
+ .filter(
223
+ (s) =>
224
+ s.key.toLowerCase().includes(q) ||
225
+ s.name.toLowerCase().includes(q),
226
+ )
227
+ .slice(0, 20)
228
+ .map((s) => ({
229
+ value: `${parts[0]} ${s.key}`,
230
+ label: s.key,
231
+ }));
232
+ }
233
+
43
234
  return null;
44
235
  },
45
236
  handler: async (args, ctx) => {
46
- const [sub, ...rest] = args.trim().split(/\s+/);
47
- const arg = rest.join(' ');
237
+ const trimmed = args.trim();
48
238
 
49
- switch (sub || 'discover') {
50
- case 'discover': {
51
- const skills = mgr.discover();
52
- if (skills.length === 0) {
53
- ctx.ui.notify('No skills found');
54
- return;
239
+ if (!trimmed && ctx.hasUI) {
240
+ const discovered = sort_skills(mgr.discover());
241
+ const importable = sort_skills(mgr.discover_importable());
242
+ if (discovered.length === 0 && importable.length === 0) {
243
+ ctx.ui.notify('No managed or importable skills found');
244
+ return;
245
+ }
246
+
247
+ const initial_enabled = new Set(
248
+ discovered
249
+ .filter((skill) => skill.enabled)
250
+ .map((skill) => skill.key),
251
+ );
252
+ const current_enabled = new Set(initial_enabled);
253
+ const queued_imports = new Set<string>();
254
+ let reload_notice: string | null = null;
255
+
256
+ const managed_items = discovered.map(to_setting_item);
257
+ const importable_items = importable.map((skill) =>
258
+ to_importable_setting_item(discovered, skill),
259
+ );
260
+
261
+ const all_items: SettingItem[] = [];
262
+ if (managed_items.length > 0) {
263
+ all_items.push({
264
+ id: '__header_managed__',
265
+ label: `── Managed (${managed_items.length}) ──`,
266
+ description: '',
267
+ currentValue: '',
268
+ });
269
+ all_items.push(...managed_items);
270
+ }
271
+ if (importable_items.length > 0) {
272
+ all_items.push({
273
+ id: '__header_importable__',
274
+ label: `── Importable (${importable_items.length}) ──`,
275
+ description: '',
276
+ currentValue: '',
277
+ });
278
+ all_items.push(...importable_items);
279
+ }
280
+
281
+ const managed_keys = new Set(discovered.map((s) => s.key));
282
+ const importable_map = new Map(
283
+ importable.map((s) => [s.key, s]),
284
+ );
285
+
286
+ await ctx.ui.custom((tui, theme, _kb, done) => {
287
+ const list = new SettingsList(
288
+ all_items,
289
+ Math.min(Math.max(all_items.length + 4, 8), 22),
290
+ {
291
+ cursor: theme.fg('accent', '›'),
292
+ label: (text, selected) => {
293
+ if (text.startsWith('──') && text.endsWith('──')) {
294
+ return theme.fg('dim', theme.bold(text));
295
+ }
296
+ return selected ? theme.fg('accent', text) : text;
297
+ },
298
+ value: (text, selected) => {
299
+ const color =
300
+ text === ENABLED
301
+ ? ('success' as const)
302
+ : text === SYNC
303
+ ? ('warning' as const)
304
+ : text === IMPORTED_LABEL
305
+ ? ('success' as const)
306
+ : ('dim' as const);
307
+ const rendered = theme.fg(color, text);
308
+ return selected
309
+ ? theme.bold(theme.fg('accent', rendered))
310
+ : rendered;
311
+ },
312
+ description: (text) => theme.fg('muted', text),
313
+ hint: (text) => theme.fg('dim', text),
314
+ },
315
+ (id, new_value) => {
316
+ if (id.startsWith('__header_')) return;
317
+
318
+ if (managed_keys.has(id)) {
319
+ if (new_value === ENABLED) {
320
+ current_enabled.add(id);
321
+ mgr.enable(id);
322
+ } else {
323
+ current_enabled.delete(id);
324
+ mgr.disable(id);
325
+ }
326
+ return;
327
+ }
328
+
329
+ const import_skill = importable_map.get(id);
330
+ if (!import_skill) return;
331
+
332
+ const state = get_importable_state(
333
+ discovered,
334
+ import_skill,
335
+ );
336
+
337
+ if (state.action === 'import') {
338
+ if (new_value === ENABLED) {
339
+ queued_imports.add(id);
340
+ } else {
341
+ queued_imports.delete(id);
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (state.action === 'sync') {
347
+ const imported_skill = find_matching_imported_skill(
348
+ discovered,
349
+ import_skill,
350
+ );
351
+ if (!imported_skill) {
352
+ ctx.ui.notify(
353
+ `Imported copy for ${import_skill.name} was not found`,
354
+ 'warning',
355
+ );
356
+ return;
357
+ }
358
+ try {
359
+ const result = mgr.sync_skill(imported_skill.key);
360
+ if (result.changed) {
361
+ reload_notice = `Synced ${import_skill.name}. Reloading...`;
362
+ done(undefined);
363
+ } else {
364
+ ctx.ui.notify(
365
+ `${import_skill.name} is already up to date.`,
366
+ 'info',
367
+ );
368
+ }
369
+ } catch (error) {
370
+ ctx.ui.notify(
371
+ error instanceof Error
372
+ ? error.message
373
+ : String(error),
374
+ 'warning',
375
+ );
376
+ }
377
+ }
378
+ },
379
+ () => done(undefined),
380
+ { enableSearch: true },
381
+ );
382
+
383
+ const container = new Container();
384
+
385
+ container.addChild({
386
+ render: () => {
387
+ const enabled = current_enabled.size;
388
+ const disabled = discovered.length - enabled;
389
+ const queued = queued_imports.size;
390
+ const parts = [
391
+ `${enabled} enabled`,
392
+ `${disabled} disabled`,
393
+ ];
394
+ if (importable.length > 0) {
395
+ parts.push(`${importable.length} importable`);
396
+ }
397
+ if (queued > 0) {
398
+ parts.push(`${queued} queued for import`);
399
+ }
400
+ return [
401
+ theme.fg('accent', theme.bold('Skills')),
402
+ theme.fg('muted', parts.join(' • ')),
403
+ '',
404
+ ];
405
+ },
406
+ invalidate: () => {},
407
+ });
408
+
409
+ container.addChild({
410
+ render(width: number) {
411
+ return list.render(width);
412
+ },
413
+ invalidate() {
414
+ list.invalidate();
415
+ },
416
+ });
417
+
418
+ container.addChild(
419
+ new Text(
420
+ theme.fg(
421
+ 'dim',
422
+ 'search filters • enter toggles • esc close',
423
+ ),
424
+ 0,
425
+ 1,
426
+ ),
427
+ );
428
+
429
+ return {
430
+ render(width: number) {
431
+ return container.render(width);
432
+ },
433
+ invalidate() {
434
+ container.invalidate();
435
+ },
436
+ handleInput(data: string) {
437
+ list.handleInput(data);
438
+ tui.requestRender();
439
+ },
440
+ };
441
+ });
442
+
443
+ if (queued_imports.size > 0) {
444
+ const imported_names: string[] = [];
445
+ for (const key of queued_imports) {
446
+ try {
447
+ mgr.import_skill(key);
448
+ imported_names.push(key);
449
+ } catch (error) {
450
+ ctx.ui.notify(
451
+ error instanceof Error
452
+ ? error.message
453
+ : String(error),
454
+ 'warning',
455
+ );
456
+ }
55
457
  }
56
- const on = skills.filter((s) => s.enabled).length;
57
- const off = skills.length - on;
58
- const lines: string[] = [
59
- `${skills.length} skills (${on} enabled, ${off} disabled)\n`,
60
- ];
61
- for (const s of skills) {
62
- lines.push(` ${s.enabled ? '+' : '-'} ${s.key}`);
63
- lines.push(` ${s.description.slice(0, 80)}`);
458
+ if (imported_names.length > 0) {
459
+ reload_notice = `Imported ${imported_names.length} skill(s). Reloading...`;
64
460
  }
65
- ctx.ui.notify(lines.join('\n'));
66
- break;
67
461
  }
68
- case 'enable': {
69
- if (!arg) {
70
- ctx.ui.notify('Usage: /skills enable <key>', 'warning');
71
- return;
72
- }
73
- mgr.enable(arg);
74
- ctx.ui.notify(`Enabled ${arg}. /reload to apply.`);
75
- break;
462
+
463
+ if (reload_notice) {
464
+ ctx.ui.notify(reload_notice, 'info');
465
+ await ctx.reload();
466
+ return;
76
467
  }
77
- case 'disable': {
468
+
469
+ if (!sets_equal(initial_enabled, current_enabled)) {
470
+ ctx.ui.notify(
471
+ 'Reloading to apply updated skills...',
472
+ 'info',
473
+ );
474
+ await ctx.reload();
475
+ return;
476
+ }
477
+
478
+ return;
479
+ }
480
+
481
+ const [sub, ...rest] = (trimmed || 'list').split(/\s+/);
482
+ const arg = rest.join(' ');
483
+
484
+ switch (sub) {
485
+ case 'import': {
78
486
  if (!arg) {
79
- ctx.ui.notify('Usage: /skills disable <key>', 'warning');
487
+ ctx.ui.notify(
488
+ 'Usage: /skills import <key|name>',
489
+ 'warning',
490
+ );
80
491
  return;
81
492
  }
82
- mgr.disable(arg);
83
- ctx.ui.notify(`Disabled ${arg}. /reload to apply.`);
84
- break;
85
- }
86
- case 'toggle': {
87
- if (!arg) {
88
- ctx.ui.notify('Usage: /skills toggle <key>', 'warning');
493
+ try {
494
+ const result = mgr.import_skill(arg);
495
+ ctx.ui.notify(
496
+ `Imported ${arg} to ${result.skillDir}. Reloading...`,
497
+ 'info',
498
+ );
499
+ await ctx.reload();
500
+ return;
501
+ } catch (error) {
502
+ ctx.ui.notify(
503
+ error instanceof Error ? error.message : String(error),
504
+ 'warning',
505
+ );
89
506
  return;
90
507
  }
91
- const state = mgr.toggle(arg);
92
- ctx.ui.notify(
93
- `${arg} ${state ? 'enabled' : 'disabled'}. /reload to apply.`,
94
- );
95
- break;
96
508
  }
97
- case 'search': {
509
+ case 'sync': {
98
510
  if (!arg) {
99
- ctx.ui.notify('Usage: /skills search <query>', 'warning');
511
+ ctx.ui.notify(
512
+ 'Usage: /skills sync <key|name>',
513
+ 'warning',
514
+ );
100
515
  return;
101
516
  }
102
- const results = mgr.search(arg);
103
- if (results.length === 0) {
104
- ctx.ui.notify(`No skills matching "${arg}"`);
517
+ try {
518
+ const result = mgr.sync_skill(arg);
519
+ ctx.ui.notify(
520
+ result.changed
521
+ ? `Synced ${arg}. Reloading...`
522
+ : `${arg} is already up to date.`,
523
+ 'info',
524
+ );
525
+ if (result.changed) {
526
+ await ctx.reload();
527
+ }
528
+ return;
529
+ } catch (error) {
530
+ ctx.ui.notify(
531
+ error instanceof Error ? error.message : String(error),
532
+ 'warning',
533
+ );
105
534
  return;
106
535
  }
107
- const lines = results.map(
108
- (s) =>
109
- `${s.enabled ? '+' : '-'} ${s.key}\n ${s.description.slice(0, 80)}`,
110
- );
111
- ctx.ui.notify(
112
- `${results.length} matches:\n ${lines.join('\n ')}`,
113
- );
114
- break;
115
536
  }
116
537
  case 'refresh': {
117
538
  mgr.refresh();
118
539
  ctx.ui.notify(
119
- `Rescanned: ${mgr.discover().length} skills found`,
540
+ `Rescanned: ${mgr.discover().length} managed skills, ${mgr.discover_importable().length} importable skills found`,
120
541
  );
121
542
  break;
122
543
  }