@prettifystudio/lyngo-cli 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/bin/lyngo +160 -6
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # lyngo
2
2
 
3
- Localization like code, from your terminal. Your agent builds the product;
3
+ Manual: https://docs.lyngo.app (every page also as Markdown; https://docs.lyngo.app/llms-full.txt for agents).
4
+
5
+ Localization for Laravel applications, from your terminal: Blade, Livewire, PHP, and Inertia with React, Vue or Svelte. Your agent builds the product;
4
6
  Lyngo keeps it localized.
5
7
 
6
8
  When you or your agent add text a person reads, `lyngo localize` wraps it with
package/bin/lyngo CHANGED
@@ -28,7 +28,10 @@ const TOKEN_DIR = path.join(os.homedir(), '.config', 'lyngo');
28
28
  const TOKEN_FILE = path.join(TOKEN_DIR, 'token');
29
29
  // Milliseconds per protocol second. Tests shrink it; people never set it.
30
30
  const TICK = Number(process.env.LYNGO_TICK_MS) > 0 ? Number(process.env.LYNGO_TICK_MS) : 1000;
31
- const HOOK_COMMAND = 'lyngo localize 2>&1 | tail -20';
31
+ // The Stop hook says so when the CLI is not on PATH, instead of a "command not
32
+ // found" that scrolls past unread (corp1 setup, 2026-10-18).
33
+ const HOOK_COMMAND = 'if command -v lyngo >/dev/null 2>&1; then lyngo localize 2>&1 | tail -20; else echo "lyngo: the CLI is not on PATH. Install it: npm i -g @prettifystudio/lyngo-cli"; fi';
34
+ const OLD_HOOK_COMMANDS = ['lyngo localize 2>&1 | tail -20'];
32
35
  const SRC = /\.(blade\.php|php|tsx|jsx|ts|js|vue|svelte)$/;
33
36
  const SKIP = /(^|\/)(vendor|node_modules|tests?|database|storage|bootstrap|public|config|\.git)\//;
34
37
 
