langctl 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-08
4
+
5
+ ### Added
6
+ - `langctl translate [project]`: fill missing translations with AI (DeepL) from the default language. `--to`, `--keys`, `--module`, `--overwrite`, `--dry-run` (uses no quota). Placeholders such as `{{name}}`, `{count}`, `%1$s` are preserved.
7
+ - `langctl review [project]`: list AI translations awaiting review; `--approve` (narrow with `--keys` / `--languages`).
8
+ - `pull --include-unreviewed`.
9
+ - `pull` caches exports by ETag in `~/.langctl/cache`: when nothing changed on the server, the API answers `304 Not Modified` and the saved snapshot is reused. Set `LANGCTL_NO_CACHE=1` to disable. In CI, cache `~/.langctl/cache` (e.g. `actions/cache`) to benefit across runs.
10
+
11
+ ### Changed
12
+ - `pull` leaves out AI translations nobody has reviewed yet (your app falls back to the default language for them) and warns how many were held back.
13
+
3
14
  ## 0.3.0 — 2026-10-08
4
15
 
5
16
  A rewrite focused on reliability in real projects and CI.
package/README.md CHANGED
@@ -129,10 +129,12 @@ the pull fails and names both keys instead of silently dropping one.
129
129
  | Command | Description |
130
130
  | --- | --- |
131
131
  | `langctl init` | Set up a repo (auth if needed, write `langctl.json`). Flags: `--project --format --output --force`. |
132
- | `langctl pull [project]` | Download translations. `-l/--languages`, `-f/--format`, `-o/--output`, `-m/--module`, `--include-drafts`, `--check`, `--dry-run`, `--require-complete`. |
132
+ | `langctl pull [project]` | Download translations. `-l/--languages`, `-f/--format`, `-o/--output`, `-m/--module`, `--include-drafts`, `--include-unreviewed`, `--check`, `--dry-run`, `--require-complete`. AI translations nobody has reviewed are left out (with a warning) until approved. |
133
133
  | `langctl push [project]` | Upload files. Default: source language only; `-l all` for every language. `--overwrite`, `--publish`, `--dry-run`, `-i/--input`. |
134
134
  | `langctl export [project]` | One-off export: `-l es -f android -o strings.xml`. |
135
135
  | `langctl import [project] <file>` | One-off import of a single file: `-l es`, `--overwrite`, `--publish`, `--dry-run`. |
136
+ | `langctl translate [project]` | Fill missing translations with AI (DeepL) from the default language. `-t/--to es,fr`, `-k/--keys`, `-m/--module`, `--overwrite`, `--dry-run` (no quota used). Placeholders like `{{name}}` are kept as-is; uses the plan's monthly AI translations, or the org's own DeepL key if one is saved. |
137
+ | `langctl review [project]` | List AI translations awaiting review; `--approve` approves them (narrow with `-k/--keys`, `-l/--languages`; `--yes` in CI). Editing a translation in the web app also counts as reviewing it. |
136
138
  | `langctl auth [--stdin]` | Store an API key in `~/.langctl/config.json` (mode 600). `echo "$KEY" \| langctl auth --stdin`. |
137
139
  | `langctl whoami` | Show org, key source, scopes and API latency. |
138
140
  | `langctl logout` · `langctl config` · `langctl formats` | Remove the stored key · show effective config · list formats. |
