localingos 0.1.32 → 0.1.33

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 CHANGED
@@ -202,4 +202,72 @@ git push && git push --tags
202
202
  |---------|------------|---------|
203
203
  | `npm version patch` | Bug fix, typo, minor tweak | 0.1.0 → 0.1.1 |
204
204
  | `npm version minor` | New feature, new format support | 0.1.0 → 0.2.0 |
205
- | `npm version major` | Breaking config/API change | 0.1.0 → 1.0.0 |
205
+ | `npm version major` | Breaking config/API change | 0.1.0 → 1.0.0 |
206
+
207
+ ## CI/CD Integration
208
+
209
+ ### GitHub Actions — Auto-sync on push
210
+
211
+ Automatically sync translations when `.messages.ts` files change on `main`:
212
+
213
+ 1. Add your API key as a repository secret: `LOCALINGOS_API_KEY`
214
+ (Settings → Secrets and variables → Actions → New repository secret)
215
+
216
+ 2. Make sure `localingos.config.json` is committed (project config, no secrets).
217
+ `.localingos.json` stays in `.gitignore` (contains API key for local dev only).
218
+
219
+ 3. Copy [`examples/github-actions-sync.yml`](examples/github-actions-sync.yml) to `.github/workflows/localingos-sync.yml`
220
+
221
+ This workflow:
222
+ - Triggers when `.messages.ts` or `src/i18n/` files change on `main`
223
+ - Runs `localingos sync --prune` to push source strings and pull translations
224
+ - Commits updated translation files back to the repo
225
+
226
+ ### GitHub Actions — PR check
227
+
228
+ Fail PRs if i18n source files are out of date or orphan messages exist:
229
+
230
+ Copy [`examples/github-actions-check.yml`](examples/github-actions-check.yml) to `.github/workflows/localingos-check.yml`
231
+
232
+ This runs `npm run i18n:check` which verifies:
233
+ - `en-US.json` and `en-US.descriptions.json` match the `.messages.ts` files
234
+ - No orphan messages (defined but never referenced in code)
235
+
236
+ ### Environment variable
237
+
238
+ The CLI supports `LOCALINGOS_API_KEY` as an environment variable, which takes precedence over the `apiKey` field in `.localingos.json`. This lets you keep the API key out of your repo:
239
+
240
+ When you run `localingos init`, it creates two files:
241
+
242
+ - **`localingos.config.json`** — commit this. Contains project settings (familyId, locale, paths). No secrets.
243
+ - **`.localingos.json`** — gitignored. Contains only your `apiKey` for local development.
244
+
245
+ ```bash
246
+ # Local dev — the CLI reads apiKey from .localingos.json automatically
247
+ localingos sync
248
+
249
+ # CI — set as a GitHub secret, the CLI reads it from the env var
250
+ LOCALINGOS_API_KEY=your-key localingos sync
251
+ ```
252
+
253
+ ### Multiple projects (monorepo)
254
+
255
+ If you have multiple projects in one repo, each with its own `localingos.config.json` and API key, add an `apiKeyEnv` field to each config pointing to a different env var:
256
+
257
+ ```json
258
+ // apps/web/localingos.config.json
259
+ { "apiKeyEnv": "LOCALINGOS_KEY_WEB", "familyId": "...", ... }
260
+
261
+ // apps/docs/localingos.config.json
262
+ { "apiKeyEnv": "LOCALINGOS_KEY_DOCS", "familyId": "...", ... }
263
+ ```
264
+
265
+ Then in your workflow, pass each secret:
266
+
267
+ ```yaml
268
+ env:
269
+ LOCALINGOS_KEY_WEB: ${{ secrets.LOCALINGOS_KEY_WEB }}
270
+ LOCALINGOS_KEY_DOCS: ${{ secrets.LOCALINGOS_KEY_DOCS }}
271
+ ```
272
+
273
+ The CLI checks `apiKeyEnv` first, then falls back to `LOCALINGOS_API_KEY`, then `apiKey` in the config file.
package/bin/localingos.js CHANGED
@@ -6,6 +6,7 @@ import { syncCommand } from '../src/commands/sync.js';
6
6
  import { pullCommand } from '../src/commands/pull.js';
