learn-anything-cli 0.3.0 → 0.4.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/README.md +6 -0
- package/README.zh-CN.md +6 -0
- package/dist/cli/index.js +3 -0
- package/dist/core/init.d.ts +6 -0
- package/dist/core/init.js +67 -2
- package/dist/core/learn-protocol/index.d.ts +8 -0
- package/dist/core/learn-protocol/index.js +5 -0
- package/dist/core/learn-protocol/migrate.d.ts +52 -0
- package/dist/core/learn-protocol/migrate.js +259 -0
- package/dist/core/learn-protocol/parser.d.ts +33 -0
- package/dist/core/learn-protocol/parser.js +150 -0
- package/dist/core/learn-protocol/schema.d.ts +38 -0
- package/dist/core/learn-protocol/schema.js +43 -0
- package/dist/core/learn-protocol/slug.d.ts +13 -0
- package/dist/core/learn-protocol/slug.js +28 -0
- package/dist/core/learn-protocol/types.d.ts +63 -0
- package/dist/core/learn-protocol/types.js +2 -0
- package/dist/core/templates/context7-guidance.d.ts +13 -0
- package/dist/core/templates/context7-guidance.js +24 -0
- package/dist/core/templates/workflows/learn-explain.js +56 -139
- package/dist/core/templates/workflows/learn-practice.js +88 -284
- package/dist/core/templates/workflows/learn-review.js +35 -93
- package/dist/core/templates/workflows/learn-status.js +26 -69
- package/dist/core/templates/workflows/learn-topic.js +73 -82
- package/dist/i18n/locales/en.js +4 -0
- package/dist/i18n/locales/zh-CN.js +4 -0
- package/dist/i18n/types.d.ts +4 -0
- package/dist/scripts/render.d.mts +13 -0
- package/dist/scripts/render.mjs +112 -0
- package/dist/scripts/status.d.mts +31 -0
- package/dist/scripts/status.mjs +418 -0
- package/dist/scripts/utils.d.mts +43 -0
- package/dist/scripts/utils.mjs +124 -0
- package/package.json +4 -1
|
@@ -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
|