@prettifystudio/lyngo-cli 0.2.3 → 0.2.5

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 +10 -6
  2. package/bin/lyngo +97 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,8 +26,10 @@ lyngo init # project, i18n setup, languages, agent hook
26
26
 
27
27
  1. Shows the workspace it uses. `--workspace <name>` picks another one.
28
28
  2. Finds the Lyngo project for the repository's `origin` remote, or creates it
29
- when you own the workspace. When GitHub is not connected yet, it prints the
30
- link to connect it and waits.
29
+ when you own the workspace. When the Lyngo GitHub App cannot read the
30
+ repository yet, it prints the GitHub link where you add it; in a terminal it
31
+ waits until you have, in an agent or a script it stops, and the next run
32
+ continues (`--wait` makes it wait anyway).
31
33
  3. Detects the framework and the i18n library.
32
34
  4. Sets the languages: `--languages fr,de`, or it asks.
33
35
  5. Applies the i18n setup as a patch to your working tree. It edits
@@ -35,15 +37,17 @@ lyngo init # project, i18n setup, languages, agent hook
35
37
  6. When Claude Code is installed: registers the Lyngo MCP server for this
36
38
  repository in Claude Code's local scope (stored in your home folder, never in
37
39
  the repository), and adds one Stop hook to `.claude/settings.local.json` that
38
- runs `lyngo localize` when your agent finishes a task.
40
+ runs `lyngo localize --hook` when your agent finishes a task.
39
41
 
40
42
  Run it again at any time: it prints the state and changes nothing that is
41
43
  already set up.
42
44
 
43
45
  ## Daily use
44
46
 
45
- - With an agent: build as usual. The hook runs `lyngo localize` at the end of
46
- every task.
47
+ - With an agent: build as usual. The hook runs `lyngo localize --hook` at the
48
+ end of every task. You see one line (what was localized and translated); the
49
+ agent gets the full report and tells you what changed. It stays silent when
50
+ nothing changed, and sends a file again only after it is edited.
47
51
  - Without an agent: run `lyngo localize` before you commit.
48
52
  - A repository that was never localized: `lyngo localize --all` once. It walks
49
53
  every source file a scan would read, in batches, and leaves the result in
@@ -59,7 +63,7 @@ already set up.
59
63
  | `lyngo logout` | Revokes the token and deletes it from this machine |
60
64
  | `lyngo whoami` | The account, the token prefix and your workspaces |
61
65
  | `lyngo init` | Connects the repository (see above). Flags: `--languages`, `--workspace`, `--yes`, `--github` |
62
- | `lyngo localize` | Localizes and translates what changed in the working tree. Flags: `--all` (the whole repository, in batches of 30), `--languages`, `--no-translate`, `--dry` |
66
+ | `lyngo localize` | Localizes and translates what changed in the working tree. Flags: `--all` (the whole repository, in batches of 30), `--languages`, `--no-translate`, `--dry`, `--hook` (the Claude Code Stop hook) |
63
67
  | `lyngo check <file>` | The user-facing strings of one file |
64
68
  | `lyngo status` | The project as Lyngo knows it |
65
69
 
package/bin/lyngo CHANGED
@@ -6,9 +6,9 @@
6
6
  // lyngo login sign in once; the token goes to ~/.config/lyngo/token
7
7
  // lyngo logout revoke the token and delete the file
8
8
  // lyngo whoami who the token belongs to
9
- // lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
9
+ // lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github] [--wait]
10
10
  // connect this repository: project, scaffolding, languages, agent hook
11
- // lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry]
11
+ // lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry] [--hook]
12
12
  // localize (+ translate) the working tree's changes
13
13
  // lyngo check <file> the user-facing strings of one file
14
14
  // lyngo status the project as Lyngo knows it
@@ -16,6 +16,7 @@
16
16
  // Config: LYNGO_HOST (default https://lyngo.app), LYNGO_URL (MCP endpoint,
17
17
  // default $LYNGO_HOST/mcp/admin), LYNGO_TOKEN (or ~/.config/lyngo/token).
18
18
  'use strict';
19
+ const crypto = require('crypto');
19
20
  const fs = require('fs');
20
21
  const os = require('os');
21
22
  const path = require('path');
@@ -29,9 +30,13 @@ const TOKEN_FILE = path.join(TOKEN_DIR, 'token');
29
30
  // Milliseconds per protocol second. Tests shrink it; people never set it.
30
31
  const TICK = Number(process.env.LYNGO_TICK_MS) > 0 ? Number(process.env.LYNGO_TICK_MS) : 1000;
31
32
  // 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'];
