@xynogen/pix-welcome 0.1.2 → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-welcome",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Pi extension — welcome banner with startup health checks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,12 +1,17 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import {
3
6
  type CheckResult,
7
+ countSkillsInDirs,
4
8
  LABEL_WIDTH,
5
9
  LOGO_ROWS,
6
10
  PI_IGNORE_RULES,
7
11
  renderCheck,
8
12
  shortCwd,
9
13
  statusIcon,
14
+ summariseSkills,
10
15
  summariseTools,
11
16
  type Theme,
12
17
  } from "./welcome.ts";
@@ -117,6 +122,86 @@ describe("summariseTools", () => {
117
122
  });
118
123
  });
119
124
 
125
+ describe("summariseSkills", () => {
126
+ it("warns when no skills loaded", () => {
127
+ const r = summariseSkills([]);
128
+ expect(r.status).toBe("warn");
129
+ expect(r.detail).toBe("none loaded");
130
+ });
131
+
132
+ it("reports count when all skills are auto-invocable", () => {
133
+ const r = summariseSkills([{}, {}, {}]);
134
+ expect(r.status).toBe("ok");
135
+ expect(r.detail).toBe("3 loaded");
136
+ });
137
+
138
+ it("notes manual skills in detail", () => {
139
+ const r = summariseSkills([{}, { disableModelInvocation: true }, {}]);
140
+ expect(r.status).toBe("ok");
141
+ expect(r.detail).toBe("3 loaded (+1 manual)");
142
+ });
143
+
144
+ it("marks all-manual as manual", () => {
145
+ const r = summariseSkills([
146
+ { disableModelInvocation: true },
147
+ { disableModelInvocation: true },
148
+ ]);
149
+ expect(r.status).toBe("ok");
150
+ expect(r.detail).toBe("2 loaded (manual)");
151
+ });
152
+ });
153
+
154
+ describe("countSkillsInDirs", () => {
155
+ it("returns 0 for nonexistent dirs", () => {
156
+ expect(countSkillsInDirs(["/nonexistent/path/xyz"])).toBe(0);
157
+ });
158
+
159
+ it("counts flat .md files", () => {
160
+ const dir = mkdtempSync(join(tmpdir(), "pix-skills-"));
161
+ try {
162
+ writeFileSync(
163
+ join(dir, "commit.md"),
164
+ "---\nname: commit\ndescription: test\n---\n",
165
+ );
166
+ writeFileSync(
167
+ join(dir, "debug.md"),
168
+ "---\nname: debug\ndescription: test\n---\n",
169
+ );
170
+ expect(countSkillsInDirs([dir])).toBe(2);
171
+ } finally {
172
+ rmSync(dir, { recursive: true });
173
+ }
174
+ });
175
+
176
+ it("counts subdir SKILL.md layout", () => {
177
+ const dir = mkdtempSync(join(tmpdir(), "pix-skills-"));
178
+ try {
179
+ mkdirSync(join(dir, "my-skill"));
180
+ writeFileSync(
181
+ join(dir, "my-skill", "SKILL.md"),
182
+ "---\nname: my-skill\ndescription: test\n---\n",
183
+ );
184
+ expect(countSkillsInDirs([dir])).toBe(1);
185
+ } finally {
186
+ rmSync(dir, { recursive: true });
187
+ }
188
+ });
189
+
190
+ it("deduplicates across dirs", () => {
191
+ const dir = mkdtempSync(join(tmpdir(), "pix-skills-"));
192
+ try {
193
+ writeFileSync(
194
+ join(dir, "foo.md"),
195
+ "---\nname: foo\ndescription: test\n---\n",
196
+ );
197
+ // same dir twice — should still count 1
198
+ expect(countSkillsInDirs([dir, dir])).toBe(1);
199
+ } finally {
200
+ rmSync(dir, { recursive: true });
201
+ }
202
+ });
203
+ });
204
+
120
205
  describe("PI_IGNORE_RULES", () => {
121
206
  it("includes both rules", () => {
122
207
  expect(PI_IGNORE_RULES).toEqual([".pi/", ".pi-lens/"]);
package/src/welcome.ts CHANGED
@@ -27,6 +27,9 @@
27
27
  * ✓ Ignore up to date
28
28
  */
29
29
 
30
+ import { existsSync, readdirSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join, resolve } from "node:path";
30
33
  import type {
31
34
  ExtensionAPI,
32
35
  ExtensionContext,
@@ -170,6 +173,116 @@ async function checkPiIgnore(
170
173
  }
171
174
  }
172
175
 
176
+ interface SkillInfo {
177
+ disableModelInvocation?: boolean;
178
+ }
179
+
180
+ /**
181
+ * Discover all `skills/` directories from:
182
+ * 1. The pi-agent npm extensions dir (walks one scope level deep)
183
+ * 2. ~/.pi/agent/skills (user-level)
184
+ * 3. Any extra dirs passed by the caller
185
+ *
186
+ * Mirrors how pi's resource-loader collects skillPaths from resources_discover.
187
+ * Does NOT depend on before_agent_start — safe to call at session_start.
188
+ */
189
+ export function discoverSkillDirs(extraDirs: string[] = []): string[] {
190
+ const npmDir = join(homedir(), ".pi", "agent", "npm", "node_modules");
191
+ const found: string[] = [];
192
+
193
+ // Walk npm extensions dir: flat packages + scoped (@scope/pkg)
194
+ if (existsSync(npmDir)) {
195
+ try {
196
+ for (const pkg of readdirSync(npmDir, { withFileTypes: true })) {
197
+ if (!pkg.isDirectory() && !pkg.isSymbolicLink()) continue;
198
+ if (pkg.name.startsWith("@")) {
199
+ // scoped: walk one level deeper
200
+ const scopeDir = join(npmDir, pkg.name);
201
+ try {
202
+ for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
203
+ if (!sub.isDirectory() && !sub.isSymbolicLink()) continue;
204
+ const skillsDir = join(scopeDir, sub.name, "skills");
205
+ if (existsSync(skillsDir)) found.push(skillsDir);
206
+ }
207
+ } catch {
208
+ /* skip */
209
+ }
210
+ } else {
211
+ const skillsDir = join(npmDir, pkg.name, "skills");
212
+ if (existsSync(skillsDir)) found.push(skillsDir);
213
+ }
214
+ }
215
+ } catch {
216
+ /* skip */
217
+ }
218
+ }
219
+
220
+ // User-level skills dir
221
+ const userSkills = join(homedir(), ".pi", "agent", "skills");
222
+ if (existsSync(userSkills)) found.push(userSkills);
223
+
224
+ found.push(...extraDirs);
225
+ return found;
226
+ }
227
+
228
+ /**
229
+ * Count skills in an explicit list of skill directories.
230
+ * Deduplicates by resolved real path to avoid double-counting symlinked packages.
231
+ */
232
+ export function countSkillsInDirs(dirs: string[]): number {
233
+ let total = 0;
234
+ const seen = new Set<string>();
235
+
236
+ const add = (p: string) => {
237
+ const real = (() => {
238
+ try {
239
+ return resolve(p);
240
+ } catch {
241
+ return p;
242
+ }
243
+ })();
244
+ if (seen.has(real)) return;
245
+ seen.add(real);
246
+ total++;
247
+ };
248
+
249
+ for (const dir of dirs) {
250
+ if (!existsSync(dir)) continue;
251
+ try {
252
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
253
+ if (entry.isDirectory()) {
254
+ const skillMd = join(dir, entry.name, "SKILL.md");
255
+ if (existsSync(skillMd)) add(skillMd);
256
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
257
+ add(join(dir, entry.name));
258
+ }
259
+ }
260
+ } catch {
261
+ /* skip */
262
+ }
263
+ }
264
+ return total;
265
+ }
266
+
267
+ /** Count skills across all auto-discovered + extra dirs. */
268
+ export function countSkillsFromDirs(extraDirs: string[] = []): number {
269
+ return countSkillsInDirs(discoverSkillDirs(extraDirs));
270
+ }
271
+
272
+ export function summariseSkills(skills: SkillInfo[]): CheckResult {
273
+ const total = skills.length;
274
+ if (total === 0)
275
+ return { label: "Skills", status: "warn", detail: "none loaded" };
276
+ const manual = skills.filter((s) => s.disableModelInvocation).length;
277
+ const detail =
278
+ manual === total
279
+ ? `${total} loaded (manual)`
280
+ : manual > 0
281
+ ? `${total} loaded (+${manual} manual)`
282
+ : `${total} loaded`;
283
+ return { label: "Skills", status: "ok", detail };
284
+ }
285
+
173
286
  interface ToolInfo {
174
287
  sourceInfo?: { source?: string };
175
288
  }
@@ -285,6 +398,7 @@ function buildCheckLines(theme: Theme, checks: CheckResult[]): string[] {
285
398
  export default function (pi: ExtensionAPI) {
286
399
  let dismissed = false;
287
400
  let requestRender: (() => void) | null = null;
401
+ let _updateSkills: ((r: CheckResult) => void) | null = null;
288
402
 
289
403
  const dismiss = (ctx: ExtensionContext) => {
290
404
  if (dismissed) return;
@@ -306,6 +420,7 @@ export default function (pi: ExtensionAPI) {
306
420
  { label: "Auth", status: "pending" },
307
421
  { label: "Models", status: "pending" },
308
422
  { label: "Tools", status: "pending" },
423
+ { label: "Skills", status: "pending" },
309
424
  { label: "Ignore", status: "pending" },
310
425
  ];
311
426
 
@@ -344,20 +459,35 @@ export default function (pi: ExtensionAPI) {
344
459
  requestRender?.();
345
460
  };
346
461
 
462
+ // Expose skills updater so the before_agent_start handler can refine the count.
463
+ _updateSkills = (r: CheckResult) => update(4, r);
464
+
347
465
  void checkPiVersion(pi).then((r) => update(0, r));
348
- void checkPiIgnore(pi, ctx.cwd).then((r) => update(4, r));
466
+ void checkPiIgnore(pi, ctx.cwd).then((r) => update(5, r));
349
467
  // auth already filled synchronously above; no async needed
350
468
 
351
469
  // Tools register during session_start (incl. other extensions); read on
352
470
  // next tick so dynamically registered tools are counted.
353
- setTimeout(
354
- () =>
355
- update(
356
- 3,
357
- checkTools(pi as unknown as { getActiveTools?: () => ToolInfo[] }),
358
- ),
359
- 0,
360
- );
471
+ // Skills: scan dirs immediately — no need to wait for before_agent_start.
472
+ setTimeout(() => {
473
+ update(
474
+ 3,
475
+ checkTools(pi as unknown as { getActiveTools?: () => ToolInfo[] }),
476
+ );
477
+ update(4, {
478
+ label: "Skills",
479
+ status: "ok",
480
+ detail: `${countSkillsFromDirs()} loaded`,
481
+ });
482
+ }, 0);
483
+ });
484
+
485
+ // before_agent_start fires after resources_discover — use the authoritative
486
+ // skills list from systemPromptOptions to refine the count, then dismiss.
487
+ pi.on("before_agent_start", (event, ctx) => {
488
+ const skills = event.systemPromptOptions?.skills ?? [];
489
+ if (skills.length > 0) _updateSkills?.(summariseSkills(skills));
490
+ dismiss(ctx);
361
491
  });
362
492
 
363
493
  pi.on("turn_start", (_event, ctx) => {