@xynogen/pix-welcome 0.1.3 → 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.3",
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,6 +1,10 @@
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,
@@ -125,25 +129,76 @@ describe("summariseSkills", () => {
125
129
  expect(r.detail).toBe("none loaded");
126
130
  });
127
131
 
128
- it("reports count when skills present", () => {
132
+ it("reports count when all skills are auto-invocable", () => {
129
133
  const r = summariseSkills([{}, {}, {}]);
130
134
  expect(r.status).toBe("ok");
131
135
  expect(r.detail).toBe("3 loaded");
132
136
  });
133
137
 
134
- it("excludes skills with disableModelInvocation", () => {
138
+ it("notes manual skills in detail", () => {
135
139
  const r = summariseSkills([{}, { disableModelInvocation: true }, {}]);
136
140
  expect(r.status).toBe("ok");
137
- expect(r.detail).toBe("2 loaded");
141
+ expect(r.detail).toBe("3 loaded (+1 manual)");
138
142
  });
139
143
 
140
- it("warns when all skills are disabled", () => {
144
+ it("marks all-manual as manual", () => {
141
145
  const r = summariseSkills([
142
146
  { disableModelInvocation: true },
143
147
  { disableModelInvocation: true },
144
148
  ]);
145
- expect(r.status).toBe("warn");
146
- expect(r.detail).toBe("none loaded");
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
+ }
147
202
  });
148
203
  });
149
204
 
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,
@@ -174,11 +177,110 @@ interface SkillInfo {
174
177
  disableModelInvocation?: boolean;
175
178
  }
176
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
+
177
272
  export function summariseSkills(skills: SkillInfo[]): CheckResult {
178
- const count = skills.filter((s) => !s.disableModelInvocation).length;
179
- return count > 0
180
- ? { label: "Skills", status: "ok", detail: `${count} loaded` }
181
- : { label: "Skills", status: "warn", detail: "none loaded" };
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 };
182
284
  }
183
285
 
184
286
  interface ToolInfo {
@@ -357,7 +459,7 @@ export default function (pi: ExtensionAPI) {
357
459
  requestRender?.();
358
460
  };
359
461
 
360
- // Expose skills updater so the before_agent_start handler can fill it in.
462
+ // Expose skills updater so the before_agent_start handler can refine the count.
361
463
  _updateSkills = (r: CheckResult) => update(4, r);
362
464
 
363
465
  void checkPiVersion(pi).then((r) => update(0, r));
@@ -366,31 +468,26 @@ export default function (pi: ExtensionAPI) {
366
468
 
367
469
  // Tools register during session_start (incl. other extensions); read on
368
470
  // next tick so dynamically registered tools are counted.
369
- setTimeout(
370
- () =>
371
- update(
372
- 3,
373
- checkTools(pi as unknown as { getActiveTools?: () => ToolInfo[] }),
374
- ),
375
- 0,
376
- );
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);
377
483
  });
378
484
 
379
- // Skills count is only available once resources_discover has run, which
380
- // happens after session_start. Grab it from the first before_agent_start
381
- // (guaranteed to have skills in systemPromptOptions) and update the banner.
382
- let skillsChecked = false;
383
- pi.on("before_agent_start", (event) => {
384
- if (skillsChecked) return;
385
- skillsChecked = true;
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) => {
386
488
  const skills = event.systemPromptOptions?.skills ?? [];
387
- const result = summariseSkills(skills);
388
- // Banner may already be dismissed by the time the first prompt fires —
389
- // update anyway so it's accurate if still visible.
390
- requestRender?.();
391
- // Reach into CHECKS via the closure captured above per session.
392
- // We store the updater so each session's banner is independent.
393
- _updateSkills?.(result);
489
+ if (skills.length > 0) _updateSkills?.(summariseSkills(skills));
490
+ dismiss(ctx);
394
491
  });
395
492
 
396
493
  pi.on("turn_start", (_event, ctx) => {