@@ -152,6 +154,7 @@ Global flags (any position): `--json` · `-q/--quiet` · `--verbose` (log HTTP r
152
154
  | `LANGCTL_API_URL` | API base URL (default `https://api.langctl.com/api/v1`). |
153
155
  | `LANGCTL_TIMEOUT` | Request timeout in seconds (default 30). Idempotent requests are retried with backoff on network errors, 429 and 5xx. |
154
156
  | `LANGCTL_CONFIG_DIR` | Where the user config lives (default `~/.langctl`). |
157
+ | `LANGCTL_NO_CACHE` | Set to `1` to disable the export cache (`~/.langctl/cache`). `pull` normally sends the last ETag and reuses the cached snapshot when the server answers 304 Not Modified. |
155
158
  | `NO_COLOR` / `CI` | Disable colors / force non-interactive mode. |
156
159
  | `NODE_EXTRA_CA_CERTS` | Trust a corporate proxy's CA. |
157
160
 
@@ -30,10 +30,12 @@ export async function pullCommand(projectArg, opts) {
30
30
  let project, snapshot;
31
31
  try {
32
32
  project = await getProject(session, slug);
33
- snapshot = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, {
33
+ // ETag-cached: unchanged translations come back as a tiny 304 and the saved snapshot is reused
34
+ ({ data: snapshot } = await session.api.getCached(`/orgs/${session.orgId}/projects/${project.id}/export`, {
34
35
  publishedOnly: includeDrafts ? 'false' : 'true',
36
+ includeUnreviewed: opts.includeUnreviewed ? 'true' : undefined,
35
37
  module,
36
- });
38
+ }));
37
39
  }
38
40
  finally {
39
41
  spin.stop();
@@ -63,6 +65,7 @@ export async function pullCommand(projectArg, opts) {
63
65
  dryRun,
64
66
  files: files.map(f => ({ ...f, path: relative(process.cwd(), f.path) })),
65
67
  changed: changed.length,
68
+ unreviewedSkipped: snapshot.metadata?.unreviewedSkipped ?? 0,
66
69
  });
67
70
  }