@@ -340,9 +343,15 @@ function setUpClaudeCode(top, remote) {
340
343
  settings.hooks = settings.hooks || {};
341
344
  const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
342
345
  const has = stop.some((group) => (group.hooks || []).some((h) => typeof h.command === 'string' && /\blyngo\b[^|]*\blocalize\b/.test(h.command)));
346
+ let upgraded = false;
347
+ for (const group of stop) for (const h of group.hooks || []) {
348
+ if (OLD_HOOK_COMMANDS.includes(h.command)) { h.command = HOOK_COMMAND; upgraded = true; }
349
+ }
343
350
  if (!has) {
344
351
  stop.push({ hooks: [{ type: 'command', command: HOOK_COMMAND }] });
345
352
  settings.hooks.Stop = stop;
353
+ }
354
+ if (!has || upgraded) {
346
355
  fs.mkdirSync(dir, { recursive: true });
347
356
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
348
357
  }
@@ -384,9 +393,11 @@ async function init(opts) {
384
393
  console.log(`Detected: ${detected.summary || 'no i18n setup found'}`);
385
394
  if (detected.i18n) project = { ...project, i18n: detected.i18n };
386
395
 
396
+ // The agent setup comes before the language question: a run that stops
397
+ // there still leaves the hook and the MCP server in place (corp1, 2026-10-18).
398
+ setUpClaudeCode(top, remote);
387
399
  project = await chooseLanguages(project, opts);
388
400
  await scaffold(project);
389
- setUpClaudeCode(top, remote);
390
401
  await fillMissingCatalogs(project, opts);
391
402
  console.log('Done. Build as usual; lyngo localize runs when your agent finishes, or run it yourself before committing.');
392
403
  }
@@ -452,10 +463,149 @@ async function translateLanguage(code, project) {
452
463
  fs.writeFileSync(targetPath, out.content);
453
464
  const l = (r.report && r.report.languages && r.report.languages[code]) || {};
454
465
  translated += l.translated || 0; memory += l.from_memory || 0; flagged.push(...(l.flagged || []));
455
- process.stdout.write(`\r${code}: ${Math.min(i + 100, missing.length)}/${missing.length}`);
466
+ if (process.stdout.isTTY) process.stdout.write(`\r${code}: ${Math.min(i + 100, missing.length)}/${missing.length}`);
456
467
  }
457
468
  const flaggedText = flagged.length ? `; ${flagged.length} flagged for review: ${flagged.slice(0, 3).map((x) => JSON.stringify(x.key || x)).join(', ')}` : '';
458
- console.log(`\r${code}: ${translated} translated, ${memory} from memory${flaggedText} -> ${targetPath}`);
469
+ console.log(`${process.stdout.isTTY ? '\r' : ''}${code}: ${translated} translated, ${memory} from memory${flaggedText} -> ${targetPath}`);
470
+ }
471
+
472
+ // ── onboard: the "copy this into your agent" path ──────────────────────────
473
+ // Written for an agent to follow: every run does what it can, reports each
474
+ // step to the server under the run id (the page at /start/<id> shows them),
475
+ // and stops with one clear instruction whenever a person must act. The same
476
+ // command, run again, continues from where it stopped.
477
+ const RUN_DIR = path.join(TOKEN_DIR, 'runs');
478
+ function runState(id) { try { return JSON.parse(fs.readFileSync(path.join(RUN_DIR, id + '.json'), 'utf8')); } catch { return {}; } }
479
+ function saveRunState(id, state) { fs.mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(RUN_DIR, id + '.json'), JSON.stringify(state), { mode: 0o600 }); }
480
+
481
+ async function onboard(opts) {
482
+ const id = String(opts.run || '').trim();
483
+ if (!/^[0-9a-f-]{20,}$/i.test(id)) fail('usage: lyngo onboard --run=<id> (the id comes from lyngo.app/start)');
484
+ const again = `lyngo onboard --run=${id}`;
485
+ const state = runState(id);
486
+ let projectId = state.project_id || null;
487
+ const report = async (step, st, detail) => {
488
+ const body = { step, state: st, detail: detail ? String(detail).slice(0, 480) : undefined, project_id: projectId || undefined };
489
+ try { body.repo = repoRemote() || undefined; } catch {}
490
+ try { await api('POST', `/cli/runs/${id}/step`, { auth: !!readToken(false), body }); } catch {}
491
+ };
492
+ const stop = (lines) => {
493
+ console.log('\nWAITING FOR THE PERSON');
494
+ for (const l of lines) console.log(' ' + l);
495
+ console.log(` Then run: ${again}`);
496
+ };
497
+ const step = (n, text) => console.log(`\n[${n}/7] ${text}`);
498
+
499
+ // 1. The repository: git, a GitHub remote, and Laravel.
500
+ step(1, 'Checking the repository');
501
+ let top;
502
+ try { top = root(); } catch { await report('cli', 'failed', 'not a git repository'); fail('this folder is not a git repository. Ask the person for the root of their Laravel project, then run this command there.'); }
503
+ process.chdir(top);
504
+ const remote = repoRemote();
505
+ if (!remote) { await report('cli', 'failed', 'no origin remote'); fail('this repository has no origin remote on GitHub. Ask the person to push it to GitHub first, then run this command again.'); }
506
+ const composer = readIfFile('composer.json') || '';
507
+ if (!/"laravel\/framework"/.test(composer)) { await report('cli', 'failed', 'not a Laravel application'); fail('this is not a Laravel application (no laravel/framework in composer.json). Lyngo is for Laravel for now. Ask the person which folder holds their Laravel app.'); }
508
+ await report('cli', 'done', `${path.basename(top)} on ${remote}`);
509
+ console.log(` ${path.basename(top)}: Laravel, remote ${remote}`);
510
+
511
+ // 2. Login: a saved token, a pending code, or a new code.
512
+ step(2, 'Logging in');
513
+ let me = null;
514
+ if (readToken(false)) {
515
+ const r = await api('GET', '/cli/me');
516
+ if (r.status === 200) me = r.data.user || r.data;
517
+ }
518
+ if (!me) {
519
+ let pending = state.device;
520
+ if (pending && pending.expires_at > Date.now()) {
521
+ for (let i = 0; i < 3 && !me; i++) {
522
+ const r = await api('POST', '/cli/token', { auth: false, body: { device_code: pending.device_code } });
523
+ if (r.status === 200 && r.data.token) { saveToken(r.data.token); me = r.data.user || {}; break; }
524
+ if (r.data.error === 'authorization_pending' || r.data.error === 'slow_down') { await sleep(3); continue; }
525
+ pending = null; break;
526
+ }
527
+ } else pending = null;
528
+ if (!me) {
529
+ if (!pending) {
530
+ const d = expect(await api('POST', '/cli/device', { auth: false, body: { hostname: os.hostname() } }), 200);
531
+ if (!d || !d.user_code || !d.device_code) fail('the server did not answer with a login code. Check LYNGO_HOST.');
532
+ pending = { device_code: d.device_code, user_code: d.user_code, url: d.verification_url_complete || d.verification_url, expires_at: Date.now() + (Number(d.expires_in) > 0 ? Number(d.expires_in) : 900) * TICK };
533
+ saveRunState(id, { ...state, device: pending });
534
+ }
535
+ await report('login', 'waiting', `Enter ${pending.user_code} at ${pending.url}`);
536
+ stop([`Ask the person to open ${pending.url} and enter the code ${pending.user_code}, then confirm.`, 'They need a Lyngo account (lyngo.app); the page lets them sign up first.']);
537
+ return;
538
+ }
539
+ }
540
+ saveRunState(id, { ...state, device: null, project_id: projectId });
541
+ await report('login', 'done', me.email || me.name || 'signed in');
542
+ console.log(` Logged in as ${me.email || me.name || 'you'}.`);
543
+
544
+ // 3. GitHub and the project.
545
+ step(3, 'Connecting the repository');
546
+ const head = (gitTry(['rev-parse', '--abbrev-ref', 'HEAD']) || '').trim();
547
+ const branch = head && head !== 'HEAD' ? head : undefined;
548
+ const r = await api('POST', '/cli/projects', { body: { remote, branch, workspace: opts.workspace } });
549
+ let project = null;
550
+ if ((r.status === 200 || r.status === 201) && r.data.project) project = r.data.project;
551
+ else if (r.data.status === 'needs_github_app' || r.status === 409) {
552
+ await report('github', 'waiting', r.data.owner ? 'the GitHub App must cover the repository' : 'the workspace owner must connect GitHub');
553
+ const lines = r.data.owner
554
+ ? [`Ask the person to install the Lyngo GitHub app on this repository: ${r.data.connect_url}`, 'On GitHub: the Lyngo app, Configure, Repository access, add the repository.']
555
+ : [`Ask the person to have the workspace owner connect GitHub for this repository at ${r.data.connect_url || HOST + '/source-control'}.`];
556
+ stop(lines);
557
+ return;
558
+ } else { await report('github', 'failed', messageOf(r.data, r.status)); printProblem(r); fail('the project could not be created. The message above says why.'); }
559
+ projectId = project.id;
560
+ saveRunState(id, { ...state, device: null, project_id: projectId });
561
+ await report('github', 'done', project.full_name || project.name);
562
+ console.log(` ${r.data.status === 'created' ? 'Created' : 'Found'} ${project.name}.`);
563
+
564
+ // 4. Detection and languages.
565
+ step(4, 'Detecting the i18n setup');
566
+ await report('setup', 'running', 'detecting');
567
+ const detected = expect(await api('POST', `/cli/projects/${project.id}/detect`, { body: {} }), 200);
568
+ console.log(` Detected: ${detected.summary || 'no i18n setup found'}`);
569
+ if (detected.i18n) project = { ...project, i18n: detected.i18n };
570
+ const primary = opts.primary || project.primary_language || 'en';
571
+ let targets = (project.languages || []).filter((l) => l !== primary);
572
+ if (opts.languages) {
573
+ const list = [primary, ...opts.languages.filter((l) => l !== primary)];
574
+ const u = await api('PATCH', `/cli/projects/${project.id}`, { body: { languages: list, primary_language: primary } });
575
+ if (u.status !== 200) { printProblem(u); await report('languages', 'failed', messageOf(u.data, u.status)); fail('the languages were not saved. The message above says why.'); }
576
+ project = { ...project, ...(u.data.project || {}) };
577
+ targets = (project.languages || list).filter((l) => l !== primary);
578
+ }
579
+ if (!targets.length) {
580
+ await report('languages', 'waiting', 'which languages to translate into');
581
+ stop([`Ask the person which languages to translate into (codes such as fr, de, ar, ja), source ${primary}.`, `Then run: ${again} --languages fr,de`]);
582
+ return;
583
+ }
584
+ await report('languages', 'done', `${primary} -> ${targets.join(', ')}`);
585
+ console.log(` Languages: ${primary} -> ${targets.join(', ')}`);
586
+
587
+ // 5. Setup as a patch, 6. the hook, missing catalogs.
588
+ step(5, 'Applying the i18n setup');
589
+ await scaffold(project);
590
+ await report('setup', 'done', detected.summary || 'ready');
591
+ step(6, 'Wiring the agent');
592
+ setUpClaudeCode(top, remote);
593
+ await report('hook', 'done', findOnPath('claude') ? 'Stop hook and MCP server registered' : 'no Claude Code here; run lyngo localize before committing');
594
+ await fillMissingCatalogs(project, { yes: true });
595
+
596
+ // 7. The first localized change.
597
+ step(7, 'Localizing a first change');
598
+ const changed = changedFiles(top);
599
+ if (!changed.length) {
600
+ await report('localize', 'waiting', 'make one change, then run again');
601
+ stop(['Make one change a person can see: add a sentence of plain text to a view or a page component (no i18n call, the CLI adds it).', 'You may do this yourself: pick a page under resources/, add one paragraph of English text.']);
602
+ return;
603
+ }
604
+ const result = await localize({});
605
+ const summary = result ? `${result.wrapped} strings localized in ${result.files} files; ${result.added} new keys, ${result.reused} reused; ${result.languages} languages filled` : 'nothing to localize in the changed files';
606
+ await report('localize', 'done', summary);
607
+ console.log(`\nDONE. ${summary}.`);
608
+ console.log('Show the person `git diff`, and tell them: build as usual; lyngo localize runs when the agent finishes a task, or by hand before a commit.');
459
609
  }
