@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 +1 -1
- package/src/welcome.test.ts +61 -6
- package/src/welcome.ts +124 -27
package/package.json
CHANGED
package/src/welcome.test.ts
CHANGED
|
@@ -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
|
|
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("
|
|
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("
|
|
141
|
+
expect(r.detail).toBe("3 loaded (+1 manual)");
|
|
138
142
|
});
|
|
139
143
|
|
|
140
|
-
it("
|
|
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("
|
|
146
|
-
expect(r.detail).toBe("
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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) => {
|