@przeprogramowani/10x-cli 0.2.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 +46 -1
- package/dist/index.mjs +231 -19
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -28,9 +28,40 @@ npm install -g @przeprogramowani/10x-cli
|
|
|
28
28
|
|---------|-------------|
|
|
29
29
|
| `10x auth` | Magic-link login with your Circle-registered email |
|
|
30
30
|
| `10x list` | Browse modules and lessons in your course |
|
|
31
|
-
| `10x get <ref>` | Fetch a lesson and apply artifacts to
|
|
31
|
+
| `10x get <ref>` | Fetch a lesson and apply artifacts to your workspace |
|
|
32
32
|
| `10x doctor` | Diagnose auth, API connectivity, and local config |
|
|
33
33
|
|
|
34
|
+
### `10x get` Flags
|
|
35
|
+
|
|
36
|
+
| Flag | Description |
|
|
37
|
+
|------|-------------|
|
|
38
|
+
| `--tool <tool>` | AI coding tool: `claude-code`, `cursor`, `copilot`, `codex`, `generic` |
|
|
39
|
+
| `--print` | Output artifact content to stdout instead of writing files |
|
|
40
|
+
| `--type <type>` | Filter by artifact type: `skills`, `prompts`, `rules`, `configs` |
|
|
41
|
+
| `--name <name>` | Filter by artifact name (requires `--type`) |
|
|
42
|
+
| `--dry-run` | Show what would be written without touching the filesystem |
|
|
43
|
+
| `--course <slug>` | Override the course slug (default: `10xdevs3`) |
|
|
44
|
+
|
|
45
|
+
#### Examples
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Fetch full lesson — writes skills, prompts, rules, configs
|
|
49
|
+
10x get m1l1
|
|
50
|
+
|
|
51
|
+
# Write only skills (skip prompts, rules, configs)
|
|
52
|
+
10x get m1l1 --type skills
|
|
53
|
+
|
|
54
|
+
# Write a single artifact
|
|
55
|
+
10x get m1l1 --type skills --name code-review
|
|
56
|
+
|
|
57
|
+
# Print to stdout (pipe-friendly)
|
|
58
|
+
10x get m1l1 --print --type skills --name code-review
|
|
59
|
+
10x get m1l1 --print --type skills --name code-review | pbcopy
|
|
60
|
+
|
|
61
|
+
# Use with a different AI coding tool
|
|
62
|
+
10x get m1l1 --tool cursor
|
|
63
|
+
```
|
|
64
|
+
|
|
34
65
|
### Global Flags
|
|
35
66
|
|
|
36
67
|
- `--json` — Machine-readable JSON output (auto-detected when piped)
|
|
@@ -45,6 +76,20 @@ Lessons are referenced by module and lesson number:
|
|
|
45
76
|
- `m1l1` — Module 1, Lesson 1
|
|
46
77
|
- `m2l3` — Module 2, Lesson 3
|
|
47
78
|
|
|
79
|
+
### Multi-Tool Support
|
|
80
|
+
|
|
81
|
+
On first run, the CLI prompts you to choose your AI coding tool. Artifacts are written to the correct directory for your tool:
|
|
82
|
+
|
|
83
|
+
| Tool | Directory | Rules file |
|
|
84
|
+
|------|-----------|------------|
|
|
85
|
+
| Claude Code | `.claude/` | `CLAUDE.md` |
|
|
86
|
+
| Cursor | `.cursor/` | `.cursor/rules/10x-course.mdc` |
|
|
87
|
+
| GitHub Copilot | `.github/` | `.github/copilot-instructions.md` |
|
|
88
|
+
| Codex CLI | `.agents/` | `AGENTS.md` |
|
|
89
|
+
| Generic | `.ai/` | `AGENTS.md` |
|
|
90
|
+
|
|
91
|
+
Override anytime with `--tool <name>`. Your choice is saved in `~/.config/10x-cli/config.json`.
|
|
92
|
+
|
|
48
93
|
## Development
|
|
49
94
|
|
|
50
95
|
```bash
|
package/dist/index.mjs
CHANGED
|
@@ -2271,8 +2271,16 @@ var cac = (name = "") => new CAC(name);
|
|
|
2271
2271
|
// package.json
|
|
2272
2272
|
var package_default = {
|
|
2273
2273
|
name: "@przeprogramowani/10x-cli",
|
|
2274
|
-
version: "0.
|
|
2274
|
+
version: "0.4.0",
|
|
2275
2275
|
description: "Open-source CLI for 10xDevs course content",
|
|
2276
|
+
repository: {
|
|
2277
|
+
type: "git",
|
|
2278
|
+
url: "https://github.com/przeprogramowani/10x-cli.git"
|
|
2279
|
+
},
|
|
2280
|
+
homepage: "https://github.com/przeprogramowani/10x-cli",
|
|
2281
|
+
bugs: {
|
|
2282
|
+
url: "https://github.com/przeprogramowani/10x-cli/issues"
|
|
2283
|
+
},
|
|
2276
2284
|
type: "module",
|
|
2277
2285
|
bin: {
|
|
2278
2286
|
"10x": "./dist/index.mjs"
|
|
@@ -3053,7 +3061,7 @@ async function request(method, path, body, options = {}) {
|
|
|
3053
3061
|
payload
|
|
3054
3062
|
};
|
|
3055
3063
|
}
|
|
3056
|
-
return { ok: true, status: response.status, data: parsed, responseHeaders: response.headers };
|
|
3064
|
+
return { ok: true, status: response.status, data: parsed, responseHeaders: response.headers, rawBody: text };
|
|
3057
3065
|
}
|
|
3058
3066
|
function apiGet(path, options = {}) {
|
|
3059
3067
|
return request("GET", path, undefined, options);
|
|
@@ -3441,7 +3449,7 @@ import { existsSync as existsSync2, accessSync, constants } from "node:fs";
|
|
|
3441
3449
|
import { join as join2 } from "node:path";
|
|
3442
3450
|
|
|
3443
3451
|
// src/lib/signing.ts
|
|
3444
|
-
import { createPublicKey, verify } from "node:crypto";
|
|
3452
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
3445
3453
|
|
|
3446
3454
|
class SignatureError extends Error {
|
|
3447
3455
|
constructor(message) {
|
|
@@ -3456,11 +3464,54 @@ var KEYSET = [
|
|
|
3456
3464
|
}
|
|
3457
3465
|
];
|
|
3458
3466
|
var REQUIRE_SIGNATURES = true;
|
|
3459
|
-
function
|
|
3460
|
-
const
|
|
3467
|
+
function loadEffectiveKeyset() {
|
|
3468
|
+
const override = process.env["BUNDLE_PUBLIC_KEYSET"];
|
|
3469
|
+
if (!override)
|
|
3470
|
+
return KEYSET;
|
|
3471
|
+
const apiBase = process.env["API_BASE_URL"];
|
|
3472
|
+
if (!apiBase)
|
|
3473
|
+
return KEYSET;
|
|
3474
|
+
let hostname;
|
|
3475
|
+
try {
|
|
3476
|
+
hostname = new URL(apiBase).hostname;
|
|
3477
|
+
} catch {
|
|
3478
|
+
return KEYSET;
|
|
3479
|
+
}
|
|
3480
|
+
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
3481
|
+
return KEYSET;
|
|
3482
|
+
}
|
|
3483
|
+
try {
|
|
3484
|
+
const parsed = JSON.parse(override);
|
|
3485
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
3486
|
+
return KEYSET;
|
|
3487
|
+
for (const k3 of parsed) {
|
|
3488
|
+
if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
|
|
3489
|
+
return KEYSET;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return parsed;
|
|
3493
|
+
} catch {
|
|
3494
|
+
return KEYSET;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
function sha256Hex(body) {
|
|
3498
|
+
return createHash("sha256").update(body).digest("hex");
|
|
3499
|
+
}
|
|
3500
|
+
function canonicalSigningString(keyId, hashHex) {
|
|
3501
|
+
return `v1:${keyId}:${hashHex}`;
|
|
3502
|
+
}
|
|
3503
|
+
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
3504
|
+
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
3505
|
+
}
|
|
3506
|
+
function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
|
|
3507
|
+
const key = keyset.find((k3) => k3.keyId === keyId);
|
|
3461
3508
|
if (!key) {
|
|
3462
3509
|
throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
|
|
3463
3510
|
}
|
|
3511
|
+
const computedHash = sha256Hex(rawBody);
|
|
3512
|
+
if (computedHash !== headerHash) {
|
|
3513
|
+
throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
|
|
3514
|
+
}
|
|
3464
3515
|
let pubKey;
|
|
3465
3516
|
try {
|
|
3466
3517
|
pubKey = createPublicKey({
|
|
@@ -3471,8 +3522,9 @@ function verifyBundleSignature(responseBody, signature, keyId) {
|
|
|
3471
3522
|
} catch {
|
|
3472
3523
|
throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
|
|
3473
3524
|
}
|
|
3525
|
+
const canonical = canonicalSigningString(keyId, computedHash);
|
|
3474
3526
|
const sig = Buffer.from(signature, "base64");
|
|
3475
|
-
const valid = verify(null, Buffer.from(
|
|
3527
|
+
const valid = verify(null, Buffer.from(canonical), pubKey, sig);
|
|
3476
3528
|
if (!valid) {
|
|
3477
3529
|
throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
|
|
3478
3530
|
}
|
|
@@ -3486,16 +3538,21 @@ function fetchModuleDetail(course, module, token, options = {}) {
|
|
|
3486
3538
|
return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
|
|
3487
3539
|
}
|
|
3488
3540
|
async function fetchLesson(course, lessonId, token, options = {}) {
|
|
3489
|
-
const
|
|
3541
|
+
const params = new URLSearchParams;
|
|
3542
|
+
if (options.lang)
|
|
3543
|
+
params.set("lang", options.lang);
|
|
3544
|
+
const qs = params.toString();
|
|
3545
|
+
const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
|
|
3546
|
+
const result = await apiGet(path, { token, signal: options.signal });
|
|
3490
3547
|
if (!result.ok)
|
|
3491
3548
|
return result;
|
|
3492
3549
|
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
3493
3550
|
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
3494
|
-
|
|
3551
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
3552
|
+
if (signature && keyIdRaw && headerHash) {
|
|
3495
3553
|
const keyId = Number(keyIdRaw);
|
|
3496
|
-
const responseBody = JSON.stringify(result.data);
|
|
3497
3554
|
try {
|
|
3498
|
-
verifyBundleSignature(
|
|
3555
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
3499
3556
|
} catch (err) {
|
|
3500
3557
|
const message = err instanceof Error ? err.message : String(err);
|
|
3501
3558
|
return {
|
|
@@ -3505,6 +3562,13 @@ async function fetchLesson(course, lessonId, token, options = {}) {
|
|
|
3505
3562
|
error: message
|
|
3506
3563
|
};
|
|
3507
3564
|
}
|
|
3565
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
3566
|
+
return {
|
|
3567
|
+
ok: false,
|
|
3568
|
+
status: 0,
|
|
3569
|
+
code: "signature_error",
|
|
3570
|
+
error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
3571
|
+
};
|
|
3508
3572
|
} else if (REQUIRE_SIGNATURES) {
|
|
3509
3573
|
return {
|
|
3510
3574
|
ok: false,
|
|
@@ -3518,6 +3582,50 @@ async function fetchLesson(course, lessonId, token, options = {}) {
|
|
|
3518
3582
|
}
|
|
3519
3583
|
return result;
|
|
3520
3584
|
}
|
|
3585
|
+
async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
|
|
3586
|
+
const params = new URLSearchParams({ tool });
|
|
3587
|
+
if (options.lang)
|
|
3588
|
+
params.set("lang", options.lang);
|
|
3589
|
+
const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
|
|
3590
|
+
const result = await apiGet(path, { token, signal: options.signal });
|
|
3591
|
+
if (!result.ok)
|
|
3592
|
+
return result;
|
|
3593
|
+
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
3594
|
+
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
3595
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
3596
|
+
if (signature && keyIdRaw && headerHash) {
|
|
3597
|
+
const keyId = Number(keyIdRaw);
|
|
3598
|
+
try {
|
|
3599
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
3600
|
+
} catch (err) {
|
|
3601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3602
|
+
return {
|
|
3603
|
+
ok: false,
|
|
3604
|
+
status: 0,
|
|
3605
|
+
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
3606
|
+
error: message
|
|
3607
|
+
};
|
|
3608
|
+
}
|
|
3609
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
3610
|
+
return {
|
|
3611
|
+
ok: false,
|
|
3612
|
+
status: 0,
|
|
3613
|
+
code: "signature_error",
|
|
3614
|
+
error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
3615
|
+
};
|
|
3616
|
+
} else if (REQUIRE_SIGNATURES) {
|
|
3617
|
+
return {
|
|
3618
|
+
ok: false,
|
|
3619
|
+
status: 0,
|
|
3620
|
+
code: "signature_missing",
|
|
3621
|
+
error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
3622
|
+
};
|
|
3623
|
+
} else {
|
|
3624
|
+
process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
|
|
3625
|
+
`);
|
|
3626
|
+
}
|
|
3627
|
+
return result;
|
|
3628
|
+
}
|
|
3521
3629
|
async function fetchHealth(options = {}) {
|
|
3522
3630
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
3523
3631
|
const controller = new AbortController;
|
|
@@ -4023,6 +4131,7 @@ var CLI_VERSION = package_default.version;
|
|
|
4023
4131
|
var DEFAULT_COURSE = "10xdevs3";
|
|
4024
4132
|
function applyBundle(bundle, projectRoot, options = {}) {
|
|
4025
4133
|
const dryRun = options.dryRun === true;
|
|
4134
|
+
const partial = options.partial === true;
|
|
4026
4135
|
const course = options.course ?? DEFAULT_COURSE;
|
|
4027
4136
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
4028
4137
|
const manifestDir = join4(projectRoot, profile.manifestDir);
|
|
@@ -4078,7 +4187,7 @@ function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4078
4187
|
}
|
|
4079
4188
|
return { name: config.name, path: target, action };
|
|
4080
4189
|
});
|
|
4081
|
-
if (!dryRun) {
|
|
4190
|
+
if (!dryRun && !partial) {
|
|
4082
4191
|
const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
|
|
4083
4192
|
for (const entry of removed.skills)
|
|
4084
4193
|
rmSync2(entry.path, { recursive: true, force: true });
|
|
@@ -4087,7 +4196,7 @@ function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
4087
4196
|
for (const entry of removed.configs)
|
|
4088
4197
|
rmSync2(entry.path, { force: true });
|
|
4089
4198
|
}
|
|
4090
|
-
if (!dryRun) {
|
|
4199
|
+
if (!dryRun && !partial) {
|
|
4091
4200
|
const nextManifest = {
|
|
4092
4201
|
package: CLI_PACKAGE_NAME,
|
|
4093
4202
|
version: CLI_VERSION,
|
|
@@ -4213,9 +4322,13 @@ function isSafeName(name) {
|
|
|
4213
4322
|
}
|
|
4214
4323
|
|
|
4215
4324
|
// src/commands/get.ts
|
|
4325
|
+
var ARTIFACT_TYPES = ["skills", "prompts", "rules", "configs"];
|
|
4326
|
+
function isArtifactType(s) {
|
|
4327
|
+
return ARTIFACT_TYPES.includes(s);
|
|
4328
|
+
}
|
|
4216
4329
|
var DEFAULT_COURSE2 = "10xdevs3";
|
|
4217
4330
|
function registerGetCommand(cli) {
|
|
4218
|
-
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, generic)").action(async (ref, options) => {
|
|
4331
|
+
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Override the course slug (default: 10xdevs3)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, generic)").option("--print", "Print artifact content to stdout instead of writing to files").option("--type <type>", "Artifact type filter: skills, prompts, rules, configs").option("--name <name>", "Artifact name filter (requires --type)").option("--lang <lang>", "Content language: en (default) or pl").action(async (ref, options) => {
|
|
4219
4332
|
const ctx = resolveContext(options);
|
|
4220
4333
|
await runGet(ctx, ref, options);
|
|
4221
4334
|
});
|
|
@@ -4225,23 +4338,114 @@ async function runGet(ctx, ref, options) {
|
|
|
4225
4338
|
if (!parsed) {
|
|
4226
4339
|
outputError(ctx, "invalid_lesson_ref", `'${ref}' is not a valid lesson reference.`, ExitCodes.USAGE, "Use the form 'm<module>l<lesson>', for example 'm1l1' or 'm2l3'.");
|
|
4227
4340
|
}
|
|
4341
|
+
if (options.name && !options.type) {
|
|
4342
|
+
outputError(ctx, "missing_type", "Specify --type when using --name.", ExitCodes.USAGE, "Usage: 10x get m1l1 --type skills --name code-review");
|
|
4343
|
+
}
|
|
4344
|
+
if (options.type && !isArtifactType(options.type)) {
|
|
4345
|
+
outputError(ctx, "invalid_type", `Unknown artifact type '${options.type}'.`, ExitCodes.USAGE, `Supported types: ${ARTIFACT_TYPES.join(", ")}`);
|
|
4346
|
+
}
|
|
4347
|
+
const SUPPORTED_LANGS = ["en", "pl"];
|
|
4348
|
+
if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
|
|
4349
|
+
outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
|
|
4350
|
+
}
|
|
4228
4351
|
const auth = await requireAuth(ctx);
|
|
4229
4352
|
const course = options.course ?? DEFAULT_COURSE2;
|
|
4230
4353
|
const profile = await resolveToolProfile(options.tool);
|
|
4354
|
+
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
|
|
4355
|
+
if (options.lang) {
|
|
4356
|
+
const existing = readToolConfig();
|
|
4357
|
+
saveToolConfig({ tool: existing?.tool ?? profile.toolId, lang: options.lang });
|
|
4358
|
+
}
|
|
4359
|
+
if (options.print) {
|
|
4360
|
+
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4231
4363
|
verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
|
|
4232
|
-
const result = await fetchLesson(course, parsed.lessonId, auth.access_token);
|
|
4364
|
+
const result = await fetchLesson(course, parsed.lessonId, auth.access_token, { lang });
|
|
4233
4365
|
if (!result.ok) {
|
|
4234
4366
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4235
4367
|
}
|
|
4368
|
+
const contentLang = result.responseHeaders.get("X-Content-Language");
|
|
4369
|
+
const isFallback = result.responseHeaders.get("X-Content-Fallback") === "true";
|
|
4370
|
+
if (isFallback && contentLang) {
|
|
4371
|
+
verbose(ctx, `${lang.toUpperCase()} not available for ${parsed.lessonId}, showing ${contentLang.toUpperCase()}.`);
|
|
4372
|
+
}
|
|
4236
4373
|
const orphanWarning = detectOrphanedArtifacts(process.cwd(), profile);
|
|
4237
4374
|
if (orphanWarning)
|
|
4238
4375
|
verbose(ctx, orphanWarning);
|
|
4239
|
-
const
|
|
4376
|
+
const isFiltered = options.type !== undefined;
|
|
4377
|
+
const bundle = filterBundle(ctx, result.data, options);
|
|
4240
4378
|
const writeResult = applyBundle(bundle, process.cwd(), {
|
|
4241
4379
|
dryRun: options.dryRun === true,
|
|
4242
|
-
profile
|
|
4380
|
+
profile,
|
|
4381
|
+
partial: isFiltered
|
|
4382
|
+
});
|
|
4383
|
+
renderGetResult(ctx, bundle, writeResult, options.dryRun === true, profile, {
|
|
4384
|
+
language: contentLang ?? lang,
|
|
4385
|
+
languageFallback: isFallback
|
|
4243
4386
|
});
|
|
4244
|
-
|
|
4387
|
+
}
|
|
4388
|
+
function filterBundle(ctx, bundle, options) {
|
|
4389
|
+
if (!options.type) {
|
|
4390
|
+
if (options.name) {
|
|
4391
|
+
outputError(ctx, "missing_type", "Specify --type when using --name.", ExitCodes.USAGE, "Usage: 10x get m1l1 --type skills --name code-review");
|
|
4392
|
+
}
|
|
4393
|
+
return bundle;
|
|
4394
|
+
}
|
|
4395
|
+
if (!isArtifactType(options.type)) {
|
|
4396
|
+
outputError(ctx, "invalid_type", `Unknown artifact type '${options.type}'.`, ExitCodes.USAGE, `Supported types: ${ARTIFACT_TYPES.join(", ")}`);
|
|
4397
|
+
}
|
|
4398
|
+
const type = options.type;
|
|
4399
|
+
const empty = {
|
|
4400
|
+
...bundle,
|
|
4401
|
+
skills: [],
|
|
4402
|
+
prompts: [],
|
|
4403
|
+
rules: [],
|
|
4404
|
+
configs: []
|
|
4405
|
+
};
|
|
4406
|
+
if (options.name) {
|
|
4407
|
+
const match = bundle[type].find((a2) => a2.name === options.name);
|
|
4408
|
+
if (!match) {
|
|
4409
|
+
outputError(ctx, "artifact_not_found", `No ${type} artifact named '${options.name}' in ${bundle.lessonId}.`, ExitCodes.NOT_FOUND, `Available ${type}: ${bundle[type].map((a2) => a2.name).join(", ") || "(none)"}`);
|
|
4410
|
+
}
|
|
4411
|
+
return { ...empty, [type]: [match] };
|
|
4412
|
+
}
|
|
4413
|
+
return { ...empty, [type]: bundle[type] };
|
|
4414
|
+
}
|
|
4415
|
+
async function runPrintMode(ctx, lessonId, course, profile, token, lang, options) {
|
|
4416
|
+
if (!options.type) {
|
|
4417
|
+
outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
|
|
4418
|
+
}
|
|
4419
|
+
if (!isArtifactType(options.type)) {
|
|
4420
|
+
outputError(ctx, "invalid_type", `Unknown artifact type '${options.type}'.`, ExitCodes.USAGE, `Supported types: ${ARTIFACT_TYPES.join(", ")}`);
|
|
4421
|
+
}
|
|
4422
|
+
if (options.name) {
|
|
4423
|
+
verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
|
|
4424
|
+
const result = await fetchArtifact(course, lessonId, options.type, options.name, profile.toolId, token, { lang });
|
|
4425
|
+
if (!result.ok) {
|
|
4426
|
+
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4427
|
+
}
|
|
4428
|
+
if (ctx.json) {
|
|
4429
|
+
output(ctx, "", result.data);
|
|
4430
|
+
} else {
|
|
4431
|
+
process.stdout.write(result.data.content);
|
|
4432
|
+
}
|
|
4433
|
+
} else {
|
|
4434
|
+
verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
|
|
4435
|
+
const result = await fetchLesson(course, lessonId, token, { lang });
|
|
4436
|
+
if (!result.ok) {
|
|
4437
|
+
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
4438
|
+
}
|
|
4439
|
+
const artifacts = result.data[options.type];
|
|
4440
|
+
if (ctx.json) {
|
|
4441
|
+
output(ctx, "", artifacts);
|
|
4442
|
+
} else {
|
|
4443
|
+
const contents = artifacts.map((a2) => a2.content);
|
|
4444
|
+
process.stdout.write(contents.join(`
|
|
4445
|
+
---
|
|
4446
|
+
`));
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4245
4449
|
}
|
|
4246
4450
|
function handleLessonError(ctx, status, code, error, payload) {
|
|
4247
4451
|
verbose(ctx, `lesson fetch failed: status=${status} code=${code}`);
|
|
@@ -4276,13 +4480,15 @@ function handleLessonError(ctx, status, code, error, payload) {
|
|
|
4276
4480
|
}
|
|
4277
4481
|
outputError(ctx, code || "lesson_fetch_failed", "Failed to fetch the lesson bundle.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
4278
4482
|
}
|
|
4279
|
-
function renderGetResult(ctx, bundle, writeResult, dryRun, profile) {
|
|
4483
|
+
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = { language: "en", languageFallback: false }) {
|
|
4280
4484
|
if (ctx.json) {
|
|
4281
4485
|
output(ctx, "", {
|
|
4282
4486
|
lessonId: bundle.lessonId,
|
|
4283
4487
|
title: bundle.title,
|
|
4284
4488
|
summary: bundle.summary,
|
|
4285
4489
|
tool: profile.toolId,
|
|
4490
|
+
language: langMeta.language,
|
|
4491
|
+
languageFallback: langMeta.languageFallback,
|
|
4286
4492
|
dry_run: dryRun,
|
|
4287
4493
|
writes: {
|
|
4288
4494
|
skills: writeResult.skills,
|
|
@@ -4422,7 +4628,8 @@ function renderModuleDetail(ctx, module) {
|
|
|
4422
4628
|
lessonId: l2.lessonId,
|
|
4423
4629
|
lesson: l2.lesson,
|
|
4424
4630
|
title: l2.title,
|
|
4425
|
-
summary: l2.summary
|
|
4631
|
+
summary: l2.summary,
|
|
4632
|
+
availableLanguages: l2.availableLanguages ?? ["en"]
|
|
4426
4633
|
}))
|
|
4427
4634
|
});
|
|
4428
4635
|
return;
|
|
@@ -4440,6 +4647,11 @@ function renderModuleDetail(ctx, module) {
|
|
|
4440
4647
|
lines.push(` ${l2.summary}`);
|
|
4441
4648
|
}
|
|
4442
4649
|
}
|
|
4650
|
+
const hasMultiLang = module.lessons.some((l2) => l2.availableLanguages && l2.availableLanguages.length > 1);
|
|
4651
|
+
if (hasMultiLang) {
|
|
4652
|
+
lines.push("");
|
|
4653
|
+
lines.push("Language variants available. Use --lang pl to fetch Polish content.");
|
|
4654
|
+
}
|
|
4443
4655
|
output(ctx, lines.join(`
|
|
4444
4656
|
`), undefined);
|
|
4445
4657
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@przeprogramowani/10x-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Open-source CLI for 10xDevs course content",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/przeprogramowani/10x-cli.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/przeprogramowani/10x-cli",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/przeprogramowani/10x-cli/issues"
|
|
12
|
+
},
|
|
5
13
|
"type": "module",
|
|
6
14
|
"bin": {
|
|
7
15
|
"10x": "./dist/index.mjs"
|