learn-anything-cli 0.3.0 → 0.4.0-beta.1

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.
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * render.mjs — standalone script
4
+ * Reads state.json (v1) and renders knowledge-map.md.
5
+ *
6
+ * Usage: node render.mjs <topic-dir>
7
+ *
8
+ * This file is compiled from src/scripts/render.mts via tsc and
9
+ * copied into each skill's scripts/ directory by init/update.
10
+ */
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { validateStateV1, totalCount, masteredCount, STATUS_ICON, STATUS_LABEL, esc } from './utils.mjs';
15
+ /* ------------------------------------------------------------------ */
16
+ /* Render */
17
+ /* ------------------------------------------------------------------ */
18
+ export function render(state) {
19
+ const lines = [];
20
+ // Title
21
+ lines.push(`# ${esc(state.topic)}`);
22
+ lines.push('');
23
+ // Progress header
24
+ const allConcepts = state.domains.flatMap((d) => d.concepts);
25
+ const total = allConcepts.length;
26
+ const mastered = allConcepts.filter((c) => c.status === 'mastered').length;
27
+ const pct = total > 0 ? Math.round((mastered / total) * 100) : 0;
28
+ lines.push(`> ${mastered}/${total} mastered · ${pct}% complete`);
29
+ lines.push('');
30
+ // Domains → concepts → details
31
+ for (const domain of state.domains) {
32
+ lines.push(`## ${esc(domain.name)}`);
33
+ lines.push('');
34
+ for (const concept of domain.concepts) {
35
+ const icon = STATUS_ICON[concept.status];
36
+ const label = STATUS_LABEL[concept.status];
37
+ lines.push(`- ${icon} **${esc(concept.name)}** (${label})`);
38
+ for (const detail of concept.details) {
39
+ lines.push(` - ${esc(detail)}`);
40
+ }
41
+ }
42
+ if (domain.concepts.length > 0)
43
+ lines.push('');
44
+ }
45
+ return lines.join('\n').trimEnd() + '\n';
46
+ }
47
+ /* ------------------------------------------------------------------ */
48
+ /* CLI */
49
+ /* ------------------------------------------------------------------ */
50
+ function usage() {
51
+ const script = process.argv[1]?.split('/').pop() || 'render.mjs';
52
+ console.error(`Usage: node ${script} <topic-dir>`);
53
+ process.exit(1);
54
+ }
55
+ function main() {
56
+ const args = process.argv.slice(2);
57
+ if (args.length === 0) {
58
+ usage();
59
+ }
60
+ const topicDir = resolve(args[0]);
61
+ const statePath = join(topicDir, 'state.json');
62
+ // 1. Read state.json
63
+ let raw;
64
+ try {
65
+ raw = readFileSync(statePath, 'utf-8');
66
+ }
67
+ catch (error) {
68
+ console.error(`Error: state.json not found at ${statePath}`, error);
69
+ process.exit(1);
70
+ }
71
+ // 2. Parse JSON
72
+ let data;
73
+ try {
74
+ data = JSON.parse(raw);
75
+ }
76
+ catch (err) {
77
+ const msg = err instanceof Error ? err.message : String(err);
78
+ console.error(`Error: Failed to parse state.json: ${msg}`);
79
+ process.exit(1);
80
+ }
81
+ // 3. Validate v1 format
82
+ const errors = validateStateV1(data);
83
+ if (errors.length > 0) {
84
+ console.error('Error: state.json validation failed:');
85
+ for (const e of errors) {
86
+ console.error(` .${e.path}: ${e.message}`);
87
+ }
88
+ console.error('Fix the above issues in state.json and re-run render.mjs.');
89
+ process.exit(1);
90
+ }
91
+ const state = data;
92
+ // 4. Render
93
+ const output = render(state);
94
+ const outputPath = join(topicDir, 'knowledge-map.md');
95
+ // 5. Write
96
+ try {
97
+ writeFileSync(outputPath, output, 'utf-8');
98
+ }
99
+ catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ console.error(`Error: Cannot write knowledge-map.md: ${msg}`);
102
+ process.exit(1);
103
+ }
104
+ // Summary to stdout
105
+ console.log(`Rendered knowledge-map.md for "${state.topic}" (${masteredCount(state)}/${totalCount(state)} mastered)`);
106
+ }
107
+ const isMain = process.argv[1] != null &&
108
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
109
+ if (isMain) {
110
+ main();
111
+ }
112
+ //# sourceMappingURL=render.mjs.map
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * status.mjs — standalone script
4
+ * Reads state.json (v1) and outputs a formatted learning heatmap to stdout.
5
+ *
6
+ * Usage:
7
+ * node status.mjs <topic-dir> Detailed heatmap (English)
8
+ * node status.mjs --locale zh-CN <topic-dir> Detailed heatmap (Chinese)
9
+ * node status.mjs --all [--locale zh-CN] <dir> Summary of all topics
10
+ *
11
+ * This file is compiled from src/scripts/status.mts via tsc and
12
+ * copied into learn-anything-status skill's scripts/ directory by init/update.
13
+ */
14
+ import type { StateV1 } from './utils.mjs';
15
+ type Locale = 'en' | 'zh-CN';
16
+ export declare function renderStatus(state: StateV1, now?: number, locale?: Locale): string;
17
+ interface TopicSummary {
18
+ topic: string;
19
+ slug: string;
20
+ total: number;
21
+ mastered: number;
22
+ active: number;
23
+ practice: number;
24
+ unexplored: number;
25
+ pct: number;
26
+ lastPracticed: string | null;
27
+ days: number;
28
+ }
29
+ export declare function renderAllTopics(summaries: TopicSummary[], now?: number, locale?: Locale): string;
30
+ export {};
31
+ //# sourceMappingURL=status.d.mts.map
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * status.mjs — standalone script
4
+ * Reads state.json (v1) and outputs a formatted learning heatmap to stdout.
5
+ *
6
+ * Usage:
7
+ * node status.mjs <topic-dir> Detailed heatmap (English)
8
+ * node status.mjs --locale zh-CN <topic-dir> Detailed heatmap (Chinese)
9
+ * node status.mjs --all [--locale zh-CN] <dir> Summary of all topics
10
+ *
11
+ * This file is compiled from src/scripts/status.mts via tsc and
12
+ * copied into learn-anything-status skill's scripts/ directory by init/update.
13
+ */
14
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { validateStateV1, totalCount, masteredCount, STATUS_ICON, } from './utils.mjs';
18
+ const EN = {
19
+ title: (topic) => `🌟 ${topic} Learning Status`,
20
+ mastered: 'Mastered',
21
+ active: 'Active',
22
+ practice: 'Practice',
23
+ unexplored: 'Unexplored',
24
+ progress: 'Progress',
25
+ statsTitle: '📊 Learning Stats',
26
+ lastPractice: (name, rel) => `💪 Last Practice: ${name} (${rel})`,
27
+ startedLearning: (date) => `📅 Started Learning: ${date}`,
28
+ daysLearning: (days) => `⏱️ Days Learning: ${days}`,
29
+ legend: 'Legend',
30
+ statusLabel: {
31
+ mastered: 'mastered',
32
+ in_progress: 'in progress',
33
+ needs_practice: 'needs practice',
34
+ unexplored: 'unexplored',
35
+ },
36
+ statusMeaning: {
37
+ mastered: 'Mastered — passed practice, high confidence',
38
+ in_progress: 'In Progress — started but not yet mastered',
39
+ needs_practice: 'Needs Practice — understand but need reinforcement',
40
+ unexplored: 'Unexplored — haven\'t started learning yet',
41
+ },
42
+ practiceCount: (n) => n === 1 ? '1 practice' : `${n} practices`,
43
+ confidence: (pct) => `${pct}% confidence`,
44
+ relativeToday: 'today',
45
+ relativeYesterday: 'yesterday',
46
+ relativeDaysAgo: (n) => `${n} days ago`,
47
+ allTopicsTitle: '🌟 Learning Status — All Topics',
48
+ topic: 'Topic',
49
+ days: 'Days',
50
+ total: 'Total',
51
+ noTopics: '📭 No learning topics found.',
52
+ startJourney: 'Run `/learn <topic-name>` to start your learning journey!',
53
+ noData: (path) => `📭 No learning data found at ${path}`,
54
+ };
55
+ const ZH_CN = {
56
+ title: (topic) => `🌟 ${topic} 学习状态`,
57
+ mastered: '已掌握',
58
+ active: '进行中',
59
+ practice: '需练习',
60
+ unexplored: '未探索',
61
+ progress: '进度',
62
+ statsTitle: '📊 学习统计',
63
+ lastPractice: (name, rel) => `💪 最近练习: ${name} (${rel})`,
64
+ startedLearning: (date) => `📅 开始学习: ${date}`,
65
+ daysLearning: (days) => `⏱️ 学习天数: ${days}`,
66
+ legend: '图例',
67
+ statusLabel: {
68
+ mastered: '已掌握',
69
+ in_progress: '进行中',
70
+ needs_practice: '需练习',
71
+ unexplored: '未探索',
72
+ },
73
+ statusMeaning: {
74
+ mastered: '已掌握 — 通过练习,掌握度高',
75
+ in_progress: '进行中 — 已开始但尚未掌握',
76
+ needs_practice: '需练习 — 理解但需要巩固',
77
+ unexplored: '未探索 — 尚未开始学习',
78
+ },
79
+ practiceCount: (n) => n === 1 ? '1 次练习' : `${n} 次练习`,
80
+ confidence: (pct) => `${pct}% 掌握度`,
81
+ relativeToday: '今天',
82
+ relativeYesterday: '昨天',
83
+ relativeDaysAgo: (n) => `${n} 天前`,
84
+ allTopicsTitle: '🌟 学习状态 — 所有主题',
85
+ topic: '主题',
86
+ days: '天数',
87
+ total: '合计',
88
+ noTopics: '📭 暂无学习主题。',
89
+ startJourney: '运行 `/learn <主题名>` 开始你的学习之旅!',
90
+ noData: (path) => `📭 未找到学习数据: ${path}`,
91
+ };
92
+ const STRINGS = { en: EN, 'zh-CN': ZH_CN };
93
+ /* ------------------------------------------------------------------ */
94
+ /* Display width helpers */
95
+ /* ------------------------------------------------------------------ */
96
+ function dw(s) {
97
+ let w = 0;
98
+ for (const ch of s) {
99
+ const cp = ch.codePointAt(0);
100
+ w += cp > 0xffff || isCJK(cp) ? 2 : 1;
101
+ }
102
+ return w;
103
+ }
104
+ /** Check if a code point is a CJK / fullwidth character (display width = 2). */
105
+ function isCJK(cp) {
106
+ return ((cp >= 0x4e00 && cp <= 0x9fff) // CJK Unified Ideographs
107
+ || (cp >= 0x3400 && cp <= 0x4dbf) // CJK Extension A
108
+ || (cp >= 0xf900 && cp <= 0xfaff) // CJK Compatibility Ideographs
109
+ || (cp >= 0x2e80 && cp <= 0x2eff) // CJK Radicals Supplement
110
+ || (cp >= 0x3000 && cp <= 0x303f) // CJK Symbols and Punctuation
111
+ || (cp >= 0x3040 && cp <= 0x309f) // Hiragana
112
+ || (cp >= 0x30a0 && cp <= 0x30ff) // Katakana
113
+ || (cp >= 0xff01 && cp <= 0xff60) // Fullwidth Forms
114
+ || (cp >= 0xac00 && cp <= 0xd7af) // Hangul Syllables
115
+ );
116
+ }
117
+ function padEnd(s, width) {
118
+ return s + ' '.repeat(Math.max(0, width - dw(s)));
119
+ }
120
+ /* ------------------------------------------------------------------ */
121
+ /* Shared helpers */
122
+ /* ------------------------------------------------------------------ */
123
+ function countByStatus(state, status) {
124
+ return state.domains.reduce((sum, d) => sum + d.concepts.filter((c) => c.status === status).length, 0);
125
+ }
126
+ function relativeDate(dateStr, t, now = Date.now()) {
127
+ const then = new Date(dateStr.replace(' ', 'T')).getTime();
128
+ const days = Math.floor((now - then) / 86400000);
129
+ if (days <= 0)
130
+ return t.relativeToday;
131
+ if (days === 1)
132
+ return t.relativeYesterday;
133
+ return t.relativeDaysAgo(days);
134
+ }
135
+ function daysBetween(dateStr, now = Date.now()) {
136
+ const then = new Date(dateStr.replace(' ', 'T')).getTime();
137
+ return Math.max(1, Math.floor((now - then) / 86400000));
138
+ }
139
+ function readStateJson(filePath) {
140
+ const raw = readFileSync(filePath, 'utf-8');
141
+ const data = JSON.parse(raw);
142
+ const errors = validateStateV1(data);
143
+ if (errors.length > 0) {
144
+ throw new Error(`state.json validation failed:\n${errors.map((e) => ` .${e.path}: ${e.message}`).join('\n')}`);
145
+ }
146
+ return data;
147
+ }
148
+ /* ------------------------------------------------------------------ */
149
+ /* Single topic — detailed heatmap */
150
+ /* ------------------------------------------------------------------ */
151
+ function findLastPracticed(state) {
152
+ let best = null;
153
+ for (const d of state.domains) {
154
+ for (const c of d.concepts) {
155
+ if (c.last_practiced && (!best || c.last_practiced > best.date)) {
156
+ best = { name: c.name, date: c.last_practiced };
157
+ }
158
+ }
159
+ }
160
+ return best;
161
+ }
162
+ function conceptLine(concept, t) {
163
+ const icon = STATUS_ICON[concept.status];
164
+ const label = t.statusLabel[concept.status];
165
+ if (concept.status === 'unexplored') {
166
+ return `${icon} ${concept.name} ${label}`;
167
+ }
168
+ const parts = [label];
169
+ if (concept.practice_count > 0)
170
+ parts.push(t.practiceCount(concept.practice_count));
171
+ if (concept.confidence > 0)
172
+ parts.push(t.confidence(Math.round(concept.confidence * 100)));
173
+ return `${icon} ${concept.name} ${parts.join(' · ')}`;
174
+ }
175
+ export function renderStatus(state, now, locale = 'en') {
176
+ const t = STRINGS[locale];
177
+ const lines = [];
178
+ const total = totalCount(state);
179
+ const mastered = masteredCount(state);
180
+ const pct = total > 0 ? Math.round((mastered / total) * 100) : 0;
181
+ // Title
182
+ lines.push(t.title(state.topic));
183
+ lines.push('');
184
+ // Tree-structured heatmap
185
+ for (const domain of state.domains) {
186
+ if (domain.concepts.length === 0)
187
+ continue;
188
+ const domainMastered = domain.concepts.filter((c) => c.status === 'mastered').length;
189
+ lines.push(`${domain.name} [${domainMastered}/${domain.concepts.length} ${t.mastered.toLowerCase()}]`);
190
+ const last = domain.concepts.length - 1;
191
+ for (let i = 0; i <= last; i++) {
192
+ const prefix = i === last ? '└──' : '├──';
193
+ lines.push(`${prefix} ${conceptLine(domain.concepts[i], t)}`);
194
+ }
195
+ lines.push('');
196
+ }
197
+ // Summary panel — column widths based on locale
198
+ const active = countByStatus(state, 'in_progress');
199
+ const practice = countByStatus(state, 'needs_practice');
200
+ const unexplored = countByStatus(state, 'unexplored');
201
+ // Compute column widths to fit headers + data
202
+ const colM = Math.max(dw(t.mastered) + 2, 10);
203
+ const colA = Math.max(dw(t.active) + 2, 9);
204
+ const colP = Math.max(dw(t.practice) + 2, 10);
205
+ const colU = Math.max(dw(t.unexplored) + 2, 11);
206
+ const colR = Math.max(dw(t.progress) + 2, 9);
207
+ const totalW = 1 + colM + 1 + colA + 1 + colP + 1 + colU + 1 + colR + 1;
208
+ const hLine = '─'.repeat(totalW - 2);
209
+ lines.push(`┌${hLine}┐`);
210
+ lines.push(`│${padEnd(` ${t.statsTitle}`, totalW - 2)}│`);
211
+ lines.push(`├${'─'.repeat(colM)}┬${'─'.repeat(colA)}┬${'─'.repeat(colP)}┬${'─'.repeat(colU)}┬${'─'.repeat(colR)}┤`);
212
+ lines.push(`│${padEnd(` ${t.mastered}`, colM)}│${padEnd(` ${t.active}`, colA)}│${padEnd(` ${t.practice}`, colP)}│${padEnd(` ${t.unexplored}`, colU)}│${padEnd(` ${t.progress}`, colR)}│`);
213
+ lines.push(`│${padEnd(` ${mastered} 🟢`, colM)}│${padEnd(` ${active} 🔵`, colA)}│${padEnd(` ${practice} 🟠`, colP)}│${padEnd(` ${unexplored} ⚪`, colU)}│${padEnd(` ${pct}%`, colR)}│`);
214
+ lines.push(`├${'─'.repeat(colM)}┴${'─'.repeat(colA)}┴${'─'.repeat(colP)}┴${'─'.repeat(colU)}┴${'─'.repeat(colR)}┤`);
215
+ const contentW = totalW - 2;
216
+ const lastP = findLastPracticed(state);
217
+ if (lastP) {
218
+ const rel = relativeDate(lastP.date, t, now);
219
+ lines.push(`│${padEnd(` ${t.lastPractice(lastP.name, rel)}`, contentW)}│`);
220
+ }
221
+ const started = state.created.split(' ')[0];
222
+ lines.push(`│${padEnd(` ${t.startedLearning(started)}`, contentW)}│`);
223
+ const days = daysBetween(state.created, now);
224
+ lines.push(`│${padEnd(` ${t.daysLearning(days)}`, contentW)}│`);
225
+ lines.push(`└${hLine}┘`);
226
+ lines.push('');
227
+ // Legend
228
+ lines.push(`## ${t.legend}`);
229
+ lines.push('');
230
+ lines.push(`| Icon | Status | Meaning |`);
231
+ lines.push(`|------|--------|---------|`);
232
+ for (const status of ['mastered', 'in_progress', 'needs_practice', 'unexplored']) {
233
+ lines.push(`| ${STATUS_ICON[status]} | ${t.statusLabel[status]} | ${t.statusMeaning[status]} |`);
234
+ }
235
+ return lines.join('\n').trimEnd() + '\n';
236
+ }
237
+ function scanTopics(baseDir) {
238
+ const summaries = [];
239
+ let entries;
240
+ try {
241
+ entries = readdirSync(baseDir);
242
+ }
243
+ catch {
244
+ return summaries;
245
+ }
246
+ for (const name of entries) {
247
+ const fullPath = join(baseDir, name);
248
+ try {
249
+ if (!statSync(fullPath).isDirectory())
250
+ continue;
251
+ }
252
+ catch {
253
+ continue;
254
+ }
255
+ const statePath = join(fullPath, 'state.json');
256
+ try {
257
+ const state = readStateJson(statePath);
258
+ const total = totalCount(state);
259
+ const mastered = masteredCount(state);
260
+ let lastPracticed = null;
261
+ for (const d of state.domains) {
262
+ for (const c of d.concepts) {
263
+ if (c.last_practiced && (!lastPracticed || c.last_practiced > lastPracticed)) {
264
+ lastPracticed = c.last_practiced;
265
+ }
266
+ }
267
+ }
268
+ summaries.push({
269
+ topic: state.topic,
270
+ slug: state.slug,
271
+ total,
272
+ mastered,
273
+ active: countByStatus(state, 'in_progress'),
274
+ practice: countByStatus(state, 'needs_practice'),
275
+ unexplored: countByStatus(state, 'unexplored'),
276
+ pct: total > 0 ? Math.round((mastered / total) * 100) : 0,
277
+ lastPracticed,
278
+ days: daysBetween(state.created),
279
+ });
280
+ }
281
+ catch {
282
+ // Skip dirs without valid state.json
283
+ }
284
+ }
285
+ return summaries;
286
+ }
287
+ export function renderAllTopics(summaries, now, locale = 'en') {
288
+ const t = STRINGS[locale];
289
+ const lines = [];
290
+ lines.push(t.allTopicsTitle);
291
+ lines.push('');
292
+ if (summaries.length === 0) {
293
+ lines.push(t.noTopics);
294
+ lines.push(t.startJourney);
295
+ lines.push('');
296
+ return lines.join('\n').trimEnd() + '\n';
297
+ }
298
+ // Compute column display widths
299
+ const topicCol = Math.max(dw(t.topic) + 2, ...summaries.map((s) => dw(s.topic) + 2));
300
+ const masterCol = Math.max(dw(t.mastered) + 2, 12);
301
+ const activeCol = Math.max(dw(t.active) + 2, 8);
302
+ const practiceCol = Math.max(dw(t.practice) + 2, 10);
303
+ const progressCol = Math.max(dw(t.progress) + 2, 10);
304
+ const daysCol = Math.max(dw(t.days) + 2, 8);
305
+ const sep = `┼${'─'.repeat(topicCol)}┼${'─'.repeat(masterCol)}┼${'─'.repeat(activeCol)}┼${'─'.repeat(practiceCol)}┼${'─'.repeat(progressCol)}┼${'─'.repeat(daysCol)}┤`;
306
+ const top = `┌${'─'.repeat(topicCol)}┬${'─'.repeat(masterCol)}┬${'─'.repeat(activeCol)}┬${'─'.repeat(practiceCol)}┬${'─'.repeat(progressCol)}┬${'─'.repeat(daysCol)}┐`;
307
+ const bot = `└${'─'.repeat(topicCol)}┴${'─'.repeat(masterCol)}┴${'─'.repeat(activeCol)}┴${'─'.repeat(practiceCol)}┴${'─'.repeat(progressCol)}┴${'─'.repeat(daysCol)}┘`;
308
+ lines.push(top);
309
+ lines.push(`│ ${padEnd(t.topic, topicCol - 1)}│ ${padEnd(t.mastered, masterCol - 1)}│ ${padEnd(t.active, activeCol - 1)}│ ${padEnd(t.practice, practiceCol - 1)}│ ${padEnd(t.progress, progressCol - 1)}│ ${padEnd(t.days, daysCol - 1)}│`);
310
+ lines.push(sep);
311
+ for (const s of summaries) {
312
+ lines.push(`│ ${padEnd(s.topic, topicCol - 1)}│ ${padEnd(`${s.mastered}/${s.total} 🟢`, masterCol - 1)}│ ${padEnd(`${s.active} 🔵`, activeCol - 1)}│ ${padEnd(`${s.practice} 🟠`, practiceCol - 1)}│ ${padEnd(`${s.pct}%`, progressCol - 1)}│ ${padEnd(`${s.days}`, daysCol - 1)}│`);
313
+ }
314
+ // Overall totals
315
+ const grandTotal = summaries.reduce((s, acc) => s + acc.total, 0);
316
+ const grandMastered = summaries.reduce((s, acc) => s + acc.mastered, 0);
317
+ const grandActive = summaries.reduce((s, acc) => s + acc.active, 0);
318
+ const grandPractice = summaries.reduce((s, acc) => s + acc.practice, 0);
319
+ const grandPct = grandTotal > 0 ? Math.round((grandMastered / grandTotal) * 100) : 0;
320
+ lines.push(sep);
321
+ lines.push(`│ ${padEnd(t.total, topicCol - 1)}│ ${padEnd(`${grandMastered}/${grandTotal} 🟢`, masterCol - 1)}│ ${padEnd(`${grandActive} 🔵`, activeCol - 1)}│ ${padEnd(`${grandPractice} 🟠`, practiceCol - 1)}│ ${padEnd(`${grandPct}%`, progressCol - 1)}│ ${padEnd('', daysCol - 1)}│`);
322
+ lines.push(bot);
323
+ lines.push('');
324
+ // Find latest practice across all topics
325
+ let latestTopic = '';
326
+ let latestDate = null;
327
+ for (const s of summaries) {
328
+ if (s.lastPracticed && (!latestDate || s.lastPracticed > latestDate)) {
329
+ latestDate = s.lastPracticed;
330
+ latestTopic = s.topic;
331
+ }
332
+ }
333
+ if (latestDate) {
334
+ lines.push(t.lastPractice(latestTopic, relativeDate(latestDate, t, now)));
335
+ }
336
+ lines.push('');
337
+ // Legend
338
+ lines.push(`## ${t.legend}`);
339
+ lines.push('');
340
+ lines.push(`| Icon | Status |`);
341
+ lines.push(`|------|--------|`);
342
+ for (const status of ['mastered', 'in_progress', 'needs_practice', 'unexplored']) {
343
+ lines.push(`| ${STATUS_ICON[status]} | ${t.statusLabel[status]} |`);
344
+ }
345
+ return lines.join('\n').trimEnd() + '\n';
346
+ }
347
+ /* ------------------------------------------------------------------ */
348
+ /* CLI */
349
+ /* ------------------------------------------------------------------ */
350
+ function usage() {
351
+ const script = process.argv[1]?.split('/').pop() || 'status.mjs';
352
+ console.error(`Usage:`);
353
+ console.error(` node ${script} [--locale en|zh-CN] <topic-dir>`);
354
+ console.error(` node ${script} --all [--locale en|zh-CN] <topics-dir>`);
355
+ process.exit(1);
356
+ }
357
+ function main() {
358
+ const args = process.argv.slice(2);
359
+ if (args.length === 0) {
360
+ usage();
361
+ }
362
+ // Parse flags
363
+ let locale = 'en';
364
+ let isAll = false;
365
+ let dirArg;
366
+ for (let i = 0; i < args.length; i++) {
367
+ if (args[i] === '--all') {
368
+ isAll = true;
369
+ }
370
+ else if (args[i] === '--locale' && args[i + 1]) {
371
+ const val = args[++i];
372
+ if (val === 'en' || val === 'zh-CN') {
373
+ locale = val;
374
+ }
375
+ else {
376
+ console.error(`Unknown locale: ${val}. Supported: en, zh-CN`);
377
+ process.exit(1);
378
+ }
379
+ }
380
+ else if (!args[i].startsWith('--')) {
381
+ dirArg = args[i];
382
+ }
383
+ }
384
+ if (!dirArg) {
385
+ usage();
386
+ }
387
+ const dir = resolve(dirArg);
388
+ if (isAll) {
389
+ const summaries = scanTopics(dir);
390
+ console.log(renderAllTopics(summaries, undefined, locale));
391
+ }
392
+ else {
393
+ const statePath = join(dir, 'state.json');
394
+ let state;
395
+ try {
396
+ state = readStateJson(statePath);
397
+ }
398
+ catch (err) {
399
+ const msg = err instanceof Error ? err.message : String(err);
400
+ if (msg.includes('ENOENT') || msg.includes('not found')) {
401
+ const t = STRINGS[locale];
402
+ console.error(t.noData(statePath));
403
+ console.error(t.startJourney);
404
+ }
405
+ else {
406
+ console.error(`Error: ${msg}`);
407
+ }
408
+ process.exit(1);
409
+ }
410
+ console.log(renderStatus(state, undefined, locale));
411
+ }
412
+ }
413
+ const isMain = process.argv[1] != null &&
414
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
415
+ if (isMain) {
416
+ main();
417
+ }
418
+ //# sourceMappingURL=status.mjs.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * utils.mts — shared types, validation, and helpers for scripts.
3
+ *
4
+ * This file is compiled from src/scripts/utils.mts via tsc and
5
+ * copied into each skill's scripts/ directory by init/update.
6
+ * It MUST NOT import any project modules — only Node.js built-ins.
7
+ */
8
+ export type ConceptStatus = 'unexplored' | 'in_progress' | 'needs_practice' | 'mastered';
9
+ export interface Concept {
10
+ name: string;
11
+ slug: string;
12
+ status: ConceptStatus;
13
+ confidence: number;
14
+ practice_count: number;
15
+ explain_count: number;
16
+ last_explained: string | null;
17
+ last_practiced: string | null;
18
+ details: string[];
19
+ }
20
+ export interface Domain {
21
+ name: string;
22
+ slug: string;
23
+ concepts: Concept[];
24
+ }
25
+ export interface StateV1 {
26
+ version: 1;
27
+ topic: string;
28
+ slug: string;
29
+ created: string;
30
+ domains: Domain[];
31
+ }
32
+ export declare const STATUS_ICON: Record<ConceptStatus, string>;
33
+ export declare const STATUS_LABEL: Record<ConceptStatus, string>;
34
+ /** Escape underscores in text destined for Markdown output. */
35
+ export declare const esc: (s: string) => string;
36
+ export interface ValidationError {
37
+ path: string;
38
+ message: string;
39
+ }
40
+ export declare function validateStateV1(data: unknown): ValidationError[];
41
+ export declare function totalCount(state: StateV1): number;
42
+ export declare function masteredCount(state: StateV1): number;
43
+ //# sourceMappingURL=utils.d.mts.map