@shaykec/core 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/README.md +63 -0
- package/commands/teach.md +78 -0
- package/engine/authoring.md +150 -0
- package/engine/teach-command.md +76 -0
- package/package.json +28 -0
- package/src/author.js +355 -0
- package/src/bridge-server.js +60 -0
- package/src/cli.js +512 -0
- package/src/gamification.js +176 -0
- package/src/gamification.test.js +509 -0
- package/src/marketplace.js +349 -0
- package/src/progress.js +157 -0
- package/src/progress.test.js +360 -0
- package/src/registry.js +201 -0
- package/src/registry.test.js +309 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Marketplace — install, update, remove, and search module packs.
|
|
3
|
+
*
|
|
4
|
+
* Packs are git repos cloned into ~/.claude-teach/modules/<pack-name>/.
|
|
5
|
+
* Registries are remote repos listing available packs for discovery.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync } from 'fs';
|
|
9
|
+
import { join, basename } from 'path';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import yaml from 'js-yaml';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* Paths */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
const TEACH_HOME = join(homedir(), '.claude-teach');
|
|
20
|
+
const MODULES_HOME = join(TEACH_HOME, 'modules');
|
|
21
|
+
const LOCAL_MODULES = join(MODULES_HOME, 'local');
|
|
22
|
+
const CONFIG_PATH = join(TEACH_HOME, 'config.yaml');
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REGISTRY = {
|
|
25
|
+
name: 'default',
|
|
26
|
+
url: 'https://github.com/shayke-cohen/claudeteach-registry',
|
|
27
|
+
enabled: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
/* Internal helpers */
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
/** Ensure the ~/.claude-teach/ directory tree exists. */
|
|
35
|
+
export function ensureHome() {
|
|
36
|
+
if (!existsSync(TEACH_HOME)) mkdirSync(TEACH_HOME, { recursive: true });
|
|
37
|
+
if (!existsSync(MODULES_HOME)) mkdirSync(MODULES_HOME, { recursive: true });
|
|
38
|
+
if (!existsSync(LOCAL_MODULES)) mkdirSync(LOCAL_MODULES, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Load ~/.claude-teach/config.yaml (create with defaults if missing). */
|
|
42
|
+
export function loadConfig() {
|
|
43
|
+
ensureHome();
|
|
44
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
45
|
+
const defaults = { registries: [DEFAULT_REGISTRY] };
|
|
46
|
+
writeFileSync(CONFIG_PATH, yaml.dump(defaults, { lineWidth: 120 }), 'utf-8');
|
|
47
|
+
return defaults;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return yaml.load(readFileSync(CONFIG_PATH, 'utf-8')) || { registries: [DEFAULT_REGISTRY] };
|
|
51
|
+
} catch {
|
|
52
|
+
return { registries: [DEFAULT_REGISTRY] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Persist config back to disk. */
|
|
57
|
+
function saveConfig(config) {
|
|
58
|
+
ensureHome();
|
|
59
|
+
writeFileSync(CONFIG_PATH, yaml.dump(config, { lineWidth: 120, noRefs: true }), 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalise a URL or GitHub shorthand into a full git clone URL.
|
|
64
|
+
* Supports:
|
|
65
|
+
* - Full HTTPS URL (https://github.com/user/repo)
|
|
66
|
+
* - GitHub shorthand (user/repo)
|
|
67
|
+
*/
|
|
68
|
+
function normaliseUrl(urlOrShorthand) {
|
|
69
|
+
if (/^https?:\/\//.test(urlOrShorthand)) {
|
|
70
|
+
return urlOrShorthand.replace(/\/+$/, '');
|
|
71
|
+
}
|
|
72
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(urlOrShorthand)) {
|
|
73
|
+
return `https://github.com/${urlOrShorthand}`;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Invalid URL or shorthand: "${urlOrShorthand}". Use a full URL or GitHub shorthand (user/repo).`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Derive a pack name from a git URL (last path segment, sans .git). */
|
|
79
|
+
function packNameFromUrl(url) {
|
|
80
|
+
const last = basename(url.replace(/\/+$/, ''));
|
|
81
|
+
return last.replace(/\.git$/, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Verify git is available. */
|
|
85
|
+
function requireGit() {
|
|
86
|
+
try {
|
|
87
|
+
execFileSync('git', ['--version'], { stdio: 'pipe' });
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error('git is not installed or not in PATH. Install git and try again.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a GitHub repo URL to a raw content URL for fetching files.
|
|
95
|
+
* e.g. https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/main/
|
|
96
|
+
*/
|
|
97
|
+
function rawContentBase(repoUrl) {
|
|
98
|
+
const cleaned = repoUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
99
|
+
const match = cleaned.match(/github\.com\/(.+)/);
|
|
100
|
+
if (!match) return null;
|
|
101
|
+
return `https://raw.githubusercontent.com/${match[1]}/main/`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ------------------------------------------------------------------ */
|
|
105
|
+
/* Pack management */
|
|
106
|
+
/* ------------------------------------------------------------------ */
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Install a module pack from a git URL or GitHub shorthand.
|
|
110
|
+
* Clones to ~/.claude-teach/modules/<pack-name>/ and validates pack.yaml.
|
|
111
|
+
*/
|
|
112
|
+
export async function installPack(urlOrShorthand) {
|
|
113
|
+
ensureHome();
|
|
114
|
+
requireGit();
|
|
115
|
+
|
|
116
|
+
const url = normaliseUrl(urlOrShorthand);
|
|
117
|
+
const name = packNameFromUrl(url);
|
|
118
|
+
const dest = join(MODULES_HOME, name);
|
|
119
|
+
|
|
120
|
+
if (name === 'local') {
|
|
121
|
+
throw new Error('"local" is reserved for user-authored modules. Choose a different pack name.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (existsSync(dest)) {
|
|
125
|
+
throw new Error(`Pack "${name}" is already installed at ${dest}. Use "update" to pull latest.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(chalk.blue(`Cloning ${url} ...`));
|
|
129
|
+
try {
|
|
130
|
+
execFileSync('git', ['clone', '--depth', '1', url, dest], { stdio: 'pipe' });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new Error(`git clone failed: ${err.stderr?.toString().trim() || err.message}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validate pack.yaml
|
|
136
|
+
const packYaml = join(dest, 'pack.yaml');
|
|
137
|
+
if (!existsSync(packYaml)) {
|
|
138
|
+
// Clean up
|
|
139
|
+
rmSync(dest, { recursive: true, force: true });
|
|
140
|
+
throw new Error(`Invalid module pack: no pack.yaml found in ${url}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let pack;
|
|
144
|
+
try {
|
|
145
|
+
pack = yaml.load(readFileSync(packYaml, 'utf-8'));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
rmSync(dest, { recursive: true, force: true });
|
|
148
|
+
throw new Error(`Invalid pack.yaml: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!pack.name) {
|
|
152
|
+
rmSync(dest, { recursive: true, force: true });
|
|
153
|
+
throw new Error('pack.yaml is missing the required "name" field.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const moduleCount = Array.isArray(pack.modules) ? pack.modules.length : 0;
|
|
157
|
+
console.log(chalk.green(`Installed "${pack.name}" (${moduleCount} module${moduleCount !== 1 ? 's' : ''}).`));
|
|
158
|
+
return pack;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* List all installed module packs.
|
|
163
|
+
* Reads ~/.claude-teach/modules/STAR/pack.yaml (skips the local directory).
|
|
164
|
+
*/
|
|
165
|
+
export async function listPacks() {
|
|
166
|
+
ensureHome();
|
|
167
|
+
const packs = [];
|
|
168
|
+
|
|
169
|
+
if (!existsSync(MODULES_HOME)) return packs;
|
|
170
|
+
|
|
171
|
+
for (const entry of readdirSync(MODULES_HOME)) {
|
|
172
|
+
if (entry === 'local') continue;
|
|
173
|
+
const packDir = join(MODULES_HOME, entry);
|
|
174
|
+
const packYaml = join(packDir, 'pack.yaml');
|
|
175
|
+
if (!existsSync(packYaml)) continue;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const data = yaml.load(readFileSync(packYaml, 'utf-8'));
|
|
179
|
+
packs.push({
|
|
180
|
+
name: data.name || entry,
|
|
181
|
+
author: data.author || 'unknown',
|
|
182
|
+
description: data.description || '',
|
|
183
|
+
version: data.version || '0.0.0',
|
|
184
|
+
modules: data.modules || [],
|
|
185
|
+
path: packDir,
|
|
186
|
+
});
|
|
187
|
+
} catch {
|
|
188
|
+
// Skip malformed
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return packs;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Update an installed pack by running git pull.
|
|
197
|
+
*/
|
|
198
|
+
export async function updatePack(packName) {
|
|
199
|
+
ensureHome();
|
|
200
|
+
requireGit();
|
|
201
|
+
|
|
202
|
+
const dest = join(MODULES_HOME, packName);
|
|
203
|
+
if (!existsSync(dest)) {
|
|
204
|
+
throw new Error(`Pack "${packName}" is not installed.`);
|
|
205
|
+
}
|
|
206
|
+
if (packName === 'local') {
|
|
207
|
+
throw new Error('"local" is your custom modules directory and cannot be updated via git.');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(chalk.blue(`Updating "${packName}" ...`));
|
|
211
|
+
try {
|
|
212
|
+
execFileSync('git', ['-C', dest, 'pull', '--ff-only'], { stdio: 'pipe' });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw new Error(`git pull failed: ${err.stderr?.toString().trim() || err.message}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(chalk.green(`Pack "${packName}" updated.`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Remove an installed pack.
|
|
222
|
+
*/
|
|
223
|
+
export async function removePack(packName) {
|
|
224
|
+
ensureHome();
|
|
225
|
+
|
|
226
|
+
if (packName === 'local') {
|
|
227
|
+
throw new Error('"local" is your custom modules directory and cannot be removed.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const dest = join(MODULES_HOME, packName);
|
|
231
|
+
if (!existsSync(dest)) {
|
|
232
|
+
throw new Error(`Pack "${packName}" is not installed.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
rmSync(dest, { recursive: true, force: true });
|
|
236
|
+
console.log(chalk.green(`Pack "${packName}" removed.`));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Search configured registries for packs matching a query.
|
|
241
|
+
* Fetches registry.yaml from each enabled registry's raw GitHub content.
|
|
242
|
+
*/
|
|
243
|
+
export async function searchPacks(query) {
|
|
244
|
+
const config = loadConfig();
|
|
245
|
+
const registries = (config.registries || []).filter(r => r.enabled !== false);
|
|
246
|
+
const results = [];
|
|
247
|
+
const q = query.toLowerCase();
|
|
248
|
+
|
|
249
|
+
for (const reg of registries) {
|
|
250
|
+
const base = rawContentBase(reg.url);
|
|
251
|
+
if (!base) {
|
|
252
|
+
console.warn(chalk.yellow(`Skipping non-GitHub registry: ${reg.url}`));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const registryUrl = `${base}registry.yaml`;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const resp = await fetch(registryUrl);
|
|
260
|
+
if (!resp.ok) {
|
|
261
|
+
console.warn(chalk.yellow(`Registry "${reg.name || reg.url}" returned HTTP ${resp.status}`));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const text = await resp.text();
|
|
265
|
+
const data = yaml.load(text);
|
|
266
|
+
const packs = data?.packs || data?.modules || [];
|
|
267
|
+
|
|
268
|
+
for (const pack of packs) {
|
|
269
|
+
const searchable = [
|
|
270
|
+
pack.name || '',
|
|
271
|
+
pack.description || '',
|
|
272
|
+
...(pack.tags || []),
|
|
273
|
+
pack.author || '',
|
|
274
|
+
].join(' ').toLowerCase();
|
|
275
|
+
|
|
276
|
+
if (searchable.includes(q)) {
|
|
277
|
+
results.push({
|
|
278
|
+
...pack,
|
|
279
|
+
registry: reg.name || reg.url,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.warn(chalk.yellow(`Failed to fetch from "${reg.name || reg.url}": ${err.message}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------------------------------------------ */
|
|
292
|
+
/* Registry configuration */
|
|
293
|
+
/* ------------------------------------------------------------------ */
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get the list of configured registries.
|
|
297
|
+
*/
|
|
298
|
+
export function getRegistries() {
|
|
299
|
+
const config = loadConfig();
|
|
300
|
+
return config.registries || [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Add a registry by URL or GitHub shorthand.
|
|
305
|
+
*/
|
|
306
|
+
export function addRegistry(urlOrShorthand) {
|
|
307
|
+
const config = loadConfig();
|
|
308
|
+
const url = normaliseUrl(urlOrShorthand);
|
|
309
|
+
const name = packNameFromUrl(url);
|
|
310
|
+
|
|
311
|
+
if (!config.registries) config.registries = [];
|
|
312
|
+
|
|
313
|
+
const existing = config.registries.find(r => r.url === url || r.name === name);
|
|
314
|
+
if (existing) {
|
|
315
|
+
throw new Error(`Registry "${name}" is already configured.`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
config.registries.push({ name, url, enabled: true });
|
|
319
|
+
saveConfig(config);
|
|
320
|
+
console.log(chalk.green(`Registry "${name}" added.`));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Remove a registry by name or URL.
|
|
325
|
+
*/
|
|
326
|
+
export function removeRegistry(nameOrUrl) {
|
|
327
|
+
const config = loadConfig();
|
|
328
|
+
if (!config.registries) {
|
|
329
|
+
throw new Error('No registries configured.');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const idx = config.registries.findIndex(
|
|
333
|
+
r => r.name === nameOrUrl || r.url === nameOrUrl
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (idx === -1) {
|
|
337
|
+
throw new Error(`Registry "${nameOrUrl}" not found.`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const removed = config.registries.splice(idx, 1)[0];
|
|
341
|
+
saveConfig(config);
|
|
342
|
+
console.log(chalk.green(`Registry "${removed.name || removed.url}" removed.`));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
/* Exports for registry.js integration */
|
|
347
|
+
/* ------------------------------------------------------------------ */
|
|
348
|
+
|
|
349
|
+
export { TEACH_HOME, MODULES_HOME, LOCAL_MODULES };
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified progress store — reads/writes .teach-progress.yaml
|
|
3
|
+
* Stores both tutorial state and gamification state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
|
|
10
|
+
const PROGRESS_FILE = '.teach-progress.yaml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load progress from .teach-progress.yaml in the project root.
|
|
14
|
+
* Returns a default structure if the file doesn't exist.
|
|
15
|
+
*/
|
|
16
|
+
export function loadProgress(rootDir) {
|
|
17
|
+
const filePath = join(rootDir, PROGRESS_FILE);
|
|
18
|
+
if (!existsSync(filePath)) {
|
|
19
|
+
return defaultProgress();
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
23
|
+
const data = yaml.load(raw);
|
|
24
|
+
return data || defaultProgress();
|
|
25
|
+
} catch {
|
|
26
|
+
return defaultProgress();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Save progress to .teach-progress.yaml.
|
|
32
|
+
*/
|
|
33
|
+
export function saveProgress(rootDir, progress) {
|
|
34
|
+
const filePath = join(rootDir, PROGRESS_FILE);
|
|
35
|
+
const out = yaml.dump(progress, { lineWidth: 120, noRefs: true });
|
|
36
|
+
writeFileSync(filePath, out, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Record XP earned for a module activity.
|
|
41
|
+
*/
|
|
42
|
+
export function recordXP(rootDir, slug, activity, xpAmount) {
|
|
43
|
+
const progress = loadProgress(rootDir);
|
|
44
|
+
|
|
45
|
+
if (!progress.modules[slug]) {
|
|
46
|
+
progress.modules[slug] = {
|
|
47
|
+
status: 'in-progress',
|
|
48
|
+
started: new Date().toISOString().split('T')[0],
|
|
49
|
+
last_session: new Date().toISOString().split('T')[0],
|
|
50
|
+
xp_earned: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mod = progress.modules[slug];
|
|
55
|
+
mod.last_session = new Date().toISOString().split('T')[0];
|
|
56
|
+
mod.xp_earned = (mod.xp_earned || 0) + xpAmount;
|
|
57
|
+
|
|
58
|
+
progress.user.xp = (progress.user.xp || 0) + xpAmount;
|
|
59
|
+
|
|
60
|
+
// Recount completed modules
|
|
61
|
+
progress.user.modules_completed = Object.values(progress.modules)
|
|
62
|
+
.filter(m => m.status === 'completed')
|
|
63
|
+
.length;
|
|
64
|
+
|
|
65
|
+
saveProgress(rootDir, progress);
|
|
66
|
+
return progress;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a module as completed.
|
|
71
|
+
*/
|
|
72
|
+
export function completeModule(rootDir, slug) {
|
|
73
|
+
const progress = loadProgress(rootDir);
|
|
74
|
+
|
|
75
|
+
if (!progress.modules[slug]) {
|
|
76
|
+
progress.modules[slug] = {
|
|
77
|
+
started: new Date().toISOString().split('T')[0],
|
|
78
|
+
xp_earned: 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
progress.modules[slug].status = 'completed';
|
|
83
|
+
progress.modules[slug].completed = new Date().toISOString().split('T')[0];
|
|
84
|
+
|
|
85
|
+
progress.user.modules_completed = Object.values(progress.modules)
|
|
86
|
+
.filter(m => m.status === 'completed')
|
|
87
|
+
.length;
|
|
88
|
+
|
|
89
|
+
saveProgress(rootDir, progress);
|
|
90
|
+
return progress;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update walkthrough step for a module.
|
|
95
|
+
*/
|
|
96
|
+
export function updateWalkthroughStep(rootDir, slug, step) {
|
|
97
|
+
const progress = loadProgress(rootDir);
|
|
98
|
+
|
|
99
|
+
if (!progress.modules[slug]) {
|
|
100
|
+
progress.modules[slug] = {
|
|
101
|
+
status: 'in-progress',
|
|
102
|
+
started: new Date().toISOString().split('T')[0],
|
|
103
|
+
xp_earned: 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
progress.modules[slug].walkthrough_step = step;
|
|
108
|
+
progress.modules[slug].last_session = new Date().toISOString().split('T')[0];
|
|
109
|
+
|
|
110
|
+
saveProgress(rootDir, progress);
|
|
111
|
+
return progress;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Record a quiz score for a module.
|
|
116
|
+
*/
|
|
117
|
+
export function recordQuizScore(rootDir, slug, score) {
|
|
118
|
+
const progress = loadProgress(rootDir);
|
|
119
|
+
|
|
120
|
+
if (!progress.modules[slug]) {
|
|
121
|
+
progress.modules[slug] = {
|
|
122
|
+
status: 'in-progress',
|
|
123
|
+
started: new Date().toISOString().split('T')[0],
|
|
124
|
+
xp_earned: 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
progress.modules[slug].quiz_score = score;
|
|
129
|
+
progress.modules[slug].last_session = new Date().toISOString().split('T')[0];
|
|
130
|
+
|
|
131
|
+
saveProgress(rootDir, progress);
|
|
132
|
+
return progress;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get summary stats from progress.
|
|
137
|
+
*/
|
|
138
|
+
export function getStats(progress) {
|
|
139
|
+
return {
|
|
140
|
+
xp: progress.user?.xp || 0,
|
|
141
|
+
belt: progress.user?.belt || 'white',
|
|
142
|
+
modulesCompleted: progress.user?.modules_completed || 0,
|
|
143
|
+
modulesInProgress: Object.values(progress.modules || {})
|
|
144
|
+
.filter(m => m.status === 'in-progress').length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function defaultProgress() {
|
|
149
|
+
return {
|
|
150
|
+
user: {
|
|
151
|
+
xp: 0,
|
|
152
|
+
belt: 'white',
|
|
153
|
+
modules_completed: 0,
|
|
154
|
+
},
|
|
155
|
+
modules: {},
|
|
156
|
+
};
|
|
157
|
+
}
|