@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.
Files changed (3) hide show
  1. package/README.md +46 -1
  2. package/dist/index.mjs +231 -19
  3. 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 `.claude/` |
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.2.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 verifyBundleSignature(responseBody, signature, keyId) {
3460
- const key = KEYSET.find((k3) => k3.keyId === keyId);
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(responseBody), pubKey, sig);
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 result = await apiGet(`/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}`, { token, signal: options.signal });
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
- if (signature && keyIdRaw) {
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(responseBody, signature, keyId);
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 bundle = result.data;
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
- renderGetResult(ctx, bundle, writeResult, options.dryRun === true, profile);
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.2.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"