@prettifystudio/lyngo-cli 0.0.0-stage → 0.1.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.
- package/README.md +78 -2
- package/bin/lyngo +532 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lyngo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Localization like code, from your terminal. Your agent builds the product;
|
|
4
|
+
Lyngo keeps it localized.
|
|
5
|
+
|
|
6
|
+
When you or your agent add text a person reads, `lyngo localize` wraps it with
|
|
7
|
+
your project's i18n calls, reuses the keys you already have, and translates the
|
|
8
|
+
catalogs with your glossary, translation memory and plural rules, before you
|
|
9
|
+
commit. The files come back to your working tree as one patch. A model never
|
|
10
|
+
holds them.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
You need a Lyngo account (lyngo.app) and Node 20 or newer.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g @prettifystudio/lyngo-cli
|
|
18
|
+
lyngo login # opens the browser once; the token goes to ~/.config/lyngo/token
|
|
19
|
+
cd your-repository
|
|
20
|
+
lyngo init # project, i18n setup, languages, agent hook
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`lyngo init` does this, in order:
|
|
24
|
+
|
|
25
|
+
1. Shows the workspace it uses. `--workspace <name>` picks another one.
|
|
26
|
+
2. Finds the Lyngo project for the repository's `origin` remote, or creates it
|
|
27
|
+
when you own the workspace. When GitHub is not connected yet, it prints the
|
|
28
|
+
link to connect it and waits.
|
|
29
|
+
3. Detects the framework and the i18n library.
|
|
30
|
+
4. Sets the languages: `--languages fr,de`, or it asks.
|
|
31
|
+
5. Applies the i18n setup as a patch to your working tree. It edits
|
|
32
|
+
`package.json` but never runs `npm install`; it prints the command.
|
|
33
|
+
6. When Claude Code is installed: registers the Lyngo MCP server for this
|
|
34
|
+
repository in Claude Code's local scope (stored in your home folder, never in
|
|
35
|
+
the repository), and adds one Stop hook to `.claude/settings.local.json` that
|
|
36
|
+
runs `lyngo localize` when your agent finishes a task.
|
|
37
|
+
|
|
38
|
+
Run it again at any time: it prints the state and changes nothing that is
|
|
39
|
+
already set up.
|
|
40
|
+
|
|
41
|
+
## Daily use
|
|
42
|
+
|
|
43
|
+
- With an agent: build as usual. The hook runs `lyngo localize` at the end of
|
|
44
|
+
every task.
|
|
45
|
+
- Without an agent: run `lyngo localize` before you commit.
|
|
46
|
+
- Anything pushed without it still gets a pull request from the Lyngo GitHub
|
|
47
|
+
App.
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
| Command | What it does |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `lyngo login` | Signs in through the browser and saves the token (mode 600) |
|
|
54
|
+
| `lyngo logout` | Revokes the token and deletes it from this machine |
|
|
55
|
+
| `lyngo whoami` | The account, the token prefix and your workspaces |
|
|
56
|
+
| `lyngo init` | Connects the repository (see above). Flags: `--languages`, `--workspace`, `--yes`, `--github` |
|
|
57
|
+
| `lyngo localize` | Localizes and translates what changed in the working tree. Flags: `--languages`, `--dry` |
|
|
58
|
+
| `lyngo check <file>` | The user-facing strings of one file |
|
|
59
|
+
| `lyngo status` | The project as Lyngo knows it |
|
|
60
|
+
|
|
61
|
+
Configuration: `LYNGO_HOST` (default `https://lyngo.app`), `LYNGO_TOKEN`
|
|
62
|
+
(otherwise `~/.config/lyngo/token`). Revoke tokens in Lyngo under Settings, CLI.
|
|
63
|
+
|
|
64
|
+
## The Claude Code plugin (optional)
|
|
65
|
+
|
|
66
|
+
This repository is also a Claude Code plugin. It adds the `/lyngo:localize`
|
|
67
|
+
command and the `lyngo-i18n` skill, which makes the agent check existing keys
|
|
68
|
+
before it writes new text. It needs the `lyngo` command from npm.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
claude plugin marketplace add <owner>/lyngo-claude-plugin
|
|
72
|
+
claude plugin install lyngo@lyngo
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Other MCP clients
|
|
76
|
+
|
|
77
|
+
Server `https://lyngo.app/mcp`, headers `Authorization: Bearer <token>` and
|
|
78
|
+
`X-Lyngo-Remote: <the repository's git remote URL>`. Tools: `status`, `lookup`,
|
|
79
|
+
`check`.
|
package/bin/lyngo
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lyngo: the Lyngo CLI for coding agents and people. Zero dependencies, Node 20+.
|
|
3
|
+
// Reads the changed files and the catalogs from disk, asks the Lyngo server,
|
|
4
|
+
// applies the patch, prints a short report. A model never holds the files.
|
|
5
|
+
//
|
|
6
|
+
// lyngo login sign in once; the token goes to ~/.config/lyngo/token
|
|
7
|
+
// lyngo logout revoke the token and delete the file
|
|
8
|
+
// lyngo whoami who the token belongs to
|
|
9
|
+
// lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
|
|
10
|
+
// connect this repository: project, scaffolding, languages, agent hook
|
|
11
|
+
// lyngo localize [--languages fr,de] [--dry] localize + translate the working tree's changes
|
|
12
|
+
// lyngo check <file> the user-facing strings of one file
|
|
13
|
+
// lyngo status the project as Lyngo knows it
|
|
14
|
+
//
|
|
15
|
+
// Config: LYNGO_HOST (default https://lyngo.app), LYNGO_URL (MCP endpoint,
|
|
16
|
+
// default $LYNGO_HOST/mcp/admin), LYNGO_TOKEN (or ~/.config/lyngo/token).
|
|
17
|
+
'use strict';
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { execFileSync, spawn } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const HOST = (process.env.LYNGO_HOST || 'https://lyngo.app').replace(/\/+$/, '');
|
|
24
|
+
const MCP_URL = process.env.LYNGO_URL || HOST + '/mcp/admin';
|
|
25
|
+
const AGENT_MCP_URL = HOST + '/mcp';
|
|
26
|
+
const TOKEN_DIR = path.join(os.homedir(), '.config', 'lyngo');
|
|
27
|
+
const TOKEN_FILE = path.join(TOKEN_DIR, 'token');
|
|
28
|
+
// Milliseconds per protocol second. Tests shrink it; people never set it.
|
|
29
|
+
const TICK = Number(process.env.LYNGO_TICK_MS) > 0 ? Number(process.env.LYNGO_TICK_MS) : 1000;
|
|
30
|
+
const HOOK_COMMAND = 'lyngo localize 2>&1 | tail -20';
|
|
31
|
+
const SRC = /\.(blade\.php|php|tsx|jsx|ts|js|vue|svelte)$/;
|
|
32
|
+
const SKIP = /(^|\/)(vendor|node_modules|tests?|database|storage|bootstrap|public|config|\.git)\//;
|
|
33
|
+
|
|
34
|
+
class CliError extends Error {}
|
|
35
|
+
|
|
36
|
+
function fail(msg) { throw new CliError(msg); }
|
|
37
|
+
function die(msg, code = 1) { console.error('lyngo: ' + msg); process.exit(code); }
|
|
38
|
+
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * TICK));
|
|
39
|
+
|
|
40
|
+
// ── git ────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function git(args) { return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
43
|
+
function gitTry(args) { try { return git(args); } catch { return null; } }
|
|
44
|
+
function root() {
|
|
45
|
+
const top = gitTry(['rev-parse', '--show-toplevel']);
|
|
46
|
+
if (top === null) fail('not a git repository. Run lyngo inside your project.');
|
|
47
|
+
return top.trim();
|
|
48
|
+
}
|
|
49
|
+
let remoteCache;
|
|
50
|
+
function repoRemote() {
|
|
51
|
+
if (remoteCache === undefined) {
|
|
52
|
+
const inside = gitTry(['rev-parse', '--is-inside-work-tree']);
|
|
53
|
+
remoteCache = inside && inside.trim() === 'true' ? (gitTry(['remote', 'get-url', 'origin']) || '').trim() || null : null;
|
|
54
|
+
}
|
|
55
|
+
return remoteCache;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── token ──────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function readToken(required = true) {
|
|
61
|
+
if (process.env.LYNGO_TOKEN && process.env.LYNGO_TOKEN.trim()) return process.env.LYNGO_TOKEN.trim();
|
|
62
|
+
if (fs.existsSync(TOKEN_FILE)) {
|
|
63
|
+
const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
64
|
+
if (t) return t;
|
|
65
|
+
}
|
|
66
|
+
if (required) fail('not logged in. Run lyngo login first.');
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const prefixOf = (t) => (t || '').slice(0, 8) + '…';
|
|
70
|
+
function saveToken(t) {
|
|
71
|
+
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
72
|
+
fs.chmodSync(TOKEN_DIR, 0o700);
|
|
73
|
+
fs.writeFileSync(TOKEN_FILE, t + '\n', { mode: 0o600 });
|
|
74
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
75
|
+
}
|
|
76
|
+
// Never let a token reach the terminal: replace it with its prefix.
|
|
77
|
+
function scrub(text, t) { return t ? String(text).split(t).join(prefixOf(t)) : String(text); }
|
|
78
|
+
|
|
79
|
+
// ── HTTP ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async function request(method, url, { body, auth = true, accept = 'application/json' } = {}) {
|
|
82
|
+
const headers = { Accept: accept };
|
|
83
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
84
|
+
if (auth) headers.Authorization = 'Bearer ' + readToken();
|
|
85
|
+
const remote = repoRemote();
|
|
86
|
+
if (remote) headers['X-Lyngo-Remote'] = remote;
|
|
87
|
+
let res;
|
|
88
|
+
try {
|
|
89
|
+
res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
fail(`could not reach ${HOST} (${e.cause?.code || e.message}).`);
|
|
92
|
+
}
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let data = {};
|
|
95
|
+
if (text) {
|
|
96
|
+
try { data = JSON.parse(text); } catch {
|
|
97
|
+
// A streamed MCP answer: the last data line holds the JSON.
|
|
98
|
+
const line = text.split('\n').filter((l) => l.startsWith('data:')).pop();
|
|
99
|
+
try { data = line ? JSON.parse(line.slice(5)) : { message: text.slice(0, 200) }; } catch { data = { message: text.slice(0, 200) }; }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (auth && res.status === 401) fail('the token was refused (revoked or expired). Run lyngo login.');
|
|
103
|
+
if (res.status === 429) fail('too many calls. Wait a minute and try again.');
|
|
104
|
+
return { status: res.status, data };
|
|
105
|
+
}
|
|
106
|
+
const api = (method, p, opts) => request(method, HOST + p, opts);
|
|
107
|
+
const messageOf = (data, status) => data.message || (typeof data.error === 'string' ? data.error : null) || `the server answered ${status}.`;
|
|
108
|
+
function expect(r, ...ok) { if (!ok.includes(r.status)) fail(messageOf(r.data, r.status)); return r.data; }
|
|
109
|
+
|
|
110
|
+
async function call(name, args) {
|
|
111
|
+
const r = await request('POST', MCP_URL, {
|
|
112
|
+
accept: 'application/json, text/event-stream',
|
|
113
|
+
body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } },
|
|
114
|
+
});
|
|
115
|
+
if (r.status !== 200) fail(messageOf(r.data, r.status));
|
|
116
|
+
const body = r.data;
|
|
117
|
+
if (body.error) fail(body.error.message || JSON.stringify(body.error));
|
|
118
|
+
const text = body.result?.content?.[0]?.text ?? '';
|
|
119
|
+
if (body.result?.isError) fail(text);
|
|
120
|
+
try { return JSON.parse(text); } catch { fail('unexpected answer: ' + text.slice(0, 200)); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── terminal helpers ───────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function openUrl(url) {
|
|
126
|
+
if (process.env.LYNGO_NO_BROWSER) return;
|
|
127
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
128
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
129
|
+
: ['xdg-open', [url]];
|
|
130
|
+
try {
|
|
131
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
132
|
+
child.on('error', () => {});
|
|
133
|
+
child.unref();
|
|
134
|
+
} catch { /* the URL is printed; opening it is a convenience */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function ask(question) {
|
|
138
|
+
if (!process.stdin.isTTY) return null;
|
|
139
|
+
const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
|
|
140
|
+
return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
|
|
141
|
+
}
|
|
142
|
+
async function confirm(question, opts) {
|
|
143
|
+
if (opts.yes) return true;
|
|
144
|
+
const answer = await ask(question + ' [Y/n] ');
|
|
145
|
+
return answer === null ? false : answer === '' || /^y(es)?$/i.test(answer);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findOnPath(name) {
|
|
149
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
|
|
150
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
151
|
+
for (const ext of exts) {
|
|
152
|
+
const p = path.join(dir, name + ext);
|
|
153
|
+
try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return p; } catch { /* next */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── login, logout, whoami ──────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
async function login() {
|
|
162
|
+
const d = expect(await api('POST', '/cli/device', { auth: false, body: { hostname: os.hostname() } }), 200);
|
|
163
|
+
if (!d || !d.user_code || !d.device_code) fail('the server did not answer with a login code. Check LYNGO_HOST.');
|
|
164
|
+
console.log(`Open ${d.verification_url} and enter ${d.user_code}`);
|
|
165
|
+
openUrl(d.verification_url_complete || d.verification_url);
|
|
166
|
+
let interval = Number(d.interval) > 0 ? Number(d.interval) : 5;
|
|
167
|
+
const deadline = Date.now() + (Number(d.expires_in) > 0 ? Number(d.expires_in) : 900) * TICK;
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
await sleep(interval);
|
|
170
|
+
const r = await api('POST', '/cli/token', { auth: false, body: { device_code: d.device_code } });
|
|
171
|
+
if (r.status === 200 && r.data.token) {
|
|
172
|
+
saveToken(r.data.token);
|
|
173
|
+
console.log(`Logged in as ${r.data.user?.email || r.data.user?.name || 'you'}. Token ${r.data.prefix || prefixOf(r.data.token)} saved to ${TOKEN_FILE}.`);
|
|
174
|
+
if (process.env.LYNGO_TOKEN) console.log('Note: LYNGO_TOKEN is set in your environment and takes precedence over the saved token.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const error = r.data.error;
|
|
178
|
+
if (error === 'authorization_pending') continue;
|
|
179
|
+
if (error === 'slow_down') { interval += 5; continue; }
|
|
180
|
+
if (error === 'access_denied') fail('the login was cancelled in the browser.');
|
|
181
|
+
if (error === 'expired_token') fail('the code expired. Run lyngo login again.');
|
|
182
|
+
fail(messageOf(r.data, r.status));
|
|
183
|
+
}
|
|
184
|
+
fail('the code expired. Run lyngo login again.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function logout() {
|
|
188
|
+
const t = readToken(false);
|
|
189
|
+
if (!t) { console.log('Not logged in.'); return; }
|
|
190
|
+
let revoked = false;
|
|
191
|
+
try {
|
|
192
|
+
const r = await api('DELETE', '/cli/token');
|
|
193
|
+
revoked = r.status === 204 || r.status === 200;
|
|
194
|
+
} catch { /* the local file goes anyway */ }
|
|
195
|
+
if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
|
|
196
|
+
console.log(revoked ? `Logged out. Token ${prefixOf(t)} is revoked.` : `Logged out on this machine. The server did not confirm that token ${prefixOf(t)} is revoked; revoke it in Settings, CLI.`);
|
|
197
|
+
if (process.env.LYNGO_TOKEN) console.log('Note: LYNGO_TOKEN is still set in your environment.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function whoami() {
|
|
201
|
+
const me = expect(await api('GET', '/cli/me'), 200);
|
|
202
|
+
console.log(`${me.user?.name || ''} <${me.user?.email || ''}>`.trim());
|
|
203
|
+
console.log(`token: ${me.token?.prefix || prefixOf(readToken())} (${me.token?.kind || 'unknown'})`);
|
|
204
|
+
if (me.workspaces?.length) {
|
|
205
|
+
console.log('workspaces:');
|
|
206
|
+
for (const w of me.workspaces) console.log(` ${w.current ? '*' : ' '} ${w.name} (${w.role})`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── init ───────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function printProblem(r) {
|
|
213
|
+
console.error('lyngo: ' + messageOf(r.data, r.status));
|
|
214
|
+
if (r.data.billing_url) console.error(' ' + r.data.billing_url);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function connectProject(opts, remote, branch) {
|
|
218
|
+
const body = { remote, branch, workspace: opts.workspace };
|
|
219
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
220
|
+
const r = await api('POST', '/cli/projects', { body });
|
|
221
|
+
const s = r.data.status;
|
|
222
|
+
if ((r.status === 200 || r.status === 201) && r.data.project) return r.data;
|
|
223
|
+
if (s === 'needs_github_app' || r.status === 409) {
|
|
224
|
+
console.log(messageOf(r.data, r.status));
|
|
225
|
+
if (r.data.connect_url) console.log(' ' + r.data.connect_url);
|
|
226
|
+
if (!r.data.owner) { process.exitCode = 1; return null; }
|
|
227
|
+
if (opts.github || await confirm('Open it now?', opts)) openUrl(r.data.connect_url);
|
|
228
|
+
console.log('Waiting for GitHub (up to 10 minutes)...');
|
|
229
|
+
const q = new URLSearchParams({ remote, ...(opts.workspace ? { workspace: opts.workspace } : {}) });
|
|
230
|
+
let connected = false;
|
|
231
|
+
for (let waited = 0; waited < 600 && !connected; waited += 5) {
|
|
232
|
+
await sleep(5);
|
|
233
|
+
const st = expect(await api('GET', '/cli/projects/status?' + q), 200);
|
|
234
|
+
connected = !!st.github_connected;
|
|
235
|
+
}
|
|
236
|
+
if (!connected) fail('GitHub is still not connected after 10 minutes. Run lyngo init again once it is.');
|
|
237
|
+
console.log('GitHub is connected.');
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
printProblem(r);
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
fail('GitHub reports the repository as connected, but Lyngo cannot see it yet. Run lyngo init again in a minute.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function detectionWarning(branch) {
|
|
248
|
+
const ahead = gitTry(['rev-list', '--count', '@{u}..HEAD']);
|
|
249
|
+
const notPushed = ahead === null || Number(ahead.trim()) > 0;
|
|
250
|
+
const pkgDirty = (gitTry(['status', '--porcelain', '--', 'package.json']) || '').trim() !== '';
|
|
251
|
+
if (notPushed || pkgDirty) console.log(`Detection reads ${branch} on GitHub. Push first for an exact result.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function chooseLanguages(project, opts) {
|
|
255
|
+
const current = project.languages || [];
|
|
256
|
+
let list = opts.languages;
|
|
257
|
+
if (!list) {
|
|
258
|
+
const answer = opts.yes ? null : await ask(`Languages [${current.join(',') || 'none'}]: `);
|
|
259
|
+
list = answer ? answer.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean) : current;
|
|
260
|
+
}
|
|
261
|
+
if (!list.length) fail('choose the languages with --languages fr,de.');
|
|
262
|
+
// --languages names the TARGET languages; the source language stays the
|
|
263
|
+
// project's primary, English when the project has none yet, or --primary.
|
|
264
|
+
const primary = opts.primary || project.primary_language || 'en';
|
|
265
|
+
if (!list.includes(primary)) list = [primary, ...list];
|
|
266
|
+
const same = list.length === current.length && list.every((l) => current.includes(l)) && primary === project.primary_language;
|
|
267
|
+
if (same) { console.log(`Languages: ${primary} -> ${list.filter((l) => l !== primary).join(', ') || 'none yet'}`); return project; }
|
|
268
|
+
const r = await api('PATCH', `/cli/projects/${project.id}`, { body: { languages: list, primary_language: primary } });
|
|
269
|
+
if (r.status !== 200) { printProblem(r); fail('the languages were not saved.'); }
|
|
270
|
+
const updated = r.data.project || project;
|
|
271
|
+
console.log(`Languages: ${updated.primary_language || primary} -> ${(updated.languages || list).filter((l) => l !== (updated.primary_language || primary)).join(', ')}`);
|
|
272
|
+
return updated;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readIfFile(p) { try { return fs.statSync(p).isFile() ? fs.readFileSync(p, 'utf8') : null; } catch { return null; } }
|
|
276
|
+
|
|
277
|
+
async function scaffold(project) {
|
|
278
|
+
const tree = (gitTry(['ls-files', '-z', '--cached', '--others', '--exclude-standard']) || '').split('\0').filter(Boolean);
|
|
279
|
+
const sent = new Map();
|
|
280
|
+
for (const p of ['package.json', 'composer.json']) { const c = readIfFile(p); if (c !== null) sent.set(p, c); }
|
|
281
|
+
for (let round = 0; round <= 3; round++) {
|
|
282
|
+
const r = await api('POST', `/cli/projects/${project.id}/scaffold`, { body: { tree, files: [...sent].map(([p, content]) => ({ path: p, content })) } });
|
|
283
|
+
if (r.status === 409 && Array.isArray(r.data.needs)) {
|
|
284
|
+
let added = 0;
|
|
285
|
+
for (const p of r.data.needs) {
|
|
286
|
+
if (sent.has(p)) continue;
|
|
287
|
+
const c = readIfFile(p);
|
|
288
|
+
if (c !== null) { sent.set(p, c); added++; }
|
|
289
|
+
}
|
|
290
|
+
if (added === 0) fail('the server asked for files this working tree does not have: ' + r.data.needs.slice(0, 5).join(', '));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const d = expect(r, 200);
|
|
294
|
+
if (!d.patch || !d.patch.trim()) { console.log('Scaffolding: nothing to add.'); return; }
|
|
295
|
+
const tmp = path.join(os.tmpdir(), `lyngo-scaffold-${Date.now()}.patch`);
|
|
296
|
+
fs.writeFileSync(tmp, d.patch);
|
|
297
|
+
try {
|
|
298
|
+
git(['apply', '--whitespace=nowarn', tmp]);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error(`lyngo: the scaffolding patch did not apply (${(e.stderr || '').toString().trim()}). It is at ${tmp}`);
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
fs.unlinkSync(tmp);
|
|
305
|
+
console.log('Scaffolding applied:');
|
|
306
|
+
for (const f of d.files || []) console.log(' ' + f);
|
|
307
|
+
for (const n of d.notes || []) console.log(' ' + n);
|
|
308
|
+
if (d.install_command) console.log(`Run ${d.install_command}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
fail('the server kept asking for more files. Run lyngo init again.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function setUpClaudeCode(top, remote) {
|
|
315
|
+
const claude = findOnPath('claude');
|
|
316
|
+
if (!claude) { console.log('Claude Code not found: no agent setup. Run lyngo localize before you commit.'); return; }
|
|
317
|
+
const t = readToken();
|
|
318
|
+
const run = (args) => execFileSync(claude, args, { cwd: top, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
319
|
+
try { run(['mcp', 'remove', 'lyngo', '-s', 'local']); } catch { /* not registered yet */ }
|
|
320
|
+
try {
|
|
321
|
+
run(['mcp', 'add', '--transport', 'http', '--scope', 'local', 'lyngo', AGENT_MCP_URL,
|
|
322
|
+
'-H', 'Authorization: Bearer ' + t, '-H', 'X-Lyngo-Remote: ' + remote]);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.error('lyngo: could not register the Lyngo MCP server in Claude Code (' + scrub(((e.stderr || e.message || '') + '').trim(), t) + ').');
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// One Stop hook, in the settings file git does not track.
|
|
329
|
+
const dir = path.join(top, '.claude');
|
|
330
|
+
const file = path.join(dir, 'settings.local.json');
|
|
331
|
+
let settings = {};
|
|
332
|
+
if (fs.existsSync(file)) {
|
|
333
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf8') || '{}'); } catch {
|
|
334
|
+
console.error('lyngo: .claude/settings.local.json is not valid JSON; the Stop hook was not added.');
|
|
335
|
+
process.exitCode = 1;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
settings.hooks = settings.hooks || {};
|
|
340
|
+
const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
|
|
341
|
+
const has = stop.some((group) => (group.hooks || []).some((h) => typeof h.command === 'string' && /\blyngo\b[^|]*\blocalize\b/.test(h.command)));
|
|
342
|
+
if (!has) {
|
|
343
|
+
stop.push({ hooks: [{ type: 'command', command: HOOK_COMMAND }] });
|
|
344
|
+
settings.hooks.Stop = stop;
|
|
345
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
346
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
347
|
+
}
|
|
348
|
+
let ignored = true;
|
|
349
|
+
try { execFileSync('git', ['check-ignore', '-q', '.claude/settings.local.json'], { cwd: top, stdio: 'ignore' }); } catch { ignored = false; }
|
|
350
|
+
if (!ignored) {
|
|
351
|
+
const rel = (gitTry(['rev-parse', '--git-path', 'info/exclude']) || '.git/info/exclude').trim();
|
|
352
|
+
const exclude = path.isAbsolute(rel) ? rel : path.join(top, rel);
|
|
353
|
+
const text = readIfFile(exclude) || '';
|
|
354
|
+
if (!text.split('\n').includes('.claude/settings.local.json')) {
|
|
355
|
+
fs.mkdirSync(path.dirname(exclude), { recursive: true });
|
|
356
|
+
fs.writeFileSync(exclude, text + (text && !text.endsWith('\n') ? '\n' : '') + '.claude/settings.local.json\n');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
console.log(`Claude Code: the lyngo MCP server is registered for this repository, and the Stop hook ${has ? 'was already' : 'is'} in .claude/settings.local.json.`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function init(opts) {
|
|
363
|
+
const top = root();
|
|
364
|
+
process.chdir(top);
|
|
365
|
+
readToken();
|
|
366
|
+
const remote = repoRemote();
|
|
367
|
+
if (!remote) fail('this repository has no origin remote. Add the GitHub remote first.');
|
|
368
|
+
const head = (gitTry(['rev-parse', '--abbrev-ref', 'HEAD']) || '').trim();
|
|
369
|
+
const branch = head && head !== 'HEAD' ? head : undefined;
|
|
370
|
+
|
|
371
|
+
const me = expect(await api('GET', '/cli/me'), 200);
|
|
372
|
+
const ws = (me.workspaces || []).find((w) => opts.workspace ? (w.id === opts.workspace || w.name === opts.workspace) : w.current);
|
|
373
|
+
if (ws) console.log(`Workspace: ${ws.name}`);
|
|
374
|
+
|
|
375
|
+
const found = await connectProject(opts, remote, branch);
|
|
376
|
+
if (!found) return;
|
|
377
|
+
let project = found.project;
|
|
378
|
+
const wsName = found.workspace?.name || ws?.name || '';
|
|
379
|
+
console.log(found.status === 'created' ? `Created ${project.name} in workspace ${wsName}.` : `Set up on ${project.name} (workspace ${wsName}).`);
|
|
380
|
+
|
|
381
|
+
detectionWarning(project.branch || branch || 'the default branch');
|
|
382
|
+
const detected = expect(await api('POST', `/cli/projects/${project.id}/detect`, { body: {} }), 200);
|
|
383
|
+
console.log(`Detected: ${detected.summary || 'no i18n setup found'}`);
|
|
384
|
+
if (detected.i18n) project = { ...project, i18n: detected.i18n };
|
|
385
|
+
|
|
386
|
+
project = await chooseLanguages(project, opts);
|
|
387
|
+
await scaffold(project);
|
|
388
|
+
setUpClaudeCode(top, remote);
|
|
389
|
+
console.log('Done. Build as usual; lyngo localize runs when your agent finishes, or run it yourself before committing.');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── localize, check, status ────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function changedFiles(top) {
|
|
395
|
+
const lines = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean);
|
|
396
|
+
const out = new Set();
|
|
397
|
+
for (const l of lines) {
|
|
398
|
+
const p = l.slice(3).trim().replace(/^"|"$/g, '');
|
|
399
|
+
const rel = p.includes(' -> ') ? p.split(' -> ')[1] : p;
|
|
400
|
+
if (SRC.test(rel) && !SKIP.test(rel) && fs.existsSync(path.join(top, rel))) out.add(rel);
|
|
401
|
+
}
|
|
402
|
+
return [...out].sort();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function localize(opts) {
|
|
406
|
+
const top = root();
|
|
407
|
+
process.chdir(top);
|
|
408
|
+
const status = await call('status', {});
|
|
409
|
+
const folder = (status.i18n?.catalog_folder || 'lang').replace(/\/+$/, '');
|
|
410
|
+
const langs = opts.languages || [status.primary_language, ...(status.languages || [])];
|
|
411
|
+
const catalogs = [];
|
|
412
|
+
for (const code of langs) {
|
|
413
|
+
const p = path.join(folder, code + '.json');
|
|
414
|
+
if (fs.existsSync(p)) catalogs.push({ path: p, content: fs.readFileSync(p, 'utf8') });
|
|
415
|
+
}
|
|
416
|
+
let files = changedFiles(top);
|
|
417
|
+
if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return; }
|
|
418
|
+
const report = { files: [], added: [], reused: [], languages: {} };
|
|
419
|
+
while (files.length) {
|
|
420
|
+
const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
|
|
421
|
+
const args = { files: payload, catalogs: catalogs.map((c) => ({ path: c.path, content: fs.existsSync(c.path) ? fs.readFileSync(c.path, 'utf8') : c.content })), format: 'patch' };
|
|
422
|
+
if (opts.languages) args.languages = opts.languages.filter((l) => l !== status.primary_language);
|
|
423
|
+
const r = await call('localize', args);
|
|
424
|
+
if (r.patch && r.patch.trim()) {
|
|
425
|
+
if (opts.dry) { process.stdout.write(r.patch); }
|
|
426
|
+
else {
|
|
427
|
+
const tmp = path.join(os.tmpdir(), 'lyngo-' + Date.now() + '.patch');
|
|
428
|
+
fs.writeFileSync(tmp, r.patch);
|
|
429
|
+
try { git(['apply', '--whitespace=nowarn', tmp]); }
|
|
430
|
+
catch (e) { fail('the patch did not apply (' + (e.stderr || '').toString().trim() + '); it is at ' + tmp); }
|
|
431
|
+
fs.unlinkSync(tmp);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const f of r.report?.files || []) if (f.wrapped?.length) report.files.push(f);
|
|
435
|
+
report.added.push(...(r.report?.primary?.added || []));
|
|
436
|
+
report.reused.push(...(r.report?.primary?.already_there || []));
|
|
437
|
+
for (const [code, l] of Object.entries(r.report?.languages || {})) {
|
|
438
|
+
const cur = report.languages[code] || { translated: 0, from_memory: 0, flagged: [] };
|
|
439
|
+
cur.translated += l.translated || 0; cur.from_memory += l.from_memory || 0; cur.flagged.push(...(l.flagged || []));
|
|
440
|
+
report.languages[code] = cur;
|
|
441
|
+
}
|
|
442
|
+
files = r.deferred || [];
|
|
443
|
+
}
|
|
444
|
+
const wrapped = report.files.reduce((n, f) => n + f.wrapped.length, 0);
|
|
445
|
+
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.`);
|
|
446
|
+
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 ? ', …' : ''}`);
|
|
447
|
+
for (const [code, l] of Object.entries(report.languages)) {
|
|
448
|
+
const flagged = l.flagged.length ? `; ${l.flagged.length} flagged for review: ${l.flagged.slice(0, 3).map((x) => JSON.stringify(x.key || x)).join(', ')}` : '';
|
|
449
|
+
console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
|
|
450
|
+
}
|
|
451
|
+
if (wrapped === 0 && report.added.length === 0) console.log(' Nothing to change: every string a person reads already goes through an i18n call.');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function check(file) {
|
|
455
|
+
if (!file) fail('usage: lyngo check <file>');
|
|
456
|
+
const r = await call('check', { path: file, content: fs.readFileSync(file, 'utf8') });
|
|
457
|
+
const strings = r.strings || r.user_facing || [];
|
|
458
|
+
console.log(`${file}: ${strings.length} user-facing string${strings.length === 1 ? '' : 's'}`);
|
|
459
|
+
for (const s of strings.slice(0, 30)) console.log(' ' + JSON.stringify(typeof s === 'string' ? s : s.value ?? s));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function status() {
|
|
463
|
+
const r = await call('status', {});
|
|
464
|
+
console.log(`${r.project}: ${r.i18n?.framework} / ${r.i18n?.library} / ${r.i18n?.function}(), catalogs in ${r.i18n?.catalog_folder}`);
|
|
465
|
+
console.log(`languages: ${r.primary_language} -> ${(r.languages || []).join(', ')}`);
|
|
466
|
+
if (r.refused_strings?.length) console.log(`refused (never localize): ${r.refused_strings.slice(0, 8).map((s) => JSON.stringify(s)).join(', ')}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── entry ──────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
function version() {
|
|
472
|
+
try { return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || '0.1.0'; } catch { return '0.1.0'; }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const HELP = `lyngo ${version()}: localization for your repository, from the terminal.
|
|
476
|
+
|
|
477
|
+
lyngo login sign in once (opens the browser)
|
|
478
|
+
lyngo logout revoke the token and delete it from this machine
|
|
479
|
+
lyngo whoami who the token belongs to
|
|
480
|
+
lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
|
|
481
|
+
connect this repository: project, i18n setup, languages, agent hook
|
|
482
|
+
(--languages are the target languages; the source stays en unless --primary)
|
|
483
|
+
lyngo localize [--languages fr,de] [--dry]
|
|
484
|
+
localize and translate what changed in the working tree
|
|
485
|
+
lyngo check <file> the user-facing strings of one file
|
|
486
|
+
lyngo status the project as Lyngo knows it
|
|
487
|
+
|
|
488
|
+
Config: LYNGO_HOST (default https://lyngo.app), LYNGO_TOKEN or ~/.config/lyngo/token.`;
|
|
489
|
+
|
|
490
|
+
function parse(argv) {
|
|
491
|
+
const opts = { _: [] };
|
|
492
|
+
const valued = new Set(['languages', 'workspace', 'primary']);
|
|
493
|
+
for (let i = 0; i < argv.length; i++) {
|
|
494
|
+
const a = argv[i];
|
|
495
|
+
if (a === '-y') { opts.yes = true; continue; }
|
|
496
|
+
if (a === '-h') { opts.help = true; continue; }
|
|
497
|
+
if (a === '-v') { opts.version = true; continue; }
|
|
498
|
+
if (!a.startsWith('--')) { opts._.push(a); continue; }
|
|
499
|
+
const [k, inline] = a.slice(2).split(/=(.*)/s);
|
|
500
|
+
if (valued.has(k)) {
|
|
501
|
+
const v = inline !== undefined ? inline : argv[++i];
|
|
502
|
+
if (v === undefined || v.startsWith('--')) fail(`--${k} needs a value.`);
|
|
503
|
+
opts[k] = k === 'languages' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v;
|
|
504
|
+
} else {
|
|
505
|
+
opts[k] = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return opts;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
(async () => {
|
|
512
|
+
try {
|
|
513
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
514
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { console.log(version()); return; }
|
|
515
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { console.log(HELP); return; }
|
|
516
|
+
const opts = parse(rest);
|
|
517
|
+
if (opts.help) { console.log(HELP); return; }
|
|
518
|
+
switch (cmd) {
|
|
519
|
+
case 'login': await login(); break;
|
|
520
|
+
case 'logout': await logout(); break;
|
|
521
|
+
case 'whoami': await whoami(); break;
|
|
522
|
+
case 'init': await init(opts); break;
|
|
523
|
+
case 'localize': await localize(opts); break;
|
|
524
|
+
case 'check': await check(opts._[0]); break;
|
|
525
|
+
case 'status': await status(); break;
|
|
526
|
+
default: console.error(`lyngo: unknown command "${cmd}".\n\n${HELP}`); process.exitCode = 1;
|
|
527
|
+
}
|
|
528
|
+
} catch (e) {
|
|
529
|
+
const t = (() => { try { return readToken(false); } catch { return null; } })();
|
|
530
|
+
die(scrub(e instanceof CliError ? e.message : (e && e.message) || String(e), t));
|
|
531
|
+
}
|
|
532
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prettifystudio/lyngo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Localization like code: Lyngo wraps user-facing text, reuses your project's keys and translates the catalogs, from the terminal.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"lyngo": "bin/lyngo"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"i18n",
|
|
20
|
+
"localization",
|
|
21
|
+
"translation",
|
|
22
|
+
"laravel",
|
|
23
|
+
"react",
|
|
24
|
+
"vue",
|
|
25
|
+
"svelte",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://lyngo.app",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/prettifystudio/lyngo-claude-plugin.git"
|
|
36
|
+
},
|
|
37
|
+
"author": "Prettify Studio"
|
|
38
|
+
}
|