@prettifystudio/lyngo-cli 0.2.4 → 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.
- package/README.md +6 -4
- package/bin/lyngo +87 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,15 +37,17 @@ lyngo init # project, i18n setup, languages, agent hook
|
|
|
37
37
|
6. When Claude Code is installed: registers the Lyngo MCP server for this
|
|
38
38
|
repository in Claude Code's local scope (stored in your home folder, never in
|
|
39
39
|
the repository), and adds one Stop hook to `.claude/settings.local.json` that
|
|
40
|
-
runs `lyngo localize` when your agent finishes a task.
|
|
40
|
+
runs `lyngo localize --hook` when your agent finishes a task.
|
|
41
41
|
|
|
42
42
|
Run it again at any time: it prints the state and changes nothing that is
|
|
43
43
|
already set up.
|
|
44
44
|
|
|
45
45
|
## Daily use
|
|
46
46
|
|
|
47
|
-
- With an agent: build as usual. The hook runs `lyngo localize` at the
|
|
48
|
-
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.
|
|
49
51
|
- Without an agent: run `lyngo localize` before you commit.
|
|
50
52
|
- A repository that was never localized: `lyngo localize --all` once. It walks
|
|
51
53
|
every source file a scan would read, in batches, and leaves the result in
|
|
@@ -61,7 +63,7 @@ already set up.
|
|
|
61
63
|
| `lyngo logout` | Revokes the token and deletes it from this machine |
|
|
62
64
|
| `lyngo whoami` | The account, the token prefix and your workspaces |
|
|
63
65
|
| `lyngo init` | Connects the repository (see above). Flags: `--languages`, `--workspace`, `--yes`, `--github` |
|
|
64
|
-
| `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) |
|
|
65
67
|
| `lyngo check <file>` | The user-facing strings of one file |
|
|
66
68
|
| `lyngo status` | The project as Lyngo knows it |
|
|
67
69
|
|
package/bin/lyngo
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// lyngo whoami who the token belongs to
|
|
9
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
|
-
|
|
34
|
-
const
|
|
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
|
|
|
@@ -646,9 +651,24 @@ async function allSourceFiles(top) {
|
|
|
646
651
|
return tracked;
|
|
647
652
|
}
|
|
648
653
|
|
|
654
|
+
function sha1(p) { try { return crypto.createHash('sha1').update(fs.readFileSync(p)).digest('hex'); } catch { return null; } }
|
|
655
|
+
|
|
649
656
|
async function localize(opts) {
|
|
650
657
|
const top = root();
|
|
651
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; }
|
|
652
672
|
const status = await call('status', {});
|
|
653
673
|
const langs = opts.languages || [status.primary_language, ...(status.languages || [])];
|
|
654
674
|
// Both catalog folders (Laravel's lang and the frontend's own), read again
|
|
@@ -663,15 +683,11 @@ async function localize(opts) {
|
|
|
663
683
|
}
|
|
664
684
|
return out;
|
|
665
685
|
};
|
|
666
|
-
// --all: the whole repository, not the changed files. The server picks the
|
|
667
|
-
// paths a scan would process out of the tracked tree (its own rules, so
|
|
668
|
-
// the two never drift); the batches of 30 below do the rest (2026-10-18).
|
|
669
|
-
let files = opts.all ? await allSourceFiles(top) : changedFiles(top);
|
|
670
|
-
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; }
|
|
671
686
|
const total = files.length;
|
|
672
|
-
if (opts.all)
|
|
673
|
-
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() };
|
|
674
689
|
let batch = 0;
|
|
690
|
+
let patched = false;
|
|
675
691
|
while (files.length) {
|
|
676
692
|
batch++;
|
|
677
693
|
const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
|
|
@@ -688,9 +704,11 @@ async function localize(opts) {
|
|
|
688
704
|
try { git(['apply', '--whitespace=nowarn', tmp]); }
|
|
689
705
|
catch (e) { fail('the patch did not apply (' + (e.stderr || '').toString().trim() + '); it is at ' + tmp); }
|
|
690
706
|
fs.unlinkSync(tmp);
|
|
707
|
+
patched = true;
|
|
691
708
|
}
|
|
692
709
|
}
|
|
693
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);
|
|
694
712
|
report.added.push(...(r.report?.primary?.added || []));
|
|
695
713
|
report.reused.push(...(r.report?.primary?.already_there || []));
|
|
696
714
|
for (const [code, l] of Object.entries(r.report?.languages || {})) {
|
|
@@ -702,19 +720,67 @@ async function localize(opts) {
|
|
|
702
720
|
if (opts.all) {
|
|
703
721
|
const done = total - files.length;
|
|
704
722
|
const got = (r.report?.files || []).reduce((n, f) => n + (f.wrapped?.length || 0), 0);
|
|
705
|
-
|
|
723
|
+
say(` batch ${batch}: ${done}/${total} files, ${got} string${got === 1 ? '' : 's'} localized`);
|
|
706
724
|
}
|
|
707
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');
|
|
708
728
|
const wrapped = report.files.reduce((n, f) => n + f.wrapped.length, 0);
|
|
709
|
-
|
|
710
|
-
for (const f of report.files)
|
|
711
|
-
if (opts['no-translate'] || (status.translate_on_localize === false && !Object.keys(report.languages).length))
|
|
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).');
|
|
712
732
|
for (const [code, l] of Object.entries(report.languages)) {
|
|
713
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(', ')}` : '';
|
|
714
|
-
|
|
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;
|
|
715
773
|
}
|
|
716
|
-
if (
|
|
717
|
-
|
|
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
|
+
);
|
|
718
784
|
}
|
|
719
785
|
|
|
720
786
|
async function check(file) {
|
|
@@ -748,10 +814,11 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
|
|
|
748
814
|
(--languages are the target languages; the source stays en unless --primary)
|
|
749
815
|
lyngo onboard --run=<id> [--languages fr,de]
|
|
750
816
|
the guided setup an agent follows, from lyngo.app/start; run it again to continue
|
|
751
|
-
lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry]
|
|
817
|
+
lyngo localize [--all] [--languages fr,de] [--no-translate] [--dry] [--hook]
|
|
752
818
|
localize what changed in the working tree, and translate it
|
|
753
819
|
(--no-translate: keys only; the project setting "Translate when localizing" is the default)
|
|
754
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)
|
|
755
822
|
lyngo translate <code> [<code>…] fill every key a language's catalog lacks, from the primary catalog
|
|
756
823
|
lyngo check <file> the user-facing strings of one file
|
|
757
824
|
lyngo status the project as Lyngo knows it
|
|
@@ -791,7 +858,7 @@ function parse(argv) {
|
|
|
791
858
|
case 'logout': await logout(); break;
|
|
792
859
|
case 'whoami': await whoami(); break;
|
|
793
860
|
case 'init': await init(opts); break;
|
|
794
|
-
case 'localize': await localize(opts); break;
|
|
861
|
+
case 'localize': await (opts.hook ? localizeHook(opts) : localize(opts)); break;
|
|
795
862
|
case 'onboard': await onboard(opts); break;
|
|
796
863
|
case 'translate': await translate(opts); break;
|
|
797
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
|
+
"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"
|