@stzhu/skills 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Zhu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @stzhu/skills
2
+
3
+ A collection of CLI tools (skills) for AI agents and developers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @stzhu/skills
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ The CLI is available as `stz-skills`. You can also run it via `pnpx`:
14
+
15
+ ```bash
16
+ pnpx @stzhu/skills <command>
17
+ ```
18
+
19
+ ### Agent Logbook
20
+
21
+ Manage and validate AI agent activity logs in the `.agent-logbook/` directory.
22
+
23
+ #### Get Session Stats
24
+
25
+ Retrieve token usage and model information for a specific agent session.
26
+
27
+ ```bash
28
+ # For Claude Code
29
+ pnpx @stzhu/skills agent-logbook stats <session-id> --agent claudecode
30
+
31
+ # For Gemini CLI
32
+ pnpx @stzhu/skills agent-logbook stats <session-id> --agent geminicli
33
+ ```
34
+
35
+ #### Validate Logbook Files
36
+
37
+ Validate the filenames and frontmatter of your logbook entries.
38
+
39
+ ```bash
40
+ # Validate all files in .agent-logbook/
41
+ pnpx @stzhu/skills agent-logbook validate
42
+
43
+ # Validate a specific file or directory
44
+ pnpx @stzhu/skills agent-logbook validate .agent-logbook/activity/
45
+ ```
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { n as app, t as buildContext } from "./context-SQBI_fVn.mjs";
3
+ import { proposeCompletions } from "@stricli/core";
4
+
5
+ //#region src/bin/bash-complete.ts
6
+ const inputs = process.argv.slice(3);
7
+ if (process.env["COMP_LINE"]?.endsWith(" ")) inputs.push("");
8
+ await proposeCompletions(app, inputs, buildContext(process));
9
+ try {
10
+ for (const { completion } of await proposeCompletions(app, inputs, buildContext(process))) process.stdout.write(`${completion}\n`);
11
+ } catch {}
12
+
13
+ //#endregion
14
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { n as app, t as buildContext } from "./context-SQBI_fVn.mjs";
3
+ import { run } from "@stricli/core";
4
+
5
+ //#region src/bin/cli.ts
6
+ await run(app, process.argv.slice(2), buildContext(process));
7
+
8
+ //#endregion
9
+ export { };
@@ -0,0 +1,102 @@
1
+ import { buildApplication, buildCommand, buildRouteMap } from "@stricli/core";
2
+ import { buildInstallCommand, buildUninstallCommand } from "@stricli/auto-complete";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { findWorkspacesRoot } from "find-workspaces";
7
+
8
+ //#region package.json
9
+ var version = "0.1.0";
10
+ var description = "Skills CLI";
11
+ var bin = {
12
+ "__stz-skills_bash_complete": "dist/bash-complete.mjs",
13
+ "stz-skills": "dist/cli.mjs"
14
+ };
15
+
16
+ //#endregion
17
+ //#region src/commands/agent-logbook/commands.ts
18
+ const statsCommand = buildCommand({
19
+ loader: async () => {
20
+ const { stats } = await import("./stats-RlcmzM9e.mjs");
21
+ return stats;
22
+ },
23
+ parameters: {
24
+ flags: { agent: {
25
+ kind: "enum",
26
+ brief: "The agent to get stats for",
27
+ values: ["claudecode", "geminicli"]
28
+ } },
29
+ positional: {
30
+ kind: "tuple",
31
+ parameters: [{
32
+ parse: String,
33
+ brief: "The session ID to get stats for"
34
+ }]
35
+ }
36
+ },
37
+ docs: { brief: "Agent logbook stats command" }
38
+ });
39
+ const validateCommand = buildCommand({
40
+ loader: async () => {
41
+ const { validate } = await import("./validate-D3AdsB3Q.mjs");
42
+ return validate;
43
+ },
44
+ parameters: { positional: {
45
+ kind: "tuple",
46
+ parameters: [{
47
+ parse: String,
48
+ brief: "The target file or directory to validate frontmatter for",
49
+ optional: true
50
+ }]
51
+ } },
52
+ docs: { brief: "Agent logbook validate command" }
53
+ });
54
+ const agentLogbookRoutes = buildRouteMap({
55
+ routes: {
56
+ stats: statsCommand,
57
+ validate: validateCommand
58
+ },
59
+ docs: { brief: "Agent logbook commands" }
60
+ });
61
+
62
+ //#endregion
63
+ //#region src/app.ts
64
+ const routes = buildRouteMap({
65
+ routes: {
66
+ "agent-logbook": agentLogbookRoutes,
67
+ install: buildInstallCommand("stz-skills", { bash: "__stz-skills_bash_complete" }),
68
+ uninstall: buildUninstallCommand("stz-skills", { bash: true })
69
+ },
70
+ docs: {
71
+ brief: description,
72
+ hideRoute: {
73
+ install: true,
74
+ uninstall: true
75
+ }
76
+ }
77
+ });
78
+ const app = buildApplication(routes, {
79
+ name: (() => {
80
+ const name = Object.keys(bin).find((key) => !key.startsWith("__"));
81
+ if (!name) throw new Error("Name not found");
82
+ return name;
83
+ })(),
84
+ versionInfo: { currentVersion: version }
85
+ });
86
+
87
+ //#endregion
88
+ //#region src/context.ts
89
+ function buildContext(process) {
90
+ const workspacesRoot = findWorkspacesRoot();
91
+ if (!workspacesRoot) throw new Error("Not in an npm project");
92
+ return {
93
+ process,
94
+ os,
95
+ fs,
96
+ path,
97
+ workspacesRoot: workspacesRoot.location
98
+ };
99
+ }
100
+
101
+ //#endregion
102
+ export { app as n, buildContext as t };
@@ -0,0 +1,341 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+
6
+ //#region src/util/defineCommandFunction.ts
7
+ /**
8
+ * Identity wrapper for strongly typed command implementations.
9
+ *
10
+ * Keeps generic flag/arg/context types attached to the command function so
11
+ * call sites get full inference and editor hints.
12
+ */
13
+ function defineCommandFunction(impl) {
14
+ return impl;
15
+ }
16
+
17
+ //#endregion
18
+ //#region src/commands/agent-logbook/stats/formatSessionStatsOutput.ts
19
+ /**
20
+ * Formats a StatsResult into a human-readable table/report for the CLI.
21
+ *
22
+ * @param agentName Name of the agent (e.g., ClaudeCode).
23
+ * @param sessionId Unique ID of the session.
24
+ * @param result Aggregated data to display.
25
+ */
26
+ function formatSessionStatsOutput(agentName, sessionId, result) {
27
+ const lines = [];
28
+ lines.push("");
29
+ lines.push(`${agentName} Session Stats: ${sessionId}`);
30
+ lines.push("========================================");
31
+ if (result.models.length > 0) {
32
+ const modelsMeta = result.meta?.filter(([label]) => label === "Models" || label === "");
33
+ if (modelsMeta && modelsMeta.length > 0) {
34
+ for (const [label, value] of modelsMeta) if (label === "Models") lines.push(`Models Used: ${value}`);
35
+ else if (label === "") lines.push(` ${value}`);
36
+ } else lines.push(`Models Used: ${result.models.join(", ") || "N/A"}`);
37
+ }
38
+ if (result.meta) {
39
+ for (const [label, value] of result.meta) if (label !== "Models" && label !== "") lines.push(`${label}: ${value}`);
40
+ }
41
+ for (const section of result.sections) {
42
+ lines.push("----------------------------------------");
43
+ lines.push(`${section.label}:`);
44
+ for (const [label, value] of section.entries) {
45
+ const num = typeof value === "number" ? value.toLocaleString() : String(value);
46
+ lines.push(` ${label.padEnd(20)} ${num}`);
47
+ }
48
+ }
49
+ if (result.summary) {
50
+ lines.push("----------------------------------------");
51
+ lines.push(`${result.summary.label}:`);
52
+ for (const [label, value] of result.summary.entries) {
53
+ const num = typeof value === "number" ? value.toLocaleString() : String(value);
54
+ lines.push(` ${label.padEnd(20)} ${num}`);
55
+ }
56
+ }
57
+ lines.push("----------------------------------------");
58
+ lines.push(`GRAND TOTAL TOKENS: ${typeof result.grandTotal === "number" ? result.grandTotal.toLocaleString() : result.grandTotal}`);
59
+ lines.push("========================================");
60
+ lines.push("");
61
+ return lines.join("\n");
62
+ }
63
+
64
+ //#endregion
65
+ //#region src/commands/agent-logbook/stats/defineSessionStatsPlugin.ts
66
+ /**
67
+ * Identity wrapper for strongly typed session stats plugin implementations.
68
+ *
69
+ * Keeps types attached to the plugin object so call sites get full inference
70
+ * and editor hints.
71
+ */
72
+ function defineSessionStatsPlugin(plugin) {
73
+ return plugin;
74
+ }
75
+
76
+ //#endregion
77
+ //#region src/commands/agent-logbook/stats/plugins/claudecode.ts
78
+ /** Base directory where Claude Code stores project and session metadata. */
79
+ const CLAUDECODE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
80
+ /**
81
+ * Parses a single JSONL file from Claude's logs to extract token usage.
82
+ * Only 'assistant' message types with 'usage' fields are counted.
83
+ *
84
+ * @param filePath Path to the .jsonl file.
85
+ */
86
+ async function parseJsonlFile(filePath) {
87
+ const stats = {
88
+ input_tokens: 0,
89
+ output_tokens: 0,
90
+ cache_creation_input_tokens: 0,
91
+ cache_read_input_tokens: 0,
92
+ models: /* @__PURE__ */ new Set()
93
+ };
94
+ const fileStream = fs.createReadStream(filePath);
95
+ const rl = readline.createInterface({
96
+ input: fileStream,
97
+ crlfDelay: Infinity
98
+ });
99
+ for await (const line of rl) try {
100
+ const data = JSON.parse(line);
101
+ if (data.type === "assistant" && data.message && data.message.usage) {
102
+ const usage = data.message.usage;
103
+ stats.input_tokens += usage.input_tokens || 0;
104
+ stats.output_tokens += usage.output_tokens || 0;
105
+ stats.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
106
+ stats.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
107
+ if (data.message.model) stats.models.add(data.message.model);
108
+ }
109
+ } catch {}
110
+ return stats;
111
+ }
112
+ /**
113
+ * Merges source stats into a target stats object.
114
+ * Used for summing main session and subagent totals.
115
+ */
116
+ function combineStats(target, source) {
117
+ target.input_tokens += source.input_tokens;
118
+ target.output_tokens += source.output_tokens;
119
+ target.cache_creation_input_tokens += source.cache_creation_input_tokens;
120
+ target.cache_read_input_tokens += source.cache_read_input_tokens;
121
+ source.models.forEach((m) => target.models.add(m));
122
+ }
123
+ /**
124
+ * The ClaudeCode-specific stats plugin.
125
+ * Handles parsing ClaudeCode's .jsonl logs found in ~/.claude/projects.
126
+ */
127
+ const claudecodePlugin = defineSessionStatsPlugin({
128
+ name: "claudecode",
129
+ async findSession(sessionId) {
130
+ try {
131
+ const sessionLookup = (await fs.promises.readdir(CLAUDECODE_PROJECTS_DIR)).map(async (projectDir) => {
132
+ const projectPath = path.join(CLAUDECODE_PROJECTS_DIR, projectDir);
133
+ if (!(await fs.promises.stat(projectPath)).isDirectory()) throw new Error(`Not a directory: ${projectPath}`);
134
+ const sessionFile = path.join(projectPath, `${sessionId}.jsonl`);
135
+ const subagentsDir = path.join(projectPath, sessionId, "subagents");
136
+ await fs.promises.access(sessionFile);
137
+ let subagentFiles = [];
138
+ try {
139
+ subagentFiles = (await fs.promises.readdir(subagentsDir)).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(subagentsDir, f));
140
+ } catch {}
141
+ return {
142
+ sessionFile,
143
+ subagentFiles
144
+ };
145
+ });
146
+ try {
147
+ return await Promise.any(sessionLookup);
148
+ } catch (error) {
149
+ if (error instanceof AggregateError) return null;
150
+ throw error;
151
+ }
152
+ } catch (error) {
153
+ console.error("Error searching projects directory:", error.message);
154
+ }
155
+ return null;
156
+ },
157
+ async aggregateStats(sessionData) {
158
+ const mainStats = await parseJsonlFile(sessionData.sessionFile);
159
+ const totalStats = {
160
+ ...mainStats,
161
+ models: new Set(mainStats.models)
162
+ };
163
+ const subagentsStats = {
164
+ input_tokens: 0,
165
+ output_tokens: 0,
166
+ cache_creation_input_tokens: 0,
167
+ cache_read_input_tokens: 0,
168
+ models: /* @__PURE__ */ new Set(),
169
+ count: sessionData.subagentFiles.length
170
+ };
171
+ const allSubagentStats = await Promise.all(sessionData.subagentFiles.map((file) => parseJsonlFile(file)));
172
+ for (const stats of allSubagentStats) {
173
+ combineStats(subagentsStats, stats);
174
+ combineStats(totalStats, stats);
175
+ }
176
+ const models = Array.from(totalStats.models);
177
+ const meta = [];
178
+ const sections = [{
179
+ label: "MAIN SESSION",
180
+ entries: [
181
+ ["Input Tokens", mainStats.input_tokens],
182
+ ["Output Tokens", mainStats.output_tokens],
183
+ ["Cache Creation Input", mainStats.cache_creation_input_tokens],
184
+ ["Cache Read Input", mainStats.cache_read_input_tokens]
185
+ ]
186
+ }];
187
+ if (subagentsStats.count > 0) {
188
+ const modelLine = `Main: ${Array.from(mainStats.models).join(", ") || "N/A"}`;
189
+ const subLine = `Subagents: ${Array.from(subagentsStats.models).join(", ") || "N/A"}`;
190
+ meta.push(["Models", modelLine]);
191
+ meta.push(["", subLine]);
192
+ sections.push({
193
+ label: `SUBAGENTS (${subagentsStats.count} total)`,
194
+ entries: [
195
+ ["Input Tokens", subagentsStats.input_tokens],
196
+ ["Output Tokens", subagentsStats.output_tokens],
197
+ ["Cache Creation Input", subagentsStats.cache_creation_input_tokens],
198
+ ["Cache Read Input", subagentsStats.cache_read_input_tokens]
199
+ ]
200
+ });
201
+ }
202
+ return {
203
+ models,
204
+ meta,
205
+ sections,
206
+ summary: {
207
+ label: "TOTAL USAGE",
208
+ entries: [
209
+ ["Total Input Tokens", totalStats.input_tokens],
210
+ ["Total Output Tokens", totalStats.output_tokens],
211
+ ["Total Cache Creation", totalStats.cache_creation_input_tokens],
212
+ ["Total Cache Read", totalStats.cache_read_input_tokens]
213
+ ]
214
+ },
215
+ grandTotal: totalStats.input_tokens + totalStats.output_tokens + totalStats.cache_creation_input_tokens + totalStats.cache_read_input_tokens
216
+ };
217
+ }
218
+ });
219
+
220
+ //#endregion
221
+ //#region src/commands/agent-logbook/stats/plugins/geminicli.ts
222
+ /** Base directory where Gemini CLI stores its temporary session data. */
223
+ const GEMINICLI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
224
+ /**
225
+ * Helper to read and parse a JSON file from the filesystem.
226
+ * @param filePath Path to the .json file.
227
+ */
228
+ async function readJsonFile(filePath) {
229
+ const content = await fs.promises.readFile(filePath, "utf8");
230
+ return JSON.parse(content);
231
+ }
232
+ /**
233
+ * The GeminiCLI-specific stats plugin.
234
+ * Handles parsing GeminiCLI session JSON files found in ~/.gemini/tmp.
235
+ */
236
+ const geminicliPlugin = defineSessionStatsPlugin({
237
+ name: "geminicli",
238
+ async findSession(sessionId) {
239
+ const matchingFiles = [];
240
+ try {
241
+ const projectDirs = await fs.promises.readdir(GEMINICLI_TMP_DIR);
242
+ const dirResults = await Promise.all(projectDirs.map(async (projectDir) => {
243
+ const chatsDir = path.join(GEMINICLI_TMP_DIR, projectDir, "chats");
244
+ try {
245
+ const jsonFiles = (await fs.promises.readdir(chatsDir)).filter((f) => f.endsWith(".json")).map((f) => path.join(chatsDir, f));
246
+ return (await Promise.all(jsonFiles.map(async (filePath) => {
247
+ return (await readJsonFile(filePath)).sessionId === sessionId ? filePath : null;
248
+ }))).filter((f) => f !== null);
249
+ } catch {
250
+ return [];
251
+ }
252
+ }));
253
+ matchingFiles.push(...dirResults.flat());
254
+ } catch (error) {
255
+ console.error("Error searching gemini tmp directory:", error.message);
256
+ }
257
+ return matchingFiles.length > 0 ? matchingFiles : null;
258
+ },
259
+ async aggregateStats(sessionFiles) {
260
+ const stats = {
261
+ input: 0,
262
+ output: 0,
263
+ cached: 0,
264
+ thoughts: 0,
265
+ tool: 0,
266
+ total: 0,
267
+ models: /* @__PURE__ */ new Set()
268
+ };
269
+ const results = await Promise.all(sessionFiles.map(async (filePath) => {
270
+ try {
271
+ return await readJsonFile(filePath);
272
+ } catch (error) {
273
+ console.error(`Error processing file ${filePath}:`, error.message);
274
+ return null;
275
+ }
276
+ }));
277
+ for (const data of results) {
278
+ if (!data?.messages || !Array.isArray(data.messages)) continue;
279
+ for (const msg of data.messages) if (msg.type === "gemini" && msg.tokens) {
280
+ stats.input += msg.tokens.input || 0;
281
+ stats.output += msg.tokens.output || 0;
282
+ stats.cached += msg.tokens.cached || 0;
283
+ stats.thoughts += msg.tokens.thoughts || 0;
284
+ stats.tool += msg.tokens.tool || 0;
285
+ stats.total += msg.tokens.total || 0;
286
+ if (msg.model) stats.models.add(msg.model);
287
+ }
288
+ }
289
+ return {
290
+ models: Array.from(stats.models),
291
+ meta: [["Files Found", sessionFiles.length]],
292
+ sections: [{
293
+ label: "TOKEN USAGE",
294
+ entries: [
295
+ ["Input Tokens", stats.input - stats.cached],
296
+ ["Output Tokens", stats.output],
297
+ ["Cached Tokens", stats.cached],
298
+ ["Thoughts Tokens", stats.thoughts],
299
+ ["Tool Tokens", stats.tool]
300
+ ]
301
+ }],
302
+ summary: null,
303
+ grandTotal: stats.total
304
+ };
305
+ }
306
+ });
307
+
308
+ //#endregion
309
+ //#region src/commands/agent-logbook/stats/index.ts
310
+ /** Mapping of agent names to their respective stats plugins. */
311
+ const plugins = {
312
+ claudecode: claudecodePlugin,
313
+ geminicli: geminicliPlugin
314
+ };
315
+ /**
316
+ * The 'stats' command implementation.
317
+ * Retrieves and displays aggregated token usage for a given agent session.
318
+ */
319
+ const stats = defineCommandFunction(async function stats({ agent }, sessionId) {
320
+ if (!agent || !sessionId) {
321
+ console.error("Usage: session-stats.js <agent> <session-id>");
322
+ console.error("Agents: claudecode, geminicli");
323
+ process.exit(1);
324
+ }
325
+ const plugin = plugins[agent];
326
+ if (!plugin) {
327
+ console.error(`Unknown agent: ${agent}`);
328
+ console.error("Available agents: claudecode, geminicli");
329
+ process.exit(1);
330
+ }
331
+ const sessionData = await plugin.findSession(sessionId);
332
+ if (!sessionData) {
333
+ console.error(`Session not found for ID: ${sessionId}`);
334
+ process.exit(1);
335
+ }
336
+ const result = await plugin.aggregateStats(sessionData);
337
+ console.log(formatSessionStatsOutput(plugin.name, sessionId, result));
338
+ });
339
+
340
+ //#endregion
341
+ export { stats };
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import matter from "gray-matter";
4
+ import { Compile } from "typebox/compile";
5
+ import yaml from "yaml";
6
+ import { Type } from "typebox";
7
+
8
+ //#region src/commands/agent-logbook/validate/frontmatterSchema.ts
9
+ const IsoDate = Type.Codec(Type.String({ format: "date-time" })).Decode((value) => new Date(value)).Encode((value) => value.toISOString());
10
+ /**
11
+ * TypeBox schema for agent logbook frontmatter.
12
+ */
13
+ const FrontmatterSchema = Type.Object({
14
+ date: IsoDate,
15
+ type: Type.Union([
16
+ Type.Literal("activity"),
17
+ Type.Literal("research"),
18
+ Type.Literal("decision"),
19
+ Type.Literal("plan")
20
+ ]),
21
+ status: Type.Union([
22
+ Type.Literal("complete"),
23
+ Type.Literal("in-progress"),
24
+ Type.Literal("abandoned"),
25
+ Type.Literal("success"),
26
+ Type.Literal("failure"),
27
+ Type.Literal("partial")
28
+ ]),
29
+ agent: Type.String({ minLength: 1 }),
30
+ branch: Type.String({ minLength: 1 }),
31
+ models: Type.Array(Type.String(), { minItems: 1 }),
32
+ sessionId: Type.Optional(Type.String()),
33
+ taskId: Type.Optional(Type.String()),
34
+ cost: Type.Optional(Type.String()),
35
+ tags: Type.Optional(Type.Array(Type.String())),
36
+ filesModified: Type.Optional(Type.Array(Type.String())),
37
+ relatedPlan: Type.Optional(Type.String()),
38
+ migrated: Type.Optional(Type.Boolean())
39
+ }, { additionalProperties: false });
40
+
41
+ //#endregion
42
+ //#region src/commands/agent-logbook/validate/index.ts
43
+ /**
44
+ * Expected filename format: `YYYY-MM-DD_HHMMSSZ_<agent>_<task-slug>.md`
45
+ *
46
+ * - `[0-9]{4}-[0-9]{2}-[0-9]{2}` — date (YYYY-MM-DD)
47
+ * - `[0-9]{6}Z` — UTC time without separators (HHMMSS) + Z suffix
48
+ * - `[a-z][a-z0-9-]*` — agent name: starts with a letter, then letters/digits/hyphens
49
+ * - `[a-z][a-z0-9-]+` — task slug: same rules, but at least 2 characters
50
+ *
51
+ * Example: `2024-03-15_143022Z_claudecode_my-task.md`
52
+ */
53
+ const FILENAME_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}Z_[a-z][a-z0-9-]*_[a-z][a-z0-9-]+\.md$/;
54
+ /**
55
+ * Recursively collects `.md` files under `targetPath`, excluding anything inside a `templates/`
56
+ * subdirectory. When `targetPath` is a single file, returns it directly.
57
+ */
58
+ async function findMarkdownFiles(targetPath) {
59
+ if (!(await fs.stat(targetPath)).isDirectory()) return [targetPath];
60
+ const results = [];
61
+ const queue = [targetPath];
62
+ while (queue.length > 0) {
63
+ const dir = queue.shift();
64
+ const entries = await fs.readdir(dir, { withFileTypes: true });
65
+ for (const entry of entries) {
66
+ const fullPath = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (entry.name === "templates") continue;
69
+ queue.push(fullPath);
70
+ } else if (entry.isFile() && entry.name.endsWith(".md")) results.push(fullPath);
71
+ }
72
+ }
73
+ return results.sort();
74
+ }
75
+ async function validate(_flags, targetPath = ".agent-logbook") {
76
+ const absoluteTargetPath = path.resolve(this.workspacesRoot, targetPath);
77
+ const typeboxValidate = Compile(FrontmatterSchema);
78
+ const files = await findMarkdownFiles(absoluteTargetPath);
79
+ let failed = 0;
80
+ const filePromises = files.map(async (file) => {
81
+ const filename = path.basename(file);
82
+ if (!FILENAME_RE.test(filename)) {
83
+ console.error(`FAIL (filename) ${file}`);
84
+ failed++;
85
+ return;
86
+ }
87
+ const { data } = matter(await fs.readFile(file, "utf8"), { engines: { yaml: {
88
+ parse: (text) => yaml.parse(text),
89
+ stringify: (data) => yaml.stringify(data)
90
+ } } });
91
+ if (!typeboxValidate.Check(data)) {
92
+ console.error(`FAIL (schema) ${file}`);
93
+ const errors = typeboxValidate.Errors(data);
94
+ for (const error of errors) console.error(` ${error.instancePath || "(root)"} ${error.message}`);
95
+ failed++;
96
+ }
97
+ });
98
+ await Promise.all(filePromises);
99
+ if (failed > 0) return process.exit(1);
100
+ console.log("All files validated successfully");
101
+ }
102
+
103
+ //#endregion
104
+ export { validate };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@stzhu/skills",
3
+ "version": "0.1.0",
4
+ "description": "Skills CLI",
5
+ "keywords": [
6
+ "skills"
7
+ ],
8
+ "license": "MIT",
9
+ "author": "Steve Zhu <4130171+stevezhu@users.noreply.github.com>",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/stevezhu/eslint-config.git"
13
+ },
14
+ "bin": {
15
+ "__stz-skills_bash_complete": "dist/bash-complete.mjs",
16
+ "stz-skills": "dist/cli.mjs"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "imports": {
23
+ "#*": "./src/*"
24
+ },
25
+ "dependencies": {
26
+ "@stricli/auto-complete": "^1.2.6",
27
+ "@stricli/core": "^1.2.6",
28
+ "find-workspaces": "^0.3.1",
29
+ "gray-matter": "^4.0.3",
30
+ "typebox": "^1.1.5",
31
+ "yaml": "^2.8.2"
32
+ },
33
+ "devDependencies": {
34
+ "@stzhu/tsconfig": "^0.1.1",
35
+ "@types/node": "^25.3.3",
36
+ "oxfmt": "^0.36.0",
37
+ "oxlint": "^1.51.0",
38
+ "oxlint-tsgolint": "^0.16.0",
39
+ "tsdown": "^0.20.3",
40
+ "tsx": "^4.21.0",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.18"
43
+ },
44
+ "engines": {
45
+ "node": ">=25"
46
+ },
47
+ "scripts": {
48
+ "ai:claude": "claude",
49
+ "ai:gemini": "SEATBELT_PROFILE=custom gemini",
50
+ "build": "tsdown",
51
+ "format": "oxfmt",
52
+ "format:check": "oxfmt --check",
53
+ "lint": "pnpm run format:check && oxlint --type-aware",
54
+ "lint:fix": "oxlint --type-aware --fix",
55
+ "postinstall": "([ ! -d \"src\" ] || pnpm run build) && node dist/cli.mjs install",
56
+ "pretest": "tsc --noEmit",
57
+ "test": "vitest"
58
+ }
59
+ }