460
610
 
461
611
  // ── localize, check, status ────────────────────────────────────────────────
@@ -483,7 +633,7 @@ async function localize(opts) {
483
633
  if (fs.existsSync(p)) catalogs.push({ path: p, content: fs.readFileSync(p, 'utf8') });
484
634
  }
485
635
  let files = changedFiles(top);
486
- if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return; }
636
+ if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return null; }
487
637
  const report = { files: [], added: [], reused: [], languages: {} };
488
638
  while (files.length) {
489
639
  const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
@@ -521,6 +671,7 @@ async function localize(opts) {
521
671
  console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
522
672
  }
523
673
  if (wrapped === 0 && report.added.length === 0) console.log(' Nothing to change: every string a person reads already goes through an i18n call.');
674
+ return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length };
524
675
  }
525
676
 
526
677
  async function check(file) {
@@ -552,6 +703,8 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
552
703
  lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
553
704
  connect this repository: project, i18n setup, languages, agent hook
554
705
  (--languages are the target languages; the source stays en unless --primary)
706
+ lyngo onboard --run=<id> [--languages fr,de]
707
+ the guided setup an agent follows, from lyngo.app/start; run it again to continue
555
708
  lyngo localize [--languages fr,de] [--no-translate] [--dry]
556
709
  localize what changed in the working tree, and translate it
557
710
  (--no-translate: keys only; the project setting "Translate when localizing" is the default)
@@ -563,7 +716,7 @@ Config: LYNGO_HOST (default https://lyngo.app), LYNGO_TOKEN or ~/.config/lyngo/t
563
716
 
564
717
  function parse(argv) {
565
718
  const opts = { _: [] };
566
- const valued = new Set(['languages', 'workspace', 'primary']);
719
+ const valued = new Set(['languages', 'workspace', 'primary', 'run']);
567
720
  for (let i = 0; i < argv.length; i++) {
568
721
  const a = argv[i];
569
722
  if (a === '-y') { opts.yes = true; continue; }
@@ -595,6 +748,7 @@ function parse(argv) {
595
748
  case 'whoami': await whoami(); break;
596
749
  case 'init': await init(opts); break;
597
750
  case 'localize': await localize(opts); break;
751
+ case 'onboard': await onboard(opts); break;
598
752
  case 'translate': await translate(opts); break;
599
753
  case 'check': await check(opts._[0]); break;
600
754
  case 'status': await status(); break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@prettifystudio/lyngo-cli",
3
- "version": "0.1.2",
4
- "description": "Localization like code: Lyngo wraps user-facing text, reuses your project's keys and translates the catalogs, from the terminal.",
3
+ "version": "0.2.1",
4
+ "description": "Localization for Laravel applications, from the terminal: Lyngo wraps user-facing text with your framework's i18n call, reuses your project's keys, translates and verifies the catalogs. Blade, Livewire, PHP, and Inertia with React, Vue or Svelte.",
5
5
  "bin": {
6
6
  "lyngo": "bin/lyngo"
7
7
  },