@nowline/cli 0.8.6 → 0.10.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 +32 -9
- package/dist/cli/args.d.ts +20 -4
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +20 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +31 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/options.d.ts +74 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +209 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +2 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mcp-install.d.ts +18 -0
- package/dist/commands/mcp-install.d.ts.map +1 -0
- package/dist/commands/mcp-install.js +151 -0
- package/dist/commands/mcp-install.js.map +1 -0
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +7 -12
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/render.d.ts +7 -0
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +161 -155
- package/dist/commands/render.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +87 -60
- package/dist/commands/serve.js.map +1 -1
- package/dist/diagnostics/adapt.d.ts +12 -1
- package/dist/diagnostics/adapt.d.ts.map +1 -1
- package/dist/diagnostics/adapt.js +53 -1
- package/dist/diagnostics/adapt.js.map +1 -1
- package/dist/diagnostics/model.d.ts +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.d.ts.map +1 -1
- package/dist/generated/version.js +2 -2
- package/dist/generated/version.js.map +1 -1
- package/dist/i18n/locale.d.ts.map +1 -1
- package/dist/i18n/locale.js +2 -5
- package/dist/i18n/locale.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/io/config.d.ts +1 -1
- package/dist/io/config.d.ts.map +1 -1
- package/dist/io/config.js +1 -1
- package/dist/io/config.js.map +1 -1
- package/dist/io/log.d.ts +17 -0
- package/dist/io/log.d.ts.map +1 -0
- package/dist/io/log.js +16 -0
- package/dist/io/log.js.map +1 -0
- package/dist/mcp-install/clients.d.ts +58 -0
- package/dist/mcp-install/clients.d.ts.map +1 -0
- package/dist/mcp-install/clients.js +304 -0
- package/dist/mcp-install/clients.js.map +1 -0
- package/dist/mcp-install/host.d.ts +29 -0
- package/dist/mcp-install/host.d.ts.map +1 -0
- package/dist/mcp-install/host.js +72 -0
- package/dist/mcp-install/host.js.map +1 -0
- package/dist/mcp-install/json-config.d.ts +29 -0
- package/dist/mcp-install/json-config.d.ts.map +1 -0
- package/dist/mcp-install/json-config.js +139 -0
- package/dist/mcp-install/json-config.js.map +1 -0
- package/dist/mcp-install/server-command.d.ts +26 -0
- package/dist/mcp-install/server-command.d.ts.map +1 -0
- package/dist/mcp-install/server-command.js +52 -0
- package/dist/mcp-install/server-command.js.map +1 -0
- package/man/fr/nowline.1 +248 -19
- package/man/fr/nowline.5 +819 -45
- package/man/nowline.1 +193 -18
- package/man/nowline.5 +764 -36
- package/package.json +19 -19
- package/src/cli/args.ts +55 -5
- package/src/cli/help.ts +31 -3
- package/src/cli/options.ts +286 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/mcp-install.ts +211 -0
- package/src/commands/mcp.ts +10 -14
- package/src/commands/render.ts +198 -195
- package/src/commands/serve.ts +102 -74
- package/src/diagnostics/adapt.ts +62 -0
- package/src/diagnostics/model.ts +1 -1
- package/src/generated/version.ts +2 -2
- package/src/i18n/locale.ts +2 -6
- package/src/index.ts +3 -0
- package/src/io/config.ts +0 -1
- package/src/io/log.ts +33 -0
- package/src/mcp-install/clients.ts +443 -0
- package/src/mcp-install/host.ts +106 -0
- package/src/mcp-install/json-config.ts +179 -0
- package/src/mcp-install/server-command.ts +75 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
applyEdits,
|
|
13
|
+
type Edit,
|
|
14
|
+
type FormattingOptions,
|
|
15
|
+
modify,
|
|
16
|
+
type ParseError,
|
|
17
|
+
parse,
|
|
18
|
+
printParseErrorCode,
|
|
19
|
+
} from 'jsonc-parser';
|
|
20
|
+
import type { JsonTarget } from './clients.js';
|
|
21
|
+
import type { ServerEntry } from './server-command.js';
|
|
22
|
+
|
|
23
|
+
export type JsonChange = 'created' | 'added' | 'updated' | 'unchanged' | 'removed' | 'absent';
|
|
24
|
+
|
|
25
|
+
export interface JsonEdit {
|
|
26
|
+
change: JsonChange;
|
|
27
|
+
/** The new file content; undefined when nothing changes. */
|
|
28
|
+
text?: string;
|
|
29
|
+
keyPath: string[];
|
|
30
|
+
value?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class JsonConfigError extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Inserts or replaces the server entry in a JSON / JSONC document, editing
|
|
37
|
+
* the text rather than re-serializing it, so comments, key order, and the
|
|
38
|
+
* file's own indentation survive. `existing` undefined means the file is
|
|
39
|
+
* missing.
|
|
40
|
+
*/
|
|
41
|
+
export function upsertEntry(
|
|
42
|
+
existing: string | undefined,
|
|
43
|
+
target: JsonTarget,
|
|
44
|
+
entry: ServerEntry,
|
|
45
|
+
): JsonEdit {
|
|
46
|
+
const isNew = existing === undefined || existing.trim() === '';
|
|
47
|
+
const text = isNew ? `${JSON.stringify(target.seed ?? {}, null, 2)}\n` : existing;
|
|
48
|
+
const doc = parseDocument(text, target.path);
|
|
49
|
+
const { keyPath, value } = target.place(entry, doc);
|
|
50
|
+
const current = valueAt(doc, keyPath);
|
|
51
|
+
if (!isNew && deepEqual(current, value)) return { change: 'unchanged', keyPath, value };
|
|
52
|
+
return {
|
|
53
|
+
change: isNew ? 'created' : current === undefined ? 'added' : 'updated',
|
|
54
|
+
text: applyEdits(text, insertionEdits(text, keyPath, value)),
|
|
55
|
+
keyPath,
|
|
56
|
+
value,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Removes the server entry, leaving every other key and comment in place. */
|
|
61
|
+
export function removeEntry(
|
|
62
|
+
existing: string | undefined,
|
|
63
|
+
target: JsonTarget,
|
|
64
|
+
entry: ServerEntry,
|
|
65
|
+
): JsonEdit {
|
|
66
|
+
if (existing === undefined || existing.trim() === '') {
|
|
67
|
+
return { change: 'absent', keyPath: [] };
|
|
68
|
+
}
|
|
69
|
+
const doc = parseDocument(existing, target.path);
|
|
70
|
+
const { keyPath } = target.place(entry, doc);
|
|
71
|
+
if (valueAt(doc, keyPath) === undefined) return { change: 'absent', keyPath };
|
|
72
|
+
// Unformatted: a pure deletion, so neighbouring entries keep their layout.
|
|
73
|
+
const edits = modify(existing, keyPath, undefined, {});
|
|
74
|
+
return { change: 'removed', text: applyEdits(existing, edits), keyPath };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readConfigFile(filePath: string): string | undefined {
|
|
78
|
+
try {
|
|
79
|
+
return readFileSync(filePath, 'utf-8');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Atomic write (temp file + rename) so a crash never leaves a harness with a
|
|
88
|
+
* half-written config. Writes through a symlink (dotfile managers) and keeps
|
|
89
|
+
* the existing file mode.
|
|
90
|
+
*/
|
|
91
|
+
export function writeConfigFile(filePath: string, text: string): void {
|
|
92
|
+
let destination = filePath;
|
|
93
|
+
let mode: number | undefined;
|
|
94
|
+
try {
|
|
95
|
+
destination = realpathSync(filePath);
|
|
96
|
+
mode = statSync(destination).mode & 0o777;
|
|
97
|
+
} catch {
|
|
98
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
const temp = `${destination}.nowline-${process.pid}.tmp`;
|
|
101
|
+
writeFileSync(temp, text, 'utf-8');
|
|
102
|
+
if (mode !== undefined) chmodSync(temp, mode);
|
|
103
|
+
renameSync(temp, destination);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* jsonc-parser's formatter re-flows the whole line it edits, and appending a
|
|
108
|
+
* key to a non-empty object edits the previous sibling's line, so a user's
|
|
109
|
+
* compact `"other": { "command": "x" }` would come back expanded. For that
|
|
110
|
+
* case, take the unformatted edit and indent the new property ourselves.
|
|
111
|
+
* Every other case (replacing our own entry, creating a missing parent)
|
|
112
|
+
* only touches lines we own, so the library's formatting is fine.
|
|
113
|
+
*/
|
|
114
|
+
function insertionEdits(text: string, keyPath: string[], value: unknown): Edit[] {
|
|
115
|
+
const formatting = detectFormatting(text);
|
|
116
|
+
const raw = modify(text, keyPath, value, {});
|
|
117
|
+
const edit = raw[0];
|
|
118
|
+
if (raw.length !== 1 || edit.length !== 0 || !edit.content.startsWith(',')) {
|
|
119
|
+
return modify(text, keyPath, value, { formattingOptions: formatting });
|
|
120
|
+
}
|
|
121
|
+
const unit = formatting.insertSpaces ? ' '.repeat(formatting.tabSize ?? 2) : '\t';
|
|
122
|
+
const lineStart = text.lastIndexOf('\n', edit.offset - 1) + 1;
|
|
123
|
+
const lineIndent = /^[ \t]*/.exec(text.slice(lineStart))?.[0] ?? '';
|
|
124
|
+
const property = JSON.stringify(JSON.parse(`{${edit.content.slice(1)}}`), null, unit)
|
|
125
|
+
.split('\n')
|
|
126
|
+
.slice(1, -1)
|
|
127
|
+
.map((line) => lineIndent + line.slice(unit.length));
|
|
128
|
+
const eol = formatting.eol ?? '\n';
|
|
129
|
+
return [{ ...edit, content: `,${eol}${property.join(eol)}` }];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseDocument(text: string, filePath: string): unknown {
|
|
133
|
+
const errors: ParseError[] = [];
|
|
134
|
+
const doc = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
135
|
+
if (errors.length > 0) {
|
|
136
|
+
const first = errors[0];
|
|
137
|
+
throw new JsonConfigError(
|
|
138
|
+
`cannot parse ${filePath}: ${printParseErrorCode(first.error)} at offset ${first.offset}; fix or move the file and re-run.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (!isRecord(doc)) {
|
|
142
|
+
throw new JsonConfigError(`cannot update ${filePath}: the top level is not a JSON object.`);
|
|
143
|
+
}
|
|
144
|
+
return doc;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function valueAt(doc: unknown, keyPath: readonly string[]): unknown {
|
|
148
|
+
let node: unknown = doc;
|
|
149
|
+
for (const key of keyPath) {
|
|
150
|
+
if (!isRecord(node)) return undefined;
|
|
151
|
+
node = node[key];
|
|
152
|
+
}
|
|
153
|
+
return node;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function detectFormatting(text: string): FormattingOptions {
|
|
157
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
158
|
+
const indent = /^([ \t]+)\S/m.exec(text)?.[1];
|
|
159
|
+
if (indent?.startsWith('\t')) return { insertSpaces: false, tabSize: 1, eol };
|
|
160
|
+
return { insertSpaces: true, tabSize: indent?.length ?? 2, eol };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
164
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
168
|
+
if (a === b) return true;
|
|
169
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
170
|
+
return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
171
|
+
}
|
|
172
|
+
if (isRecord(a) && isRecord(b)) {
|
|
173
|
+
const keys = Object.keys(a);
|
|
174
|
+
return (
|
|
175
|
+
keys.length === Object.keys(b).length && keys.every((key) => deepEqual(a[key], b[key]))
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import type { HostEnv } from './host.js';
|
|
3
|
+
|
|
4
|
+
export type ServerKind = 'self' | 'npx';
|
|
5
|
+
|
|
6
|
+
/** The stdio launch line every harness config stores. */
|
|
7
|
+
export interface ServerEntry {
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedServer {
|
|
13
|
+
entry: ServerEntry;
|
|
14
|
+
/** Notices for stderr (dropped under `-q`). */
|
|
15
|
+
warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decides what `--mcp-install` registers.
|
|
20
|
+
*
|
|
21
|
+
* `self` points at the installed `nowline`. The path is absolute because GUI
|
|
22
|
+
* harnesses (Claude Desktop, Cursor, VS Code launched from the Dock) don't
|
|
23
|
+
* inherit the shell PATH, and it is the stable PATH entry rather than the
|
|
24
|
+
* realpath, so a Homebrew symlink keeps working across `brew upgrade` instead
|
|
25
|
+
* of pinning `Cellar/nowline/<version>/`.
|
|
26
|
+
*
|
|
27
|
+
* `npx` registers `npx -y @nowline/mcp`, which tracks npm independently of
|
|
28
|
+
* the CLI but needs Node visible to the harness.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveServer(
|
|
31
|
+
kind: ServerKind,
|
|
32
|
+
root: string | undefined,
|
|
33
|
+
host: HostEnv,
|
|
34
|
+
): ResolvedServer {
|
|
35
|
+
const rootArgs = root !== undefined ? ['--root', path.resolve(host.cwd, root)] : [];
|
|
36
|
+
if (kind === 'npx') {
|
|
37
|
+
// Windows GUI apps can't spawn `npx.cmd` without a shell.
|
|
38
|
+
if (host.platform === 'win32') {
|
|
39
|
+
return {
|
|
40
|
+
entry: { command: 'cmd', args: ['/c', 'npx', '-y', '@nowline/mcp', ...rootArgs] },
|
|
41
|
+
warnings: [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
entry: { command: 'npx', args: ['-y', '@nowline/mcp', ...rootArgs] },
|
|
46
|
+
warnings: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (host.isCompiledBinary) {
|
|
51
|
+
const binary = host.realpath(host.execPath);
|
|
52
|
+
const onPath = host
|
|
53
|
+
.findAllOnPath('nowline')
|
|
54
|
+
.find((candidate) => host.realpath(candidate) === binary);
|
|
55
|
+
const command = onPath ?? host.execPath;
|
|
56
|
+
const warnings: string[] = [];
|
|
57
|
+
if (onPath === undefined && /[\\/]Cellar[\\/]/.test(command)) {
|
|
58
|
+
warnings.push(
|
|
59
|
+
`warning: registered ${command}, which changes on \`brew upgrade\`; put Homebrew's bin on PATH and re-run --mcp-install.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { entry: { command, args: ['--mcp', ...rootArgs] }, warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Running under Node (npm install or a checkout): the `nowline` shim is a
|
|
66
|
+
// `#!/usr/bin/env node` script a GUI harness may not be able to run, so
|
|
67
|
+
// register the absolute Node and the script it runs.
|
|
68
|
+
const script = host.realpath(host.argv1 ?? '');
|
|
69
|
+
return {
|
|
70
|
+
entry: { command: host.execPath, args: [script, '--mcp', ...rootArgs] },
|
|
71
|
+
warnings: [
|
|
72
|
+
`warning: registered ${host.execPath}; re-run --mcp-install if you remove or switch this Node version.`,
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|