@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.
- package/README.md +10 -6
- package/bin/lyngo +97 -22
- 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
|
|
30
|
-
link
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
for (const f of report.files)
|
|
703
|
-
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).');
|
|
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
|
-
|
|
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 (
|
|
709
|
-
|
|
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
|
+
"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"
|