33
+ // found" that scrolls past unread (corp1 setup, 2026-10-18). No pipe: Claude
34
+ // Code's JSON must reach lyngo on stdin, and stdout must stay one JSON object.
35
+ const HOOK_COMMAND = `if command -v lyngo >/dev/null 2>&1; then lyngo localize --hook; else echo '{"systemMessage":"Lyngo: the CLI is not on PATH. Install it: npm i -g @prettifystudio/lyngo-cli"}'; fi`;
36
+ const OLD_HOOK_COMMANDS = [
37
+ 'lyngo localize 2>&1 | tail -20',
38
+ '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',
39
+ ];
35
40
  const SRC = /\.(blade\.php|php|tsx|jsx|ts|js|vue|svelte)$/;
36
41
  const SKIP = /(^|\/)(vendor|node_modules|tests?|database|storage|bootstrap|public|config|\.git)\//;
37
42
 
@@ -228,6 +233,14 @@ async function connectProject(opts, remote, branch) {
228
233
  console.log(messageOf(r.data, r.status));
229
234
  if (r.data.connect_url) console.log(' ' + r.data.connect_url);
230
235
  if (!r.data.owner) { process.exitCode = 1; return null; }
236
+ // No person at a terminal (an agent, a script): waiting would only get
237
+ // the command killed by the caller's timeout. Say what to do and stop;
238
+ // the next run picks up where this one ended (corp2, 2026-10-24).
239
+ if (!process.stdin.isTTY && !opts.github && !opts.wait) {
240
+ console.log('WAITING FOR THE PERSON: open the link above, add the repository to the Lyngo GitHub App, then run lyngo init again.');
241
+ process.exitCode = 3;
242
+ return null;
243
+ }
231
244
  if (opts.github || await confirm('Open it now?', opts)) openUrl(r.data.connect_url);
232
245
  console.log('Waiting for GitHub (up to 10 minutes)...');
233
246
  const q = new URLSearchParams({ remote, ...(opts.workspace ? { workspace: opts.workspace } : {}) });
@@ -638,9 +651,24 @@ async function allSourceFiles(top) {
638
651
  return tracked;
639
652
  }
640
653
 
654
+ function sha1(p) { try { return crypto.createHash('sha1').update(fs.readFileSync(p)).digest('hex'); } catch { return null; } }
655
+
641
656
  async function localize(opts) {
642
657
  const top = root();
643
658
  process.chdir(top);
659
+ // In hook mode the report goes to the agent, not the terminal (localizeHook).
660
+ const lines = [];
661
+ const say = (line) => (opts.hook ? lines.push(line) : console.log(line));
662
+ // --all: the whole repository, not the changed files. The server picks the
663
+ // paths a scan would process out of the tracked tree (its own rules, so
664
+ // the two never drift); the batches of 30 below do the rest (2026-10-18).
665
+ let files = opts.all ? await allSourceFiles(top) : changedFiles(top);
666
+ // The hook runs at every stop: it sends only the files whose content changed
667
+ // since its last successful run, so a stop without new edits costs nothing.
668
+ const changed = files;
669
+ const statePath = opts.hook ? path.resolve(top, git(['rev-parse', '--git-path', 'lyngo-hook.json']).trim()) : null;
670
+ if (statePath) { const seen = parseCatalog(statePath) || {}; files = files.filter((p) => seen[p] !== sha1(p)); }
671
+ if (files.length === 0) { say(opts.all ? 'lyngo: no source file of this project found in the repository.' : 'lyngo: nothing changed in the working tree.'); return null; }
644
672
  const status = await call('status', {});
645
673
  const langs = opts.languages || [status.primary_language, ...(status.languages || [])];
646
674
  // Both catalog folders (Laravel's lang and the frontend's own), read again
@@ -655,15 +683,11 @@ async function localize(opts) {
655
683
  }
656
684
  return out;
657
685
  };
658
- // --all: the whole repository, not the changed files. The server picks the
659
- // paths a scan would process out of the tracked tree (its own rules, so
660
- // the two never drift); the batches of 30 below do the rest (2026-10-18).
661
- let files = opts.all ? await allSourceFiles(top) : changedFiles(top);
662
- if (files.length === 0) { console.log(opts.all ? 'lyngo: no source file of this project found in the repository.' : 'lyngo: nothing changed in the working tree.'); return null; }
663
686
  const total = files.length;
664
- if (opts.all) console.log(`lyngo: localizing ${total} file${total === 1 ? '' : 's'} in batches of 30 (${Math.ceil(total / 30)} call${total > 30 ? 's' : ''}); each batch applies before the next.`);
665
- const report = { files: [], added: [], reused: [], languages: {} };
687
+ if (opts.all) say(`lyngo: localizing ${total} file${total === 1 ? '' : 's'} in batches of 30 (${Math.ceil(total / 30)} call${total > 30 ? 's' : ''}); each batch applies before the next.`);
688
+ const report = { files: [], added: [], reused: [], languages: {}, missing: new Set() };
666
689
  let batch = 0;
690
+ let patched = false;
667
691
  while (files.length) {
668
692
  batch++;
669
693
  const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
@@ -680,9 +704,11 @@ async function localize(opts) {
680
704
  try { git(['apply', '--whitespace=nowarn', tmp]); }
681
705
  catch (e) { fail('the patch did not apply (' + (e.stderr || '').toString().trim() + '); it is at ' + tmp); }
682
706
  fs.unlinkSync(tmp);
707
+ patched = true;
683
708
  }
684
709
  }
685
710
  for (const f of r.report?.files || []) if (f.wrapped?.length) report.files.push(f);
711
+ for (const k of r.report?.dot_keys || []) report.missing.add(k);
686
712
  report.added.push(...(r.report?.primary?.added || []));
687
713
  report.reused.push(...(r.report?.primary?.already_there || []));
688
714
  for (const [code, l] of Object.entries(r.report?.languages || {})) {
@@ -694,19 +720,67 @@ async function localize(opts) {
694
720
  if (opts.all) {
695
721
  const done = total - files.length;
696
722
  const got = (r.report?.files || []).reduce((n, f) => n + (f.wrapped?.length || 0), 0);
697
- console.log(` batch ${batch}: ${done}/${total} files, ${got} string${got === 1 ? '' : 's'} localized`);
723
+ say(` batch ${batch}: ${done}/${total} files, ${got} string${got === 1 ? '' : 's'} localized`);
698
724
  }
699
725
  }
726
+ // Recorded after the patch, so the hook's own changes never count as new edits.
727
+ if (statePath) fs.writeFileSync(statePath, JSON.stringify(Object.fromEntries(changed.map((p) => [p, sha1(p)]))) + '\n');
700
728
  const wrapped = report.files.reduce((n, f) => n + f.wrapped.length, 0);
701
- console.log(`lyngo: ${wrapped} string${wrapped === 1 ? '' : 's'} localized in ${report.files.length} file${report.files.length === 1 ? '' : 's'}; ${report.added.length} new key${report.added.length === 1 ? '' : 's'}, ${report.reused.length} reused.`);
702
- for (const f of report.files) console.log(` ${f.path}: ${f.wrapped.slice(0, 6).map((s) => JSON.stringify(s)).join(', ')}${f.wrapped.length > 6 ? ', …' : ''}`);
703
- if (opts['no-translate'] || (status.translate_on_localize === false && !Object.keys(report.languages).length)) console.log(' catalogs: not translated (keys only).');
729
+ say(`lyngo: ${wrapped} string${wrapped === 1 ? '' : 's'} localized in ${report.files.length} file${report.files.length === 1 ? '' : 's'}; ${report.added.length} new key${report.added.length === 1 ? '' : 's'}, ${report.reused.length} reused.`);
730
+ for (const f of report.files) say(` ${f.path}: ${f.wrapped.slice(0, 6).map((s) => JSON.stringify(s)).join(', ')}${f.wrapped.length > 6 ? ', …' : ''}`);
731
+ if (opts['no-translate'] || (status.translate_on_localize === false && !Object.keys(report.languages).length)) say(' catalogs: not translated (keys only).');
704
732
  for (const [code, l] of Object.entries(report.languages)) {
705
733
  const flagged = l.flagged.length ? `; ${l.flagged.length} flagged for review: ${l.flagged.slice(0, 3).map((x) => JSON.stringify(x.key || x)).join(', ')}` : '';
706
- console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
734
+ say(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
735
+ }
736
+ if (report.missing.size) say(` path keys no catalog holds: ${[...report.missing].slice(0, 6).map((k) => JSON.stringify(k)).join(', ')}${report.missing.size > 6 ? ', …' : ''}`);
737
+ if (wrapped === 0 && report.added.length === 0) say(' Nothing to change: every string a person reads already goes through an i18n call.');
738
+ return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length, byLanguage: report.languages, sent: total, patched, lines };
739
+ }
740
+
741
+ // ── the Claude Code Stop hook ──────────────────────────────────────────────
742
+ // A Stop hook's plain stdout reaches only Claude Code's debug log. It reads one
743
+ // JSON object instead: systemMessage is a line for the person, and
744
+ // additionalContext wakes the agent with the report. Always exit 0: exit 2 or
745
+ // a "block" decision shows as an error, any other code as a hook error.
746
+ const HOOK_CLIP = 7000; // each field is capped at 10,000 characters, and JSON escaping adds some
747
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
748
+ function hookOut(systemMessage, context) {
749
+ const out = { systemMessage: clip(systemMessage, 500) };
750
+ if (context) out.hookSpecificOutput = { hookEventName: 'Stop', additionalContext: context };
751
+ process.stdout.write(JSON.stringify(out) + '\n');
752
+ }
753
+
754
+ async function localizeHook(opts) {
755
+ let input = {};
756
+ if (!process.stdin.isTTY) {
757
+ let raw = '';
758
+ try { for await (const chunk of process.stdin) raw += chunk; input = JSON.parse(raw); } catch { /* no input: run as a plain stop */ }
759
+ }
760
+ // Claude is already continuing because of a stop hook: a second report would loop.
761
+ if (input && input.stop_hook_active === true) return;
762
+ let r;
763
+ try {
764
+ r = await localize(opts);
765
+ } catch (e) {
766
+ const t = (() => { try { return readToken(false); } catch { return null; } })();
767
+ const msg = scrub((e && e.message) || String(e), t).replace(/\s+/g, ' ').trim().replace(/\.$/, '');
768
+ const fix = /\blogin\b/i.test(msg) ? 'run /lyngo:login (or lyngo login in a terminal)'
769
+ : /lyngo init/i.test(msg) ? 'run /lyngo:init (or lyngo init in a terminal)'
770
+ : 'run /lyngo:localize to try again';
771
+ hookOut('Lyngo: ' + msg, `Lyngo could not localize the changes: ${clip(msg, HOOK_CLIP)}. Tell the user, with the fix: ${fix}.`);
772
+ return;
707
773
  }
708
- if (wrapped === 0 && report.added.length === 0) console.log(' Nothing to change: every string a person reads already goes through an i18n call.');
709
- return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length };
774
+ if (!r) return;
775
+ const n = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
776
+ if (!r.patched) { hookOut(`Lyngo: checked ${n(r.sent, 'file')}, nothing to localize`); return; }
777
+ const langs = Object.entries(r.byLanguage)
778
+ .filter(([, l]) => l.translated || l.from_memory || l.flagged.length)
779
+ .map(([code, l]) => `${code}: ${l.translated} translated${l.from_memory ? `, ${l.from_memory} from memory` : ''}${l.flagged.length ? `, ${l.flagged.length} flagged for review` : ''}`);
780
+ hookOut(
781
+ `Lyngo: ${n(r.wrapped, 'string')} localized in ${n(r.files, 'file')}, ${n(r.added, 'new key')}${r.reused ? `, ${r.reused} reused` : ''}${langs.length ? '; ' + langs.join('; ') : ''}`,
782
+ clip(r.lines.join('\n'), HOOK_CLIP) + '\n\nLyngo has already updated these files and the catalogs on disk. Tell the user in one or two sentences what Lyngo changed. Do not edit those files or the catalogs again. If the user checks the page, rebuild the frontend first.',
783
+ );
710
784
  }
