@lenne.tech/cli 1.28.0 → 1.29.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.
@@ -47,6 +47,7 @@ const crypto = __importStar(require("crypto"));
47
47
  const path_1 = require("path");
48
48
  const ts = __importStar(require("typescript"));
49
49
  const markdown_table_1 = require("../lib/markdown-table");
50
+ const vendor_claude_md_1 = require("../lib/vendor-claude-md");
50
51
  /**
51
52
  * Server helper functions
52
53
  */
@@ -1585,36 +1586,9 @@ class Server {
1585
1586
  const apiClaudeMdPath = `${dest}/CLAUDE.md`;
1586
1587
  if (filesystem.exists(apiClaudeMdPath)) {
1587
1588
  const existing = filesystem.read(apiClaudeMdPath) || '';
1588
- const marker = '<!-- lt-vendor-marker -->';
1589
- if (!existing.includes(marker)) {
1590
- const vendorBlock = [
1591
- marker,
1592
- '',
1593
- '# Vendor-Mode Notice',
1594
- '',
1595
- 'This api project runs in **vendor mode**: the `@lenne.tech/nest-server`',
1596
- 'core/ tree has been copied directly into `src/core/` as first-class',
1597
- 'project code. There is **no** `@lenne.tech/nest-server` npm dependency.',
1598
- '',
1599
- '- **Read framework code from `src/core/**`** — not from `node_modules/`.',
1600
- '- **Generated imports use relative paths** to `src/core`, e.g.',
1601
- " `import { CrudService } from '../../../core';`",
1602
- ' The exact depth depends on the file location. `lt server module`',
1603
- ' computes it automatically.',
1604
- '- **Baseline + patch log** live in `src/core/VENDOR.md`. Log any',
1605
- ' substantial local change there so the `nest-server-core-updater`',
1606
- ' agent can classify it at sync time.',
1607
- '- **Update flow:** run `/lt-dev:backend:update-nest-server-core` (the',
1608
- ' agent clones upstream, computes a delta, and presents a review).',
1609
- '- **Contribute back:** run `/lt-dev:backend:contribute-nest-server-core`',
1610
- ' to propose local fixes as upstream PRs.',
1611
- '- **Freshness check:** `pnpm run check:vendor-freshness` warns (non-',
1612
- ' blockingly) when upstream has a newer release than the baseline.',
1613
- '',
1614
- '---',
1615
- '',
1616
- ].join('\n');
1617
- filesystem.write(apiClaudeMdPath, vendorBlock + existing);
1589
+ const patched = (0, vendor_claude_md_1.insertVendorBlockIfMissing)(existing, vendor_claude_md_1.BACKEND_VENDOR_MARKER, (0, vendor_claude_md_1.buildBackendVendorBlock)());
1590
+ if (patched !== existing) {
1591
+ filesystem.write(apiClaudeMdPath, patched);
1618
1592
  }
1619
1593
  }
1620
1594
  // ── 9c. Merge nest-server CLAUDE.md sections into project CLAUDE.md ──
@@ -2307,14 +2281,10 @@ class Server {
2307
2281
  // ── 6. Clean CLAUDE.md vendor marker ────────────────────────────────
2308
2282
  const claudeMdPath = `${dest}/CLAUDE.md`;
2309
2283
  if (filesystem.exists(claudeMdPath)) {
2310
- let content = filesystem.read(claudeMdPath) || '';
2311
- const marker = '<!-- lt-vendor-marker -->';
2312
- if (content.includes(marker)) {
2313
- // Remove everything from marker to the first `---` separator (end of vendor block)
2314
- content = content.replace(new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?---\\s*\\n?`, ''), '');
2315
- // Remove leading whitespace/newlines
2316
- content = content.replace(/^\n+/, '');
2317
- filesystem.write(claudeMdPath, content);
2284
+ const content = filesystem.read(claudeMdPath) || '';
2285
+ const cleaned = (0, vendor_claude_md_1.removeVendorBlock)(content, vendor_claude_md_1.BACKEND_VENDOR_MARKER);
2286
+ if (cleaned !== content) {
2287
+ filesystem.write(claudeMdPath, cleaned);
2318
2288
  }
2319
2289
  }
2320
2290
  // ── 7. Restore tsconfig excludes ────────────────────────────────────
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ /**
3
+ * Generic `--help` / `-h` support for **every** lt command.
4
+ *
5
+ * Problem this solves: gluegun's built-in `.help()` only handles the top-level
6
+ * `lt --help` (the command list). For a subcommand, `lt fullstack convert-mode
7
+ * --help` simply *runs* the command — so a user who only wanted to read about a
8
+ * command accidentally triggers it. {@link installHelpInterceptor} wraps every
9
+ * loaded command so that, when help is requested, the command prints rich help
10
+ * and returns **without executing**.
11
+ *
12
+ * Two levels of detail:
13
+ * 1. Generic (always available) — usage, aliases and description from the
14
+ * command metadata gluegun already has.
15
+ * 2. Rich (opt-in) — a command module may `export const help: CommandHelp`
16
+ * describing options, features, examples and configuration. The interceptor
17
+ * loads it from `command.file` without running the command.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.installHelpInterceptor = installHelpInterceptor;
21
+ exports.isHelpRequested = isHelpRequested;
22
+ exports.loadCommandHelp = loadCommandHelp;
23
+ exports.renderCommandHelp = renderCommandHelp;
24
+ /**
25
+ * Wrap every command's `run` so that `--help` / `-h` prints help and returns
26
+ * without executing. Call once after `build().create()`, before `cli.run()`.
27
+ *
28
+ * Idempotent per command (guarded by `__helpWrapped`), so a command is never
29
+ * double-wrapped if this runs more than once in the same process (e.g. tests).
30
+ */
31
+ function installHelpInterceptor(commands, defaultCommand) {
32
+ for (const command of commands || []) {
33
+ if (typeof command.run !== 'function' || command.__helpWrapped) {
34
+ continue;
35
+ }
36
+ // Leave the top-level/default command and gluegun's preloaded builtins
37
+ // (`help`, `version` — they have `file === null`) to gluegun's own
38
+ // `.help()`, so that `lt --help` keeps printing the brand banner + full
39
+ // command list. Real file-backed subcommands (incl. `lt config help`) are
40
+ // still wrapped.
41
+ if (command === defaultCommand || !command.file || !(command.commandPath && command.commandPath.length)) {
42
+ continue;
43
+ }
44
+ const originalRun = command.run;
45
+ command.run = (toolbox) => {
46
+ if ((toolbox === null || toolbox === void 0 ? void 0 : toolbox.print) && isHelpRequested(toolbox.parameters)) {
47
+ renderCommandHelp(toolbox.print, command, loadCommandHelp(command.file));
48
+ return undefined;
49
+ }
50
+ return originalRun(toolbox);
51
+ };
52
+ command.__helpWrapped = true;
53
+ }
54
+ }
55
+ /**
56
+ * True when the invocation asks for help (`--help` or `-h`) — but NOT for
57
+ * `--help-json` (handled separately by `tools.helpJson`).
58
+ */
59
+ function isHelpRequested(parameters) {
60
+ const options = (parameters === null || parameters === void 0 ? void 0 : parameters.options) || {};
61
+ if (options['help-json'] === true || options.helpJson === true) {
62
+ return false;
63
+ }
64
+ return options.help === true || options.h === true;
65
+ }
66
+ /** Best-effort load of a command's rich `help` export from its file. */
67
+ function loadCommandHelp(file) {
68
+ if (!file) {
69
+ return undefined;
70
+ }
71
+ try {
72
+ const mod = require(file);
73
+ const help = (mod && (mod.help || (mod.default && mod.default.help)));
74
+ return help && typeof help === 'object' ? help : undefined;
75
+ }
76
+ catch (_a) {
77
+ return undefined;
78
+ }
79
+ }
80
+ /**
81
+ * Render human-readable help for a command to `print`. Uses the rich `help`
82
+ * definition when available, otherwise a useful generic fallback. Never runs
83
+ * the command.
84
+ */
85
+ function renderCommandHelp(print, command, help) {
86
+ var _a, _b, _c;
87
+ const { bold, cyan, dim, yellow } = print.colors;
88
+ const usage = usagePath(command);
89
+ const description = (help === null || help === void 0 ? void 0 : help.description) || command.description || '(no description)';
90
+ print.info('');
91
+ print.info(`${bold(usage)} — ${description}`);
92
+ const aliases = aliasList(command, help);
93
+ if (aliases.length) {
94
+ print.info(dim(`Aliases: ${aliases.join(', ')}`));
95
+ }
96
+ if ((_a = help === null || help === void 0 ? void 0 : help.features) === null || _a === void 0 ? void 0 : _a.length) {
97
+ print.info('');
98
+ print.info(bold('What it does:'));
99
+ for (const feature of help.features) {
100
+ print.info(` • ${feature}`);
101
+ }
102
+ }
103
+ print.info('');
104
+ print.info(bold('Usage:'));
105
+ print.info(` ${usage} [options]`);
106
+ if ((_b = help === null || help === void 0 ? void 0 : help.examples) === null || _b === void 0 ? void 0 : _b.length) {
107
+ print.info('');
108
+ print.info(bold('Examples:'));
109
+ for (const example of help.examples) {
110
+ print.info(` ${cyan(example.startsWith('lt ') ? example : `lt ${example}`)}`);
111
+ }
112
+ }
113
+ print.info('');
114
+ print.info(bold('Options:'));
115
+ const options = [...((help === null || help === void 0 ? void 0 : help.options) || [])];
116
+ for (const option of options) {
117
+ const meta = [];
118
+ if (option.required) {
119
+ meta.push('required');
120
+ }
121
+ if ((_c = option.values) === null || _c === void 0 ? void 0 : _c.length) {
122
+ meta.push(option.values.join('|'));
123
+ }
124
+ else if (option.type) {
125
+ meta.push(option.type);
126
+ }
127
+ if (option.default !== undefined) {
128
+ meta.push(`default: ${String(option.default)}`);
129
+ }
130
+ const metaText = meta.length ? dim(` (${meta.join(', ')})`) : '';
131
+ print.info(` ${option.flag.padEnd(28)} ${option.description}${metaText}`);
132
+ }
133
+ // Always-present global flags
134
+ print.info(` ${'--help, -h'.padEnd(28)} Show this help and exit (does not run the command)`);
135
+ print.info(` ${'--help-json'.padEnd(28)} Machine-readable help as JSON (where provided)`);
136
+ if (!options.some((o) => o.flag === '--noConfirm')) {
137
+ print.info(` ${'--noConfirm'.padEnd(28)} Skip confirmation prompts (where supported)`);
138
+ }
139
+ if (help === null || help === void 0 ? void 0 : help.configuration) {
140
+ print.info('');
141
+ print.info(bold('Configuration (lt.config.json / lt.config.yaml):'));
142
+ for (const line of help.configuration.split('\n')) {
143
+ print.info(` ${line}`);
144
+ }
145
+ }
146
+ if (!help) {
147
+ print.info('');
148
+ print.info(dim('Tip: see docs/commands.md and docs/lt.config.md for full reference.'));
149
+ }
150
+ print.info('');
151
+ print.info(yellow('This is help output — the command was NOT executed.'));
152
+ print.info('');
153
+ }
154
+ function aliasList(command, help) {
155
+ return (help === null || help === void 0 ? void 0 : help.aliases) || command.aliases || command.alias || [];
156
+ }
157
+ /** Resolve the user-facing invocation, e.g. `lt fullstack convert-mode`. */
158
+ function usagePath(command) {
159
+ const path = command.commandPath && command.commandPath.length ? command.commandPath : [command.name || ''];
160
+ return `lt ${path.filter(Boolean).join(' ')}`.trim();
161
+ }
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+ /**
3
+ * Single source of truth for the "Vendor-Mode Notice" blocks that the CLI
4
+ * writes into project CLAUDE.md files.
5
+ *
6
+ * Why this exists: when a project runs in vendor mode, Claude Code (and humans)
7
+ * must know — from the project itself, without the lt-dev plugin installed —
8
+ * that the framework lives in a vendored `core/` tree and which command syncs
9
+ * it (`update`) vs. ports local fixes back (`contribute`). The plugin hooks are
10
+ * a proactive safety net, but they only fire when the plugin is installed; the
11
+ * CLAUDE.md block is the plugin-independent channel.
12
+ *
13
+ * Three blocks are generated:
14
+ * - Backend → `projects/api/CLAUDE.md` (marker {@link BACKEND_VENDOR_MARKER})
15
+ * - Frontend → `projects/app/CLAUDE.md` (marker {@link FRONTEND_VENDOR_MARKER})
16
+ * - Root → workspace `CLAUDE.md` (marker {@link ROOT_VENDOR_MARKER})
17
+ *
18
+ * The root block is new: Claude often reads the monorepo root CLAUDE.md first,
19
+ * so it needs a short pointer to the per-subproject vendor docs.
20
+ *
21
+ * Each block starts with its HTML marker comment and ends with a `---`
22
+ * horizontal rule, so {@link upsertVendorBlock} / {@link removeVendorBlock} can
23
+ * find and replace exactly the generated region without touching hand-written
24
+ * content below it.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.ROOT_VENDOR_MARKER = exports.FRONTEND_VENDOR_MARKER = exports.BACKEND_VENDOR_MARKER = void 0;
28
+ exports.buildBackendVendorBlock = buildBackendVendorBlock;
29
+ exports.buildFrontendVendorBlock = buildFrontendVendorBlock;
30
+ exports.buildRootVendorBlock = buildRootVendorBlock;
31
+ exports.hasVendorBlock = hasVendorBlock;
32
+ exports.healVendorClaudeMd = healVendorClaudeMd;
33
+ exports.insertVendorBlockIfMissing = insertVendorBlockIfMissing;
34
+ exports.removeVendorBlock = removeVendorBlock;
35
+ exports.upsertVendorBlock = upsertVendorBlock;
36
+ exports.BACKEND_VENDOR_MARKER = '<!-- lt-vendor-marker -->';
37
+ exports.FRONTEND_VENDOR_MARKER = '<!-- lt-vendor-marker-frontend -->';
38
+ exports.ROOT_VENDOR_MARKER = '<!-- lt-vendor-marker-root -->';
39
+ /**
40
+ * Vendor-mode notice for the backend api project (`projects/api/CLAUDE.md`).
41
+ */
42
+ function buildBackendVendorBlock() {
43
+ return block([
44
+ exports.BACKEND_VENDOR_MARKER,
45
+ '',
46
+ '# Vendor-Mode Notice',
47
+ '',
48
+ 'This api project runs in **vendor mode**: the `@lenne.tech/nest-server`',
49
+ 'core/ tree has been copied directly into `src/core/` as first-class',
50
+ 'project code. There is **no** `@lenne.tech/nest-server` npm dependency.',
51
+ '',
52
+ '- **Read framework code from `src/core/**`** — not from `node_modules/`.',
53
+ '- **Generated imports use relative paths** to `src/core`, e.g.',
54
+ " `import { CrudService } from '../../../core';`",
55
+ ' The exact depth depends on the file location. `lt server module`',
56
+ ' computes it automatically.',
57
+ '- **Baseline + patch log** live in `src/core/VENDOR.md`. Log any',
58
+ ' substantial local change there so the `nest-server-core-updater`',
59
+ ' agent can classify it at sync time.',
60
+ '- **Update flow:** run `/lt-dev:backend:update-nest-server-core` (the',
61
+ ' agent clones upstream, computes a delta, and presents a review). The',
62
+ ' update also raises npm packages to at least the upstream baseline',
63
+ ' (via `/lt-dev:maintenance:maintain`).',
64
+ '- **Contribute back:** run `/lt-dev:backend:contribute-nest-server-core`',
65
+ ' to propose local fixes as upstream PRs.',
66
+ '- **Freshness check:** `pnpm run check:vendor-freshness` warns (non-',
67
+ ' blockingly) when upstream has a newer release than the baseline.',
68
+ ]);
69
+ }
70
+ /**
71
+ * Vendor-mode notice for the frontend app project (`projects/app/CLAUDE.md`).
72
+ */
73
+ function buildFrontendVendorBlock() {
74
+ return block([
75
+ exports.FRONTEND_VENDOR_MARKER,
76
+ '',
77
+ '# Vendor-Mode Notice (Frontend)',
78
+ '',
79
+ 'This frontend project runs in **vendor mode**: the `@lenne.tech/nuxt-extensions`',
80
+ 'module has been copied directly into `app/core/` as first-class',
81
+ 'project code. There is **no** `@lenne.tech/nuxt-extensions` npm dependency.',
82
+ '',
83
+ '- **Read framework code from `app/core/**`** — not from `node_modules/`.',
84
+ "- **nuxt.config.ts** references `'./app/core/module'` instead of",
85
+ " `'@lenne.tech/nuxt-extensions'`.",
86
+ '- **Baseline + patch log** live in `app/core/VENDOR.md`. Log any',
87
+ ' substantial local change there so the `nuxt-extensions-core-updater`',
88
+ ' agent can classify it at sync time.',
89
+ '- **Update flow:** run `/lt-dev:frontend:update-nuxt-extensions-core`. The',
90
+ ' update also raises npm packages to at least the upstream baseline',
91
+ ' (via `/lt-dev:maintenance:maintain`).',
92
+ '- **Contribute back:** run `/lt-dev:frontend:contribute-nuxt-extensions-core`.',
93
+ '- **Freshness check:** `pnpm run check:vendor-freshness` warns when',
94
+ ' upstream has a newer release than the baseline.',
95
+ ]);
96
+ }
97
+ /**
98
+ * Vendor-mode notice for the monorepo root (`<workspace>/CLAUDE.md`).
99
+ *
100
+ * Tailored to which halves are vendored so Claude sees only the relevant
101
+ * commands. At least one of `backend` / `frontend` must be true; otherwise the
102
+ * caller should {@link removeVendorBlock} instead of writing an empty notice.
103
+ */
104
+ function buildRootVendorBlock(opts) {
105
+ const { backend, frontend } = opts;
106
+ const lines = [
107
+ exports.ROOT_VENDOR_MARKER,
108
+ '',
109
+ '# Vendor-Mode Notice (Monorepo)',
110
+ '',
111
+ 'This workspace runs at least one framework in **vendor mode** — the',
112
+ 'framework source is vendored directly into the project tree instead of',
113
+ 'being an npm dependency. Read framework code from the vendored `core/`',
114
+ 'trees, not from `node_modules/`.',
115
+ '',
116
+ '**Vendored frameworks:**',
117
+ ];
118
+ if (backend) {
119
+ lines.push('- **Backend** (`@lenne.tech/nest-server`): `projects/api/src/core/` —', ' details in `projects/api/CLAUDE.md` and `projects/api/src/core/VENDOR.md`.');
120
+ }
121
+ if (frontend) {
122
+ lines.push('- **Frontend** (`@lenne.tech/nuxt-extensions`): `projects/app/app/core/` —', ' details in `projects/app/CLAUDE.md` and `projects/app/app/core/VENDOR.md`.');
123
+ }
124
+ lines.push('', '**Update** (sync from upstream; also raises npm packages to at least the', 'upstream baseline via `/lt-dev:maintenance:maintain`):');
125
+ if (backend) {
126
+ lines.push('- Backend: `/lt-dev:backend:update-nest-server-core`');
127
+ }
128
+ if (frontend) {
129
+ lines.push('- Frontend: `/lt-dev:frontend:update-nuxt-extensions-core`');
130
+ }
131
+ lines.push('', '**Contribute back** generally-useful core fixes as upstream PRs:');
132
+ if (backend) {
133
+ lines.push('- Backend: `/lt-dev:backend:contribute-nest-server-core`');
134
+ }
135
+ if (frontend) {
136
+ lines.push('- Frontend: `/lt-dev:frontend:contribute-nuxt-extensions-core`');
137
+ }
138
+ lines.push('', 'Project-specific code never goes into a `core/` tree — see each', "subproject's VENDOR.md Modification Policy.");
139
+ return block(lines);
140
+ }
141
+ /** True when `content` already contains the given vendor marker. */
142
+ function hasVendorBlock(content, marker) {
143
+ return content.includes(marker);
144
+ }
145
+ /**
146
+ * Bring every CLAUDE.md in a workspace in line with its current vendor state:
147
+ * upsert the matching notice block where a framework is vendored, remove it
148
+ * where it is not. Idempotent — running it on an already-correct workspace
149
+ * changes nothing.
150
+ *
151
+ * This is what makes `lt fullstack update` able to *heal* pre-existing or
152
+ * drifted vendor projects (e.g. ones scaffolded before the root notice existed,
153
+ * or whose notice fell out of date).
154
+ *
155
+ * @returns the list of CLAUDE.md paths that were actually modified.
156
+ */
157
+ function healVendorClaudeMd(fs, state) {
158
+ const changed = [];
159
+ const apply = (path, marker, desiredBlock) => {
160
+ if (!fs.exists(path)) {
161
+ return;
162
+ }
163
+ const content = fs.read(path) || '';
164
+ const next = desiredBlock ? upsertVendorBlock(content, marker, desiredBlock) : removeVendorBlock(content, marker);
165
+ if (next !== content) {
166
+ fs.write(path, next);
167
+ changed.push(path);
168
+ }
169
+ };
170
+ if (state.apiDir) {
171
+ apply(joinPath(state.apiDir, 'CLAUDE.md'), exports.BACKEND_VENDOR_MARKER, state.backendVendor ? buildBackendVendorBlock() : null);
172
+ }
173
+ if (state.appDir) {
174
+ apply(joinPath(state.appDir, 'CLAUDE.md'), exports.FRONTEND_VENDOR_MARKER, state.frontendVendor ? buildFrontendVendorBlock() : null);
175
+ }
176
+ if (state.workspaceRoot) {
177
+ const anyVendor = state.backendVendor || state.frontendVendor;
178
+ apply(joinPath(state.workspaceRoot, 'CLAUDE.md'), exports.ROOT_VENDOR_MARKER, anyVendor ? buildRootVendorBlock({ backend: state.backendVendor, frontend: state.frontendVendor }) : null);
179
+ }
180
+ return changed;
181
+ }
182
+ /**
183
+ * Insert the block at the very top of the file **only when it is missing**.
184
+ * Used during conversion so a hand-customized existing block is never clobbered.
185
+ */
186
+ function insertVendorBlockIfMissing(content, marker, newBlock) {
187
+ if (content.includes(marker)) {
188
+ return content;
189
+ }
190
+ return newBlock + content;
191
+ }
192
+ /**
193
+ * Remove the generated block (marker through the first `---`) and trim leading
194
+ * blank lines. Used when converting a project back to npm mode.
195
+ */
196
+ function removeVendorBlock(content, marker) {
197
+ if (!content.includes(marker)) {
198
+ return content;
199
+ }
200
+ return content.replace(blockRegex(marker), '').replace(/^\n+/, '');
201
+ }
202
+ /**
203
+ * Insert the block if missing, or replace the existing generated region with the
204
+ * current canonical block (idempotent self-heal). Used by `lt fullstack update`
205
+ * to bring pre-existing / drifted projects up to the current notice.
206
+ */
207
+ function upsertVendorBlock(content, marker, newBlock) {
208
+ if (!content.includes(marker)) {
209
+ return newBlock + content;
210
+ }
211
+ return content.replace(blockRegex(marker), newBlock);
212
+ }
213
+ /** Join block lines into the canonical `marker … --- ` shape (ends with `---\n`). */
214
+ function block(lines) {
215
+ return [...lines, '', '---', ''].join('\n');
216
+ }
217
+ /** Build the regex that matches an existing block from its marker to the first `---`. */
218
+ function blockRegex(marker) {
219
+ return new RegExp(`${escapeRegExp(marker)}[\\s\\S]*?---\\s*\\n?`);
220
+ }
221
+ /** Escape a string for safe use inside a RegExp. */
222
+ function escapeRegExp(value) {
223
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
224
+ }
225
+ function joinPath(dir, file) {
226
+ return dir.endsWith('/') ? `${dir}${file}` : `${dir}/${file}`;
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",