@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 +1 -1
- package/src/welcome.test.ts +85 -0
- package/src/welcome.ts +139 -9
package/package.json
CHANGED
package/src/welcome.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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) => {
|