711
785
 
712
786
  async function check(file) {
@@ -735,15 +809,16 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
735
809
  lyngo login sign in once (opens the browser)
736
810
  lyngo logout revoke the token and delete it from this machine
737
811
  lyngo whoami who the token belongs to
738
- lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
812
+ lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github] [--wait]
739
813
  connect this repository: project, i18n setup, languages, agent hook
740
814
  (--languages are the target languages; the source stays en unless --primary)
741
815
  lyngo onboard --run=<id> [--languages fr,de]
742
816
  the guided setup an agent follows, from lyngo.app/start; run it again to continue
743
- lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry]
817
+ lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry] [--hook]
744
818
  localize what changed in the working tree, and translate it
745
819
  (--no-translate: keys only; the project setting "Translate when localizing" is the default)
746
820
  (--all: the whole repository instead of the changed files, in batches of 30)
821
+ (--hook: the Claude Code Stop hook; one JSON object, only files changed since its last run)
747
822
  lyngo translate <code> [<code>…] fill every key a language's catalog lacks, from the primary catalog
748
823
  lyngo check <file> the user-facing strings of one file
749
824
  lyngo status the project as Lyngo knows it
@@ -783,7 +858,7 @@ function parse(argv) {
783
858
  case 'logout': await logout(); break;
784
859
  case 'whoami': await whoami(); break;
785
860
  case 'init': await init(opts); break;
786
- case 'localize': await localize(opts); break;
861
+ case 'localize': await (opts.hook ? localizeHook(opts) : localize(opts)); break;
787
862
  case 'onboard': await onboard(opts); break;
788
863
  case 'translate': await translate(opts); break;
789
864
  case 'check': await check(opts._[0]); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prettifystudio/lyngo-cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
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"