7
7
  import { pushCommand } from '../src/commands/push.js';
8
8
  import { extractCommand } from '../src/commands/extract.js';
9
+ import { mcpServeCommand } from '../src/commands/mcp-serve.js';
9
10
  import pkg from '../package.json' with { type: 'json' };
10
11
 
11
12
  program
@@ -48,4 +49,9 @@ program
48
49
  .option('-c, --config <path>', 'Path to config file', '.localingos.json')
49
50
  .action(extractCommand);
50
51
 
52
+ program
53
+ .command('mcp-serve')
54
+ .description('Start an MCP server over stdio for AI coding agents (Cursor, Claude Desktop, Kiro)')
55
+ .action(mcpServeCommand);
56
+
51
57
  program.parse();
@@ -0,0 +1,35 @@
1
+ # .github/workflows/localingos-check.yml
2
+ #
3
+ # Checks that i18n source files are up to date on pull requests.
4
+ # Fails if .messages.ts files have changed but extract hasn't been run,
5
+ # or if orphan messages exist.
6
+ #
7
+ # Setup:
8
+ # 1. Copy this file to .github/workflows/localingos-check.yml
9
+ # 2. Make sure "i18n:check" script exists in package.json
10
+
11
+ name: i18n Check
12
+
13
+ on:
14
+ pull_request:
15
+ paths:
16
+ - 'src/**/*.messages.ts'
17
+ - 'src/**/*.tsx'
18
+ - 'src/**/*.ts'
19
+
20
+ jobs:
21
+ check:
22
+ runs-on: ubuntu-latest
23
+
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: '20'
30
+
31
+ - name: Install dependencies
32
+ run: npm ci
33
+
34
+ - name: Check i18n is up to date
35
+ run: npm run i18n:check
@@ -0,0 +1,58 @@
1
+ # .github/workflows/localingos-sync.yml
2
+ #
3
+ # Syncs translations with Localingos on every push to main.
4
+ # Translations are committed back to the repo automatically.
5
+ #
6
+ # Setup:
7
+ # 1. Add your API key as a repository secret: LOCALINGOS_API_KEY
8
+ # (Settings → Secrets and variables → Actions → New repository secret)
9
+ # 2. Make sure localingos.config.json is committed (project config, no secrets)
10
+ # .localingos.json stays in .gitignore (contains API key for local dev only)
11
+ # 3. Copy this file to .github/workflows/localingos-sync.yml
12
+ #
13
+ # Multiple projects (monorepo):
14
+ # Each localingos.config.json can set "apiKeyEnv" to a different env var name:
15
+ # { "apiKeyEnv": "LOCALINGOS_KEY_DOCS", "familyId": "..." }
16
+ # { "apiKeyEnv": "LOCALINGOS_KEY_APP", "familyId": "..." }
17
+ # Then add each as a separate secret and pass them all in the env block.
18
+
19
+ name: Localingos Sync
20
+
21
+ on:
22
+ push:
23
+ branches: [main]
24
+ paths:
25
+ - 'src/**/*.messages.ts'
26
+ - 'src/i18n/**'
27
+ - 'localingos.config.json'
28
+
29
+ jobs:
30
+ sync:
31
+ runs-on: ubuntu-latest
32
+ permissions:
33
+ contents: write
34
+
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+
38
+ - uses: actions/setup-node@v4
39
+ with:
40
+ node-version: '20'
41
+
42
+ - name: Install Localingos CLI
43
+ run: npm install -g localingos
44
+
45
+ - name: Sync translations
46
+ env:
47
+ LOCALINGOS_API_KEY: ${{ secrets.LOCALINGOS_API_KEY }}
48
+ # For monorepos with multiple projects, add per-project secrets:
49
+ # LOCALINGOS_KEY_APP: ${{ secrets.LOCALINGOS_KEY_APP }}
50
+ # LOCALINGOS_KEY_DOCS: ${{ secrets.LOCALINGOS_KEY_DOCS }}
51
+ run: localingos sync --prune
52
+
53
+ - name: Commit updated translations
54
+ run: |
55
+ git config user.name "github-actions[bot]"
56
+ git config user.email "github-actions[bot]@users.noreply.github.com"
57
+ git add src/i18n/
58
+ git diff --staged --quiet || git commit -m "chore: sync translations [skip ci]" && git push
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localingos",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "CLI tool to sync translations with Localingos",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -14,14 +14,17 @@
14
14
  "i18n",