68
71
  else {
@@ -73,6 +76,10 @@ export async function pullCommand(projectArg, opts) {
73
76
  log.out(`${icon} ${relative(process.cwd(), f.path)} ${chalk.dim(verb)}${coverage}`);
74
77
  }
75
78
  log.info(chalk.dim(`${project.slug}: ${snapshot.keys.length} ${includeDrafts ? '' : 'published '}keys, ${languages.length} language(s), format ${format.id}`));
79
+ const unreviewed = snapshot.metadata?.unreviewedSkipped ?? 0;
80
+ if (unreviewed > 0) {
81
+ log.warn(`${unreviewed} AI translation(s) awaiting review were left out (your app falls back to ${project.defaultLanguage} for them). Review with "langctl review ${project.slug}", or pass --include-unreviewed.`);
82
+ }
76
83
  if (snapshot.keys.length === 0 && !includeDrafts) {
77
84
  log.warn('No published keys — drafts are excluded by default. Publish keys, or pass --include-drafts.');
78
85
  }
@@ -0,0 +1,65 @@
1
+ import chalk from 'chalk';
2
+ import { usageError } from '../core/errors.js';
3
+ import { getSession } from '../core/http.js';
4
+ import { log, printJson, runtime, spinner, table } from '../core/output.js';
5
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
6
+ import { confirm } from '../core/prompts.js';
7
+ const split = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
8
+ /**
9
+ * List AI translations awaiting review, or approve them with --approve.
10
+ * Unreviewed AI translations are left out of `langctl pull` until approved (or edited) in langctl.
11
+ */
12
+ export async function reviewCommand(projectArg, opts) {
13
+ const session = await getSession();
14
+ const project = await getProject(session, projectSlugFrom(projectArg));
15
+ const keys = split(opts.keys);
16
+ const languages = split(opts.languages);
17
+ if (languages)
18
+ assertLanguages(project, languages);
19
+ const spin = spinner('Fetching AI translations awaiting review…');
20
+ let list;
21
+ try {
22
+ list = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/review`);
23
+ }
24
+ finally {
25
+ spin.stop();
26
+ }
27
+ const items = list.items.filter(i => (!keys || keys.includes(i.key)) && (!languages || languages.includes(i.language)));
28
+ if (keys) {
29
+ const unknown = keys.filter(k => !list.items.some(i => i.key === k));
30
+ if (unknown.length)
31
+ log.warn(`Nothing awaiting review for: ${unknown.join(', ')}`);
32
+ }
33
+ if (!opts.approve) {
34
+ if (runtime.json)
35
+ return printJson({ project: project.slug, total: items.length, items });
36
+ if (!items.length) {
37
+ log.success('No AI translations awaiting review.');
38
+ return;
39
+ }
40
+ table(items.map(i => [i.key, i.language, i.text.length > 60 ? `${i.text.slice(0, 57)}…` : i.text]), ['key', 'lang', 'AI translation']);
41
+ log.info(chalk.dim(`${items.length} awaiting review — left out of "langctl pull" until approved. Approve with "langctl review ${project.slug} --approve" (narrow with --keys / --languages), or edit them in the web app.`));
42
+ return;
43
+ }
44
+ if (!items.length) {
45
+ if (runtime.json)
46
+ return printJson({ project: project.slug, approved: 0 });
47
+ log.success('Nothing to approve.');
48
+ return;
49
+ }
50
+ if (!(await confirm(`Approve ${items.length} AI translation(s) in ${project.slug}? They will ship on the next pull.`))) {
51
+ throw usageError('Cancelled.');
52
+ }
53
+ const keyIds = [...new Set(items.map(i => i.keyId))];
54
+ let approved = 0;
55
+ for (let i = 0; i < keyIds.length; i += 1000) {
56
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/review`, {
57
+ keyIds: keyIds.slice(i, i + 1000),
58
+ languages: languages ?? [...new Set(items.map(it => it.language))],
59
+ });
60
+ approved += res.approved;
61
+ }
62
+ if (runtime.json)
63
+ return printJson({ project: project.slug, approved });
64
+ log.success(`Approved ${approved} AI translation(s). They'll be included in the next "langctl pull".`);
65
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from 'chalk';
2
+ import { usageError } from '../core/errors.js';
3
+ import { getSession } from '../core/http.js';
4
+ import { log, printJson, runtime, spinner, table } from '../core/output.js';
5
+ import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
6
+ /** The API translates at most 50 strings per request. */
7
+ const BATCH = 50;
8
+ const isBlank = (v) => !v || !v.trim();
9
+ /**
10
+ * Fill missing translations with AI (DeepL) from the project's default language.
11
+ * Counts against the plan's monthly AI translations unless the org uses its own DeepL key.
12
+ */
13
+ export async function translateCommand(projectArg, opts) {
14
+ const session = await getSession();
15
+ const project = await getProject(session, projectSlugFrom(projectArg));
16
+ const source = project.defaultLanguage;
17
+ const targets = opts.to
18
+ ? opts.to.split(',').map(s => s.trim()).filter(Boolean)
19
+ : project.languages.filter(l => l !== source);
20
+ if (!targets.length)
21
+ throw usageError(`Project "${project.slug}" has no languages besides ${source}.`, `Add one with "langctl projects add-language ${project.slug} <code>".`);
22
+ assertLanguages(project, targets);
23
+ if (targets.includes(source))
24
+ throw usageError(`${source} is the source language and can't be a target.`);
25
+ const spin = spinner('Fetching keys…');
26
+ let keys;
27
+ try {
28
+ keys = await listAllKeys(session, project, { module: opts.module });
29
+ }
30
+ finally {
31
+ spin.stop();
32
+ }
33
+ if (opts.keys) {
34
+ const wanted = new Set(opts.keys.split(',').map(s => s.trim()).filter(Boolean));
35
+ const unknown = [...wanted].filter(k => !keys.some(key => key.key === k));
36
+ if (unknown.length)
37
+ throw usageError(`Key${unknown.length > 1 ? 's' : ''} not found: ${unknown.join(', ')}`);
38
+ keys = keys.filter(k => wanted.has(k.key));
39
+ }
40
+ // Work list: per target language, keys with source text whose target is empty (or all, with --overwrite)
41
+ const plan = targets.map(lang => ({
42
+ lang,
43
+ keys: keys.filter(k => !isBlank(k.translations?.[source]) && (opts.overwrite || isBlank(k.translations?.[lang]))),
44
+ }));
45
+ const total = plan.reduce((n, p) => n + p.keys.length, 0);
46
+ const noSource = keys.filter(k => isBlank(k.translations?.[source])).length;
47
+ if (opts.dryRun || total === 0) {
48
+ if (runtime.json) {
49
+ return printJson({ dryRun: Boolean(opts.dryRun), source, languages: plan.map(p => ({ language: p.lang, keys: p.keys.map(k => k.key) })), total });
50
+ }
51
+ table(plan.map(p => [p.lang, String(p.keys.length)]), ['language', opts.overwrite ? 'keys to retranslate' : 'missing']);
52
+ if (noSource)
53
+ log.info(chalk.dim(`${noSource} key(s) have no ${source} text and are skipped.`));
54
+ if (total === 0)
55
+ log.success('Nothing to translate.');
56
+ else
57
+ log.info(`Dry run: ${total} translation(s) would use ${total} AI translation(s). Run without --dry-run to translate.`);
58
+ return;
59
+ }
60
+ const done = [];
61
+ let usage;
62
+ let provider;
63
+ const progress = spinner(`Translating 0/${total}…`);
64
+ try {
65
+ for (const { lang, keys: todo } of plan) {
66
+ for (let i = 0; i < todo.length; i += BATCH) {
67
+ const batch = todo.slice(i, i + BATCH);
68
+ const res = await session.api.post(`/orgs/${session.orgId}/translate/bulk`, {
69
+ items: batch.map(k => ({ text: k.translations[source], keyId: k.id })),
70
+ sourceLang: source,
71
+ targetLang: lang,
72
+ projectId: project.id,
73
+ });
74
+ usage = res.usage;
75
+ provider = res.provider;
76
+ for (const [j, key] of batch.entries()) {
77
+ const text = res.results[j]?.translatedText;
78
+ if (!text)
79
+ continue;
80
+ await session.api.patch(`/orgs/${session.orgId}/projects/${project.id}/keys/${key.id}/translations/${encodeURIComponent(lang)}`, { value: text });
81
+ done.push({ language: lang, key: key.key, text });
82
+ progress.text = `Translating ${done.length}/${total}…`;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ finally {
88
+ progress.stop();
89
+ }
90
+ if (runtime.json)
91
+ return printJson({ source, translated: done, provider, usage });
92
+ for (const d of done)
93
+ log.out(`${chalk.dim(d.language)} ${d.key} ${d.text}`);
94
+ const quota = provider === 'org'
95
+ ? 'using your own DeepL key'
96
+ : usage?.limit != null ? `${usage.used}/${usage.limit} AI translations used this month` : `${usage?.used ?? done.length} AI translations used this month`;
97
+ log.success(`Translated ${done.length} string(s) into ${plan.filter(p => p.keys.length).map(p => p.lang).join(', ')} (${quota}).`);
98
+ log.info(chalk.dim(`AI translations are held back from "langctl pull" until reviewed. Check them with "langctl review ${project.slug}", then approve with --approve (or edit them in the web app).`));
99
+ }
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { configDir } from './config.js';
5
+ /**
6
+ * Small on-disk cache for conditional GETs (ETag / If-None-Match). When the server says
7
+ * "304 Not Modified" the CLI reuses the saved body, so repeated pulls cost almost nothing.
8
+ * Lives in the config dir (~/.langctl/cache); disable with LANGCTL_NO_CACHE=1.
9
+ */
10
+ const MAX_ENTRIES = 40;
11
+ function cacheDir() {
12
+ return join(configDir(), 'cache');
13
+ }
14
+ export function cacheEnabled() {
15
+ return !['1', 'true', 'yes'].includes(String(process.env.LANGCTL_NO_CACHE || '').toLowerCase());
16
+ }
17
+ /** Cache key covers the API, the key (orgs/scopes differ per key) and the full request URL. */
18
+ export function cacheKey(apiUrl, apiKey, url) {
19
+ const keyId = createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
20
+ return createHash('sha256').update(`${apiUrl}\n${keyId}\n${url}`).digest('hex').slice(0, 32);
21
+ }
22
+ export function readCache(key) {
23
+ try {
24
+ const entry = JSON.parse(readFileSync(join(cacheDir(), `${key}.json`), 'utf8'));
25
+ return entry && typeof entry.etag === 'string' ? entry : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function writeCache(key, etag, data) {
32
+ try {
33
+ const dir = cacheDir();
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify({ etag, data, savedAt: new Date().toISOString() }), { mode: 0o600 });
36
+ prune(dir);
37
+ }
38
+ catch {
39
+ // A cache that can't be written (read-only home, full disk) must never fail a command
40
+ }
41
+ }
42
+ function prune(dir) {
43
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'))
44
+ .map(f => ({ f, t: statSync(join(dir, f)).mtimeMs }))
45
+ .sort((a, b) => b.t - a.t);
46
+ for (const { f } of files.slice(MAX_ENTRIES))
47
+ unlinkSync(join(dir, f));
48
+ }
package/dist/core/http.js CHANGED
@@ -2,6 +2,7 @@ import { CliError, ExitCode, httpError, networkError } from './errors.js';
2
2
  import { requireCredentials } from './config.js';
3
3
  import { detectCi, log } from './output.js';
4
4
  import { VERSION } from '../version.js';
5
+ import { cacheEnabled, cacheKey, readCache, writeCache } from './cache.js';
5
6
  export const httpSettings = {
6
7
  timeoutMs: Number(process.env.LANGCTL_TIMEOUT || 30) * 1000,
7
8
  retries: 3,
@@ -19,11 +20,40 @@ export class ApiClient {
19
20
  return this.creds.apiUrl;
20
21
  }
21
22
  async request(method, path, opts = {}) {
23
+ return (await this.send(method, this.buildUrl(path, opts.query), opts)).data;
24
+ }
25
+ buildUrl(path, query) {
22
26
  const url = new URL(this.creds.apiUrl + path);
23
- for (const [k, v] of Object.entries(opts.query ?? {})) {
27
+ for (const [k, v] of Object.entries(query ?? {})) {
24
28
  if (v !== undefined && v !== '')
25
29
  url.searchParams.set(k, String(v));
26
30
  }
31
+ return url;
32
+ }
33
+ /**
34
+ * GET with an ETag cache: if the server reports the resource unchanged (304) the saved body is
35
+ * reused. Used for exports, which CI fetches over and over while translations rarely change.
36
+ */
37
+ async getCached(path, query) {
38
+ const url = this.buildUrl(path, query);
39
+ if (!cacheEnabled())
40
+ return { data: (await this.send('GET', url, {})).data, fromCache: false };
41
+ const key = cacheKey(this.creds.apiUrl, this.creds.apiKey, url.toString());
42
+ const cached = readCache(key);
43
+ const res = await this.send('GET', url, { ifNoneMatch: cached?.etag });
44
+ if (res.notModified && cached) {
45
+ log.debug(`cache hit for ${url.pathname} (${cached.etag})`);
46
+ return { data: cached.data, fromCache: true };
47
+ }
48
+ if (res.notModified) {
49
+ // 304 without a cached body (cache wiped mid-run): fetch unconditionally
50
+ return { data: (await this.send('GET', url, {})).data, fromCache: false };
51
+ }
52
+ if (res.etag)
53
+ writeCache(key, res.etag, res.data);
54
+ return { data: res.data, fromCache: false };
55
+ }
56
+ async send(method, url, opts) {
27
57
  const headers = {
28
58
  'X-API-Key': this.creds.apiKey,
29
59
  'User-Agent': userAgent(),
@@ -32,6 +62,8 @@ export class ApiClient {
32
62
  // Only declare a JSON body when there is one — Fastify rejects an empty body with this header
33
63
  if (opts.body !== undefined)
34
64
  headers['Content-Type'] = 'application/json';
65
+ if (opts.ifNoneMatch)
66
+ headers['If-None-Match'] = opts.ifNoneMatch;
35
67
  const idempotent = opts.idempotent ?? (method === 'GET');
36
68
  const attempts = idempotent ? httpSettings.retries : 1;
37
69
  for (let attempt = 1;; attempt++) {
@@ -55,6 +87,9 @@ export class ApiClient {
55
87
  throw mapped;
56
88
  }
57
89
  log.debug(`${method} ${url.pathname}${url.search} → ${res.status} (${Date.now() - started}ms)`);
90
+ if (res.status === 304) {
91
+ return { status: 304, data: undefined, etag: res.headers.get('etag'), notModified: true };
92
+ }
58
93
  const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
59
94
  if (retryable && attempt < attempts) {
60
95
  const retryAfter = Number(res.headers.get('retry-after'));
@@ -77,7 +112,7 @@ export class ApiClient {
77
112
  const d = data;
78
113
  throw httpError(res.status, d?.error || d?.message, method, url.pathname);
79
114
  }
80
- return data;
115
+ return { status: res.status, data: data, etag: res.headers.get('etag'), notModified: false };
81
116
  }
82
117
  }
83
118
  get(path, query) {
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ import { orgInfoCommand, orgPlanCommand, orgStatsCommand } from './commands/org.
11
11
  import { projectsAddLanguageCommand, projectsCreateCommand, projectsDeleteCommand, projectsGetCommand, projectsListCommand, projectsRemoveLanguageCommand, projectsStatsCommand, projectsUpdateCommand, } from './commands/projects.js';
12
12
  import { pullCommand } from './commands/pull.js';
13
13
  import { pushCommand } from './commands/push.js';
14
+ import { translateCommand } from './commands/translate.js';
15
+ import { reviewCommand } from './commands/review.js';
14
16
  import { getTeamMemberCommand, inviteTeamMemberCommand, listInvitationsCommand, listTeamCommand, removeTeamMemberCommand, revokeInvitationCommand, updateTeamRoleCommand, } from './commands/team.js';
15
17
  import { flagOverrides } from './core/config.js';
16
18
  import { CliError, ExitCode, usageError } from './core/errors.js';
@@ -91,6 +93,7 @@ withGlobals(program.command('pull [project]'))
91
93
  .addOption(new Option('-d, --dir <path>', 'legacy: base directory for the default layout').hideHelp())
92
94
  .option('-m, --module <name>', 'only keys from this module')
93
95
  .option('--include-drafts', 'include unpublished keys (excluded by default)')
96
+ .option('--include-unreviewed', 'include AI translations nobody has reviewed yet (excluded by default)')
94
97
  .addOption(new Option('--no-published-only').hideHelp())
95
98
  .option('--check', 'do not write; exit 7 if any file is out of date (for CI)')
96
99
  .option('--dry-run', 'show what would change without writing')
@@ -192,6 +195,19 @@ withGlobals(keys.command('unpublish <project> [keys...]')).description('move key
192
195
  .option('--all', 'unpublish every published key')
193
196
  .option('-m, --module <name>', 'unpublish every key in a module')
194
197
  .action(run((project, names, opts) => publishKeysCommand(project, names, opts, false)));
198
+ // ── AI translation ──────────────────────────────────────────────
199
+ withGlobals(program.command('translate [project]')).description('fill missing translations with AI (DeepL) from the default language')
200
+ .option('-t, --to <codes>', 'comma-separated target languages (default: every non-default language)')
201
+ .option('-k, --keys <names>', 'only these keys (comma-separated)')
202
+ .option('-m, --module <name>', 'only keys in a module')
203
+ .option('--overwrite', 'retranslate keys that already have a translation')
204
+ .option('--dry-run', 'show what would be translated without using any AI translations')
205
+ .action(run((project, opts) => translateCommand(project, opts)));
206
+ withGlobals(program.command('review [project]')).description('list AI translations awaiting review, or approve them')
207
+ .option('--approve', 'approve the listed translations (asks to confirm; --yes in CI)')
208
+ .option('-k, --keys <names>', 'only these keys (comma-separated)')
209
+ .option('-l, --languages <codes>', 'only these languages (comma-separated)')
210
+ .action(run((project, opts) => reviewCommand(project, opts)));
195
211
  // ── Team & org ──────────────────────────────────────────────────
196
212
  const team = program.command('team').description('manage team members (needs an API key with the org:admin scope)');
197
213
  withGlobals(team.command('list')).alias('ls').description('list members').action(run(() => listTeamCommand()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "langctl",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Translation management CLI for developers: sync i18n files (JSON, Android, iOS, Flutter ARB) between your repo, CI and Langctl.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",