15
15
  "translation",
16
16
  "localization",
17
- "cli"
17
+ "cli",
18
+ "mcp"
18
19
  ],
19
20
  "license": "MIT",
20
21
  "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.28.0",
21
23
  "chalk": "^5.3.0",
22
24
  "commander": "^12.0.0",
23
25
  "inquirer": "^9.2.0",
24
26
  "localingos": "^0.1.11",
25
- "yaml": "^2.4.0"
27
+ "yaml": "^2.4.0",
28
+ "zod": "^3.25.0"
26
29
  }
27
30
  }
package/pom.xml ADDED
@@ -0,0 +1,53 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
3
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
5
+ https://maven.apache.org/xsd/maven-4.0.0.xsd">
6
+ <modelVersion>4.0.0</modelVersion>
7
+
8
+ <parent>
9
+ <groupId>com.localingos</groupId>
10
+ <artifactId>localingos-parent</artifactId>
11
+ <version>1.0.0</version>
12
+ </parent>
13
+
14
+ <artifactId>localingos-cli</artifactId>
15
+ <packaging>pom</packaging>
16
+ <name>Localingos CLI</name>
17
+ <description>CLI tool to sync translations with Localingos</description>
18
+
19
+ <build>
20
+ <plugins>
21
+ <plugin>
22
+ <groupId>com.github.eirslett</groupId>
23
+ <artifactId>frontend-maven-plugin</artifactId>
24
+ <executions>
25
+ <execution>
26
+ <id>install node and npm</id>
27
+ <goals><goal>install-node-and-npm</goal></goals>
28
+ <configuration>
29
+ <nodeVersion>v22.12.0</nodeVersion>
30
+ <npmVersion>10.9.0</npmVersion>
31
+ </configuration>
32
+ </execution>
33
+ <execution>
34
+ <id>npm install</id>
35
+ <goals><goal>npm</goal></goals>
36
+ <configuration>
37
+ <arguments>ci</arguments>
38
+ </configuration>
39
+ </execution>
40
+ </executions>
41
+ </plugin>
42
+ <plugin>
43
+ <artifactId>maven-clean-plugin</artifactId>
44
+ <configuration>
45
+ <filesets>
46
+ <fileset><directory>node_modules</directory></fileset>
47
+ <fileset><directory>node</directory></fileset>
48
+ </filesets>
49
+ </configuration>
50
+ </plugin>
51
+ </plugins>
52
+ </build>
53
+ </project>
@@ -351,7 +351,7 @@ Create \`scripts/extract-messages.js\` — a deterministic Node.js script that:
351
351
  3. Parses each file to extract \`id\`, \`text\`, and \`description\` from every \`MessageDefinition\`
352
352
  4. Builds nested JSON from all dot-path ids, using \`text\` as the ${languageName} value, and writes it to the configured \`sourceFile\` path
353
353
  5. Builds a **separate descriptions file** at the same path but with a \`.descriptions.json\` suffix (e.g. \`${descriptionsFile}\`) using the same nested structure, but with \`description\` as the value. Only include keys that have a non-empty \`description\`. If no descriptions exist at all, still write an empty \`{}\` file.
354
- 6. Merges into the existing source file**never overwrites existing values**, only adds new keys
354
+ 6. The \`*${msgExt}\` files are the **source of truth** always overwrite the source JSON values with the text from message definitions. If a text is changed in a \`*${msgExt}\` file, the JSON must be updated to match.
355
355
  7. Reports: new keys added, existing keys preserved, duplicate ids (which should error and abort)
356
356
 
357
357
  **IMPORTANT**: Do NOT hardcode the output file name (e.g. \`en-US.json\`). The script must derive it from the \`sourceFile\` field in \`.localingos.json\` so it works for any configured locale.
@@ -104,7 +104,8 @@ export async function initCommand() {
104
104
  };
105
105
 
106
106
  const savedPath = saveConfig(config);
107
- console.log(chalk.green(`\n✅ Config saved to ${savedPath}`));
107
+ console.log(chalk.green(`\n✅ Project config saved to localingos.config.json (commit this)`));
108
+ console.log(chalk.green(`✅ API key saved to .localingos.json (keep this gitignored)`));
108
109
 
109
110
  // Check if .gitignore exists and suggest adding .localingos.json
110
111
  const gitignorePath = path.resolve('.gitignore');
@@ -118,7 +119,7 @@ export async function initCommand() {
118
119
  default: true
119
120
  }]);
120
121
  if (addGitignore) {
121
- fs.appendFileSync(gitignorePath, '\n# Localingos CLI config (contains API key)\n.localingos.json\n');
122
+ fs.appendFileSync(gitignorePath, '\n# Localingos local config (contains API key — do not commit)\n.localingos.json\n');
122
123
  console.log(chalk.green('✅ Added .localingos.json to .gitignore'));
123
124
  }
124
125
  }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * localingos mcp-serve
3
+ *
4
+ * Starts an MCP server over stdio for AI coding agents.
5
+ * Reads the same config as the CLI (localingos.config.json + .localingos.json).
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { z } from 'zod';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { execSync } from 'child_process';
14
+
15
+ function loadConfig(cwd) {
16
+ let config = {};
17
+ try { config = JSON.parse(fs.readFileSync(path.join(cwd, 'localingos.config.json'), 'utf-8')); } catch {}
18
+ try { config = { ...config, ...JSON.parse(fs.readFileSync(path.join(cwd, '.localingos.json'), 'utf-8')) }; } catch {}
19
+ const envVarName = config.apiKeyEnv || 'LOCALINGOS_API_KEY';
20
+ if (process.env[envVarName]) config.apiKey = process.env[envVarName];
21
+ return config;
22
+ }
23
+
24
+ async function api(method, urlPath, apiUrl, apiKey, body) {
25
+ const url = `${apiUrl.replace(/\/+$/, '')}${urlPath}`;
26
+ const opts = { method, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } };
27
+ if (body) opts.body = JSON.stringify(body);
28
+ const res = await fetch(url, opts);
29
+ if (!res.ok) throw new Error(`API ${method} ${urlPath} failed (${res.status}): ${await res.text().catch(() => '')}`);
30
+ const text = await res.text();
31
+ return text ? JSON.parse(text) : {};
32
+ }
33
+
34
+ function flatten(obj, prefix = '') {
35
+ const entries = [];
36
+ for (const [k, v] of Object.entries(obj)) {
37
+ const full = prefix ? `${prefix}.${k}` : k;
38
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) entries.push(...flatten(v, full));
39
+ else entries.push({ id: full, text: String(v) });
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ function readSourceEntries(config, cwd) {
45
+ const sf = path.resolve(cwd, config.sourceFile);
46
+ if (!fs.existsSync(sf)) return [];
47
+ const entries = flatten(JSON.parse(fs.readFileSync(sf, 'utf-8')));
48
+ let descs = {};
49
+ try { for (const d of flatten(JSON.parse(fs.readFileSync(sf.replace(/\.json$/, '.descriptions.json'), 'utf-8')))) descs[d.id] = d.text; } catch {}
50
+ return entries.map(e => ({ id: e.id, locale: config.sourceLocale, text: e.text, description: descs[e.id] || null, forceReTranslate: false }));
51
+ }
52
+
53
+ function writeTranslations(translations, config, cwd) {
54
+ const byLocale = {};
55
+ for (const t of translations.filter(t => t.locale !== config.sourceLocale)) (byLocale[t.locale] ||= []).push(t);
56
+ const outputDir = path.resolve(cwd, config.outputDir);
57
+ const written = [];
58
+ for (const [locale, items] of Object.entries(byLocale)) {
59
+ const nested = {};
60
+ for (const { foreignId, text } of items) {
61
+ const keys = foreignId.split('.');
62
+ let cur = nested;
63
+ for (let i = 0; i < keys.length - 1; i++) { cur[keys[i]] = cur[keys[i]] || {}; cur = cur[keys[i]]; }
64
+ cur[keys[keys.length - 1]] = text;
65
+ }
66
+ const fileName = (config.outputPattern || '{locale}.json').replace('{locale}', locale);
67
+ fs.mkdirSync(outputDir, { recursive: true });
68
+ fs.writeFileSync(path.join(outputDir, fileName), JSON.stringify(nested, null, 2) + '\n');
69
+ written.push(fileName);
70
+ }
71
+ return written;
72
+ }
73
+
74
+ export async function mcpServeCommand() {
75
+ const cwd = process.cwd();
76
+ const config = loadConfig(cwd);
77
+ const apiUrl = process.env.LOCALINGOS_API_URL || 'https://api.localingos.com';
78
+
79
+ const server = new McpServer({ name: 'localingos', version: '0.1.0' });
80
+
81
+ server.tool('localingos_sync',
82
+ 'Push source strings to Localingos and pull back translations. Runs extract first if available.',
83
+ { prune: z.boolean().optional().describe('Remove stale keys from JSON files') },
84
+ async ({ prune }) => {
85
+ let extractOut = '';
86
+ const script = path.join(cwd, 'scripts/extract-messages.js');
87
+ if (fs.existsSync(script)) {
88
+ try { extractOut = execSync(`node scripts/extract-messages.js${prune ? ' --prune' : ''}`, { cwd, encoding: 'utf-8', timeout: 30000 }); } catch (e) { extractOut = `Extract warning: ${e.message}`; }
89
+ }
90
+ const entries = readSourceEntries(config, cwd);
91
+ if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
92
+ const result = await api('POST', '/sync', apiUrl, config.apiKey, { familyId: config.familyId, translatables: entries });
93
+ const written = writeTranslations(result.translations || [], config, cwd);
94
+ const pending = result.pending || [];
95
+ const lines = [`Pushed ${entries.length} source strings`, `${(result.translations || []).length} translations available`,
96
+ pending.length ? `${pending.length} keys pending` : null, written.length ? `Written: ${written.join(', ')}` : null,
97
+ extractOut ? `\n${extractOut}` : null].filter(Boolean);
98
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
99
+ }
100
+ );
101
+
102
+ server.tool('localingos_push', 'Push source strings only.', {}, async () => {
103
+ const entries = readSourceEntries(config, cwd);
104
+ if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
105
+ await api('POST', '/translatable', apiUrl, config.apiKey, { familyId: config.familyId, translatableList: entries });
106
+ return { content: [{ type: 'text', text: `Pushed ${entries.length} source strings.` }] };
107
+ });
108
+
109
+ server.tool('localingos_pull', 'Pull latest translations and write locale files.', {}, async () => {
110
+ const translations = await api('GET', `/translation/${config.familyId}`, apiUrl, config.apiKey);
111
+ if (!translations?.length) return { content: [{ type: 'text', text: 'No translations available yet.' }] };
112
+ const written = writeTranslations(translations, config, cwd);
113
+ return { content: [{ type: 'text', text: `Pulled:\n${written.join('\n')}` }] };
114
+ });
115
+
116
+ server.tool('localingos_status', 'Show project status: family, locales, key counts.', {}, async () => {
117
+ const entries = readSourceEntries(config, cwd);
118
+ const outputDir = path.resolve(cwd, config.outputDir);
119
+ let locales = [];
120
+ if (fs.existsSync(outputDir)) {
121
+ const re = new RegExp('^' + (config.outputPattern || '{locale}.json').replace('{locale}', '(.+)').replace('.', '\\.') + '$');
122
+ locales = fs.readdirSync(outputDir).map(f => f.match(re)?.[1]).filter(Boolean);
123
+ }
124
+ return { content: [{ type: 'text', text: `Family: ${config.familyId}\nSource: ${config.sourceLocale}\nKeys: ${entries.length}\nTranslations: ${locales.join(', ') || 'none'}` }] };
125
+ });
126
+
127
+ server.tool('localingos_search', 'Search translation keys or text.',
128
+ { query: z.string().describe('Search term — matches against key IDs and text values') },
129
+ async ({ query }) => {
130
+ const q = query.toLowerCase();
131
+ const matches = readSourceEntries(config, cwd).filter(e => e.id.toLowerCase().includes(q) || e.text.toLowerCase().includes(q));
132
+ if (!matches.length) return { content: [{ type: 'text', text: `No matches for "${query}"` }] };
133
+ const lines = matches.slice(0, 20).map(e => `${e.id}: "${e.text}"`);
134
+ if (matches.length > 20) lines.push(`... and ${matches.length - 20} more`);
135
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
136
+ }
137
+ );
138
+
139
+ server.tool('localingos_extract', 'Run i18n extraction script.',
140
+ { prune: z.boolean().optional().describe('Remove stale/orphan keys'), check: z.boolean().optional().describe('Check mode — fail if files would change') },
141
+ async ({ prune, check }) => {
142
+ const script = path.join(cwd, 'scripts/extract-messages.js');
143
+ if (!fs.existsSync(script)) return { content: [{ type: 'text', text: 'scripts/extract-messages.js not found.' }] };
144
+ const flags = [prune && '--prune', check && '--check'].filter(Boolean).join(' ');
145
+ try {
146
+ return { content: [{ type: 'text', text: execSync(`node scripts/extract-messages.js ${flags}`, { cwd, encoding: 'utf-8', timeout: 30000 }) }] };
147
+ } catch (e) { return { content: [{ type: 'text', text: e.stdout || e.message }] }; }
148
+ }
149
+ );
150
+
151
+ // ── Family management tools ───────────────────────────────────────────
152
+
153
+ server.tool('localingos_family_members', 'List members of a family.',
154
+ { familyId: z.string().describe('Family ID') },
155
+ async ({ familyId }) => {
156
+ const members = await api('GET', `/family/${familyId}/members`, apiUrl, config.apiKey);
157
+ if (!members?.length) return { content: [{ type: 'text', text: 'No members found.' }] };
158
+ const lines = members.map(m => `${m.userId} — ${m.authorization}`);
159
+ return { content: [{ type: 'text', text: `${members.length} member(s):\n${lines.join('\n')}` }] };
160
+ }
161
+ );
162
+
163
+ server.tool('localingos_add_member', 'Add a member to a family (admin only).',
164
+ { familyId: z.string(), userId: z.string().describe('User ID to add'), role: z.enum(['ADMIN', 'WRITE', 'READ']).default('WRITE').describe('Role for the new member') },
165
+ async ({ familyId, userId, role }) => {
166
+ try {
167
+ await api('POST', `/family/${familyId}/members`, apiUrl, config.apiKey, { userId, authorization: role });
168
+ return { content: [{ type: 'text', text: `Added ${userId} as ${role} to family ${familyId}` }] };
169
+ } catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
170
+ }
171
+ );
172
+
173
+ server.tool('localingos_remove_member', 'Remove a member from a family (admin only).',
174
+ { familyId: z.string(), userId: z.string().describe('User ID to remove') },
175
+ async ({ familyId, userId }) => {
176
+ try {
177
+ await api('DELETE', `/family/${familyId}/members/${userId}`, apiUrl, config.apiKey);
178
+ return { content: [{ type: 'text', text: `Removed ${userId} from family ${familyId}` }] };
179
+ } catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
180
+ }
181
+ );
182
+
183
+ server.tool('localingos_billing_status', 'Show billing status for a family — plan, usage, owner.',
184
+ { familyId: z.string().describe('Family ID') },
185
+ async ({ familyId }) => {
186
+ try {
187
+ const b = await api('GET', `/family/${familyId}/billing-status`, apiUrl, config.apiKey);
188
+ if (!b.attached) return { content: [{ type: 'text', text: 'No billing attached to this family.' }] };
189
+ const lines = [
190
+ `Status: ${b.accessMode}${b.planName ? ' · ' + b.planName : ''}`,
191
+ `Owner: ${b.ownerEmail || b.ownerUserId}`,
192
+ b.yearlyIncludedWords > 0 ? `Usage: ${b.yearlyUsedWords.toLocaleString()} / ${b.yearlyIncludedWords.toLocaleString()} words (${b.yearlyRemainingWords.toLocaleString()} remaining)` : null,
193
+ b.trialInfo || null,
194
+ ].filter(Boolean);
195
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
196
+ } catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
197
+ }
198
+ );
199
+
200
+ server.tool('localingos_attach_billing', 'Attach your billing account to a family (admin only).',
201
+ { familyId: z.string() },
202
+ async ({ familyId }) => {
203
+ try {
204
+ await api('POST', `/family/${familyId}/billing/attach`, apiUrl, config.apiKey);
205
+ return { content: [{ type: 'text', text: `Billing attached to family ${familyId}` }] };
206
+ } catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
207
+ }
208
+ );
209
+
210
+ server.tool('localingos_detach_billing', 'Detach billing from a family (admin only). Family becomes read-only.',
211
+ { familyId: z.string() },
212
+ async ({ familyId }) => {
213
+ try {
214
+ await api('POST', `/family/${familyId}/billing/detach`, apiUrl, config.apiKey);
215
+ return { content: [{ type: 'text', text: `Billing detached from family ${familyId}` }] };
216
+ } catch (e) { return { content: [{ type: 'text', text: `Failed: ${e.message}` }] }; }
217
+ }
218
+ );
219
+
220
+ const transport = new StdioServerTransport();
221
+ await server.connect(transport);
222
+ }
package/src/config.js CHANGED
@@ -2,7 +2,23 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
 
5
- const DEFAULT_CONFIG_FILE = '.localingos.json';
5
+ /**
6
+ * Config resolution order:
7
+ *
8
+ * 1. localingos.config.json — committed to repo, shared with team/CI
9
+ * Contains: familyId, sourceLocale, format, sourceFile, outputDir, outputPattern, apiKeyEnv
10
+ *
11
+ * 2. .localingos.json — in .gitignore, local dev only
12
+ * Contains: apiKey (and can override any field from the committed config)
13
+ *
14
+ * 3. Environment variable — for CI/CD
15
+ * LOCALINGOS_API_KEY (default) or custom name via apiKeyEnv field
16
+ *
17
+ * API key resolution: env var (apiKeyEnv || LOCALINGOS_API_KEY) > .localingos.json > localingos.config.json
18
+ */
19
+
20
+ const PROJECT_CONFIG_FILE = 'localingos.config.json';
21
+ const LOCAL_CONFIG_FILE = '.localingos.json';
6
22
 
7
23
  const ENVIRONMENTS = {
8
24
  prod: 'https://api.localingos.com',
@@ -10,13 +26,6 @@ const ENVIRONMENTS = {
10
26
  local: 'https://desktop.localingos.com:8080'
11
27
  };
12
28
 
13
- /**
14
- * Resolve the API URL based on --env flag or LOCALINGOS_ENV env variable.
15
- * Defaults to production.
16
- *
17
- * For the 'local' environment, TLS certificate verification is disabled
18
- * since the local dev server uses a self-signed certificate.
19
- */
20
29
  export function resolveApiUrl(envFlag) {
21
30
  const env = envFlag || process.env.LOCALINGOS_ENV || 'prod';
22
31
  const url = ENVIRONMENTS[env];
@@ -25,45 +34,97 @@ export function resolveApiUrl(envFlag) {
25
34
  console.error(chalk.red(`Unknown environment "${env}". Valid: ${valid}`));
26
35
  process.exit(1);
27
36
  }
28
-
29
- // Disable TLS verification for local dev (self-signed certificate)
30
37
  if (env === 'local') {
31
38
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
32
39
  }
33
-
34
40
  return url;
35
41
  }
36
42
 
43
+ function readJsonSafe(filePath) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
37
51
  export function loadConfig(configPath) {
38
- const resolvedPath = path.resolve(configPath || DEFAULT_CONFIG_FILE);
52
+ // If explicit --config flag was passed, use only that file (backward compat)
53
+ if (configPath && configPath !== LOCAL_CONFIG_FILE) {
54
+ const resolved = path.resolve(configPath);
55
+ if (!fs.existsSync(resolved)) {
56
+ console.error(chalk.red(`Config file not found: ${resolved}`));
57
+ process.exit(1);
58
+ }
59
+ const config = readJsonSafe(resolved);
60
+ if (!config) {
61
+ console.error(chalk.red(`Failed to parse config file: ${resolved}`));
62
+ process.exit(1);
63
+ }
64
+ return resolveApiKey(config);
65
+ }
66
+
67
+ // Merge: project config (committed) ← local overrides (.gitignored)
68
+ const projectPath = path.resolve(PROJECT_CONFIG_FILE);
69
+ const localPath = path.resolve(LOCAL_CONFIG_FILE);
70
+
71
+ const projectConfig = readJsonSafe(projectPath) || {};
72
+ const localConfig = readJsonSafe(localPath) || {};
39
73
 
40
- if (!fs.existsSync(resolvedPath)) {
41
- console.error(chalk.red(`Config file not found: ${resolvedPath}`));
74
+ if (!Object.keys(projectConfig).length && !Object.keys(localConfig).length) {
75
+ console.error(chalk.red(`No config found. Expected ${PROJECT_CONFIG_FILE} or ${LOCAL_CONFIG_FILE}`));
42
76
  console.error(chalk.yellow('Run "localingos init" to create one.'));
43
77
  process.exit(1);
44
78
  }
45
79
 
46
- try {
47
- const raw = fs.readFileSync(resolvedPath, 'utf-8');
48
- const config = JSON.parse(raw);
80
+ // Local overrides project
81
+ const config = { ...projectConfig, ...localConfig };
82
+
83
+ return resolveApiKey(config);
84
+ }
85
+
86
+ function resolveApiKey(config) {
87
+ // Env var takes precedence: check project-specific name first, then generic
88
+ const envVarName = config.apiKeyEnv || 'LOCALINGOS_API_KEY';
89
+ if (process.env[envVarName]) {
90
+ config.apiKey = process.env[envVarName];
91
+ }
49
92
 
50
- // Validate required fields
51
- const required = ['apiKey', 'familyId', 'sourceLocale', 'format', 'sourceFile', 'outputDir'];
52
- const missing = required.filter(field => !config[field]);
53
- if (missing.length > 0) {
93
+ const required = ['apiKey', 'familyId', 'sourceLocale', 'format', 'sourceFile', 'outputDir'];
94
+ const missing = required.filter(field => !config[field]);
95
+ if (missing.length > 0) {
96
+ if (missing.length === 1 && missing[0] === 'apiKey') {
97
+ console.error(chalk.red('API key not found. Provide it via one of:'));
98
+ console.error(chalk.yellow(` • Environment variable: ${envVarName}`));
99
+ console.error(chalk.yellow(` • "apiKey" field in ${LOCAL_CONFIG_FILE} (gitignored)`));
100
+ } else {
54
101
  console.error(chalk.red(`Missing required config fields: ${missing.join(', ')}`));
55
- process.exit(1);
56
102
  }
57
-
58
- return config;
59
- } catch (e) {
60
- console.error(chalk.red(`Failed to parse config file: ${e.message}`));
61
103
  process.exit(1);
62
104
  }
105
+
106
+ return config;
63
107
  }
64
108
 
109
+ /**
110
+ * Save the project config (committed) and local config (gitignored) separately.
111
+ */
65
112
  export function saveConfig(config, configPath) {
66
- const resolvedPath = path.resolve(configPath || DEFAULT_CONFIG_FILE);
67
- fs.writeFileSync(resolvedPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
68
- return resolvedPath;
69
- }
113
+ // If explicit path, write everything there (backward compat)
114
+ if (configPath && configPath !== LOCAL_CONFIG_FILE) {
115
+ const resolved = path.resolve(configPath);
116
+ fs.writeFileSync(resolved, JSON.stringify(config, null, 2) + '\n', 'utf-8');
117
+ return resolved;
118
+ }
119
+
120
+ // Split: apiKey goes to .localingos.json, everything else to localingos.config.json
121
+ const { apiKey, ...projectFields } = config;
122
+
123
+ const projectPath = path.resolve(PROJECT_CONFIG_FILE);
124
+ const localPath = path.resolve(LOCAL_CONFIG_FILE);
125
+
126
+ fs.writeFileSync(projectPath, JSON.stringify(projectFields, null, 2) + '\n', 'utf-8');
127
+ fs.writeFileSync(localPath, JSON.stringify({ apiKey }, null, 2) + '\n', 'utf-8');
128
+
129
+ return projectPath;
130
+ }