argsbarg 1.3.1 → 1.4.1

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/src/index.ts CHANGED
@@ -7,8 +7,19 @@ It gives consumers one stable import path without forcing them to know the inter
7
7
  module layout.
8
8
  */
9
9
 
10
+ export { cliInvoke } from "./invoke.ts";
11
+ export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
10
12
  export { CliContext } from "./context.ts";
11
13
  export { cliErrWithHelp, cliRun } from "./runtime";
12
14
  export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
13
- export type { CliCommand, CliHandler, CliOption, CliPositional } from "./types.ts";
15
+ export type {
16
+ CliCommand,
17
+ CliHandler,
18
+ CliInvocation,
19
+ CliMcpResource,
20
+ CliMcpServerConfig,
21
+ CliMcpToolConfig,
22
+ CliOption,
23
+ CliPositional,
24
+ } from "./types.ts";
14
25
  export { isInteractiveTty } from "./utils.ts";
package/src/invoke.ts ADDED
@@ -0,0 +1,192 @@
1
+ /*
2
+ This module invokes leaf CLI handlers without exiting the process.
3
+ It parses argv against the user schema, captures stdout/stderr, and patches
4
+ process.exit so MCP tool calls can run handlers repeatedly.
5
+ */
6
+
7
+ import { CliContext } from "./context.ts";
8
+ import { parse, postParseValidate, ParseKind } from "./parse.ts";
9
+ import { CliCommand } from "./types.ts";
10
+ import { format } from "node:util";
11
+
12
+ /** Outcome of a non-exiting CLI invocation. */
13
+ export type CliInvokeKind = "ok" | "help" | "schema" | "error";
14
+
15
+ /** Result of cliInvoke: captured output and exit metadata without process.exit. */
16
+ export interface CliInvokeResult {
17
+ /** Invocation outcome. */
18
+ kind: CliInvokeKind;
19
+ /** Simulated exit code. */
20
+ exitCode: number;
21
+ /** Captured stdout during handler execution. */
22
+ stdout: string;
23
+ /** Captured stderr during handler execution. */
24
+ stderr: string;
25
+ /** Set when kind === "error" (parse/validation message). */
26
+ errorMsg?: string;
27
+ }
28
+
29
+ /** Thrown internally when a patched process.exit fires during handler execution. */
30
+ class CliInvokeExit extends Error {
31
+ /** Exit code passed to process.exit. */
32
+ readonly code: number;
33
+
34
+ /** Creates an exit signal with the given status code. */
35
+ constructor(code: number) {
36
+ super(`process.exit(${code})`);
37
+ this.name = "CliInvokeExit";
38
+ this.code = code;
39
+ }
40
+ }
41
+
42
+ /** Looks up a subcommand or routing node by `key`. */
43
+ function findChild(cmds: CliCommand[], name: string): CliCommand | undefined {
44
+ return cmds.find((c) => c.key === name);
45
+ }
46
+
47
+ /**
48
+ * Parses argv against the user root, runs the leaf handler, and returns captured output.
49
+ * Never calls process.exit.
50
+ */
51
+ export async function cliInvoke(root: CliCommand, argv: string[]): Promise<CliInvokeResult> {
52
+ let pr = parse(root, argv);
53
+ pr = postParseValidate(root, pr);
54
+
55
+ if (pr.kind === ParseKind.Help) {
56
+ return {
57
+ kind: "help",
58
+ exitCode: 1,
59
+ stdout: "",
60
+ stderr: "",
61
+ errorMsg: "Help is not available via MCP tool calls.",
62
+ };
63
+ }
64
+
65
+ if (pr.kind === ParseKind.Schema) {
66
+ return {
67
+ kind: "schema",
68
+ exitCode: 1,
69
+ stdout: "",
70
+ stderr: "",
71
+ errorMsg: "Schema export is not available via MCP tool calls.",
72
+ };
73
+ }
74
+
75
+ if (pr.kind === ParseKind.Error) {
76
+ return {
77
+ kind: "error",
78
+ exitCode: 1,
79
+ stdout: "",
80
+ stderr: pr.errorMsg,
81
+ errorMsg: pr.errorMsg,
82
+ };
83
+ }
84
+
85
+ let current: CliCommand = root;
86
+ for (const seg of pr.path) {
87
+ const ch = findChild(current.commands ?? [], seg);
88
+ if (!ch) {
89
+ return {
90
+ kind: "error",
91
+ exitCode: 1,
92
+ stdout: "",
93
+ stderr: "Internal error: missing handler for path.",
94
+ errorMsg: "Internal error: missing handler for path.",
95
+ };
96
+ }
97
+ current = ch;
98
+ }
99
+
100
+ if (!("handler" in current) || !current.handler) {
101
+ return {
102
+ kind: "error",
103
+ exitCode: 1,
104
+ stdout: "",
105
+ stderr: "Internal error: missing handler for path.",
106
+ errorMsg: "Internal error: missing handler for path.",
107
+ };
108
+ }
109
+
110
+ const handler = current.handler;
111
+ const ctx = new CliContext(root.key, pr.path, pr.args, pr.opts, root, "mcp");
112
+
113
+ let stdout = "";
114
+ let stderr = "";
115
+ const origExit = process.exit;
116
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
117
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
118
+ const origConsoleLog = console.log;
119
+ const origConsoleError = console.error;
120
+ const origConsoleInfo = console.info;
121
+ const origConsoleWarn = console.warn;
122
+
123
+ process.exit = ((code?: number) => {
124
+ throw new CliInvokeExit(code ?? 0);
125
+ }) as typeof process.exit;
126
+
127
+ process.stdout.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
128
+ stdout += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
129
+ if (typeof args[0] === "function") {
130
+ (args[0] as () => void)();
131
+ }
132
+ return true;
133
+ }) as typeof process.stdout.write;
134
+
135
+ process.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
136
+ stderr += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
137
+ if (typeof args[0] === "function") {
138
+ (args[0] as () => void)();
139
+ }
140
+ return true;
141
+ }) as typeof process.stderr.write;
142
+
143
+ console.log = (...args: unknown[]) => {
144
+ stdout += format(...args) + "\n";
145
+ };
146
+ console.info = (...args: unknown[]) => {
147
+ stdout += format(...args) + "\n";
148
+ };
149
+ console.warn = (...args: unknown[]) => {
150
+ stderr += format(...args) + "\n";
151
+ };
152
+ console.error = (...args: unknown[]) => {
153
+ stderr += format(...args) + "\n";
154
+ };
155
+
156
+ try {
157
+ await Promise.resolve(handler(ctx));
158
+ return { kind: "ok", exitCode: 0, stdout, stderr };
159
+ } catch (err) {
160
+ if (err instanceof CliInvokeExit) {
161
+ if (err.code === 0) {
162
+ return { kind: "ok", exitCode: 0, stdout, stderr };
163
+ }
164
+ const msg = stderr.trim() || `Exit code ${err.code}`;
165
+ return { kind: "error", exitCode: err.code, stdout, stderr, errorMsg: msg };
166
+ }
167
+ if (err instanceof Error) {
168
+ return {
169
+ kind: "error",
170
+ exitCode: 1,
171
+ stdout,
172
+ stderr: err.message + "\n",
173
+ errorMsg: err.message,
174
+ };
175
+ }
176
+ return {
177
+ kind: "error",
178
+ exitCode: 1,
179
+ stdout,
180
+ stderr: "Unknown error\n",
181
+ errorMsg: "Unknown error",
182
+ };
183
+ } finally {
184
+ process.exit = origExit;
185
+ process.stdout.write = origStdoutWrite;
186
+ process.stderr.write = origStderrWrite;
187
+ console.log = origConsoleLog;
188
+ console.error = origConsoleError;
189
+ console.info = origConsoleInfo;
190
+ console.warn = origConsoleWarn;
191
+ }
192
+ }
package/src/mcp/env.ts ADDED
@@ -0,0 +1,99 @@
1
+ /*
2
+ This module bootstraps process.env for MCP servers from login shell and .env files.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ /** Parses `env` stdout from a login shell into a key/value map. */
9
+ export function captureShellEnv(shell: string): Record<string, string> {
10
+ const result = spawnSync(shell, ["-l", "-c", "env"], {
11
+ encoding: "utf8",
12
+ timeout: 5000,
13
+ });
14
+ if (result.error || result.status !== 0) {
15
+ return {};
16
+ }
17
+ const env: Record<string, string> = {};
18
+ for (const line of result.stdout.split("\n")) {
19
+ const eq = line.indexOf("=");
20
+ if (eq > 0) {
21
+ env[line.slice(0, eq)] = line.slice(eq + 1);
22
+ }
23
+ }
24
+ return env;
25
+ }
26
+
27
+ /** Merges captured shell env into process.env (PATH merged; host wins for other keys). */
28
+ export function applyShellEnv(env: Record<string, string>): void {
29
+ for (const [key, val] of Object.entries(env)) {
30
+ if (key === "PATH") {
31
+ const existing = process.env.PATH ?? "";
32
+ const existingParts = new Set(existing.split(":"));
33
+ const shellOnly = val.split(":").filter((p) => p.length > 0 && !existingParts.has(p));
34
+ if (shellOnly.length > 0) {
35
+ process.env.PATH = [...shellOnly, existing].join(":");
36
+ }
37
+ } else if (process.env[key] === undefined) {
38
+ process.env[key] = val;
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Loads a .env file into process.env (always overwrites). Warns on stderr if missing. */
44
+ export function loadEnvFile(envFile: string): void {
45
+ const resolved = envFile.startsWith("~")
46
+ ? envFile.replace("~", process.env.HOME ?? "")
47
+ : envFile;
48
+ let text: string;
49
+ try {
50
+ text = readFileSync(resolved, "utf8");
51
+ } catch {
52
+ process.stderr.write(`[argsbarg] envFile not found: ${envFile}\n`);
53
+ return;
54
+ }
55
+ for (const line of text.split("\n")) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed || trimmed.startsWith("#")) {
58
+ continue;
59
+ }
60
+ const eq = trimmed.indexOf("=");
61
+ if (eq < 1) {
62
+ continue;
63
+ }
64
+ const key = trimmed.slice(0, eq).trim();
65
+ let val = trimmed.slice(eq + 1).trim();
66
+ if (
67
+ (val.startsWith('"') && val.endsWith('"')) ||
68
+ (val.startsWith("'") && val.endsWith("'"))
69
+ ) {
70
+ val = val.slice(1, -1);
71
+ }
72
+ if (key) {
73
+ process.env[key] = val;
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Applies mcpServer shellEnv and envFile bootstrap in order. */
79
+ export function bootstrapMcpEnv(config: {
80
+ shellEnv?: boolean | string;
81
+ envFile?: string;
82
+ }): void {
83
+ const shellEnvCfg = config.shellEnv;
84
+ if (shellEnvCfg) {
85
+ const shell =
86
+ typeof shellEnvCfg === "string"
87
+ ? shellEnvCfg
88
+ : (process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"));
89
+ const captured = captureShellEnv(shell);
90
+ if (Object.keys(captured).length === 0) {
91
+ process.stderr.write(`[argsbarg] shellEnv: failed to capture shell environment from ${shell}\n`);
92
+ } else {
93
+ applyShellEnv(captured);
94
+ }
95
+ }
96
+ if (config.envFile) {
97
+ loadEnvFile(config.envFile);
98
+ }
99
+ }
@@ -0,0 +1,57 @@
1
+ /*
2
+ This module builds MCP tools/call success results from captured handler output.
3
+ */
4
+
5
+ /** Text content block in an MCP tool result. */
6
+ export interface McpTextContent {
7
+ type: "text";
8
+ text: string;
9
+ }
10
+
11
+ /** Successful MCP tools/call result payload. */
12
+ export interface McpToolCallSuccess {
13
+ content: McpTextContent[];
14
+ structuredContent?: unknown;
15
+ isError: false;
16
+ }
17
+
18
+ /** Parses stdout as JSON when the full trimmed string is valid JSON. */
19
+ function parseStructuredStdout(stdout: string): unknown | undefined {
20
+ const trimmed = stdout.trim();
21
+ if (trimmed.length === 0) {
22
+ return undefined;
23
+ }
24
+ try {
25
+ return JSON.parse(trimmed) as unknown;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Builds a successful tools/call result from captured handler stdout/stderr.
33
+ * stderr is a second content block when non-empty; structuredContent is set when stdout is JSON.
34
+ */
35
+ export function buildToolCallSuccess(stdout: string, stderr: string): McpToolCallSuccess {
36
+ const content: McpTextContent[] = [];
37
+ if (stdout.length > 0) {
38
+ content.push({ type: "text", text: stdout });
39
+ }
40
+ const errText = stderr.trim();
41
+ if (errText.length > 0) {
42
+ if (content.length === 0) {
43
+ content.push({ type: "text", text: "" });
44
+ }
45
+ content.push({ type: "text", text: errText });
46
+ }
47
+ if (content.length === 0) {
48
+ content.push({ type: "text", text: "" });
49
+ }
50
+
51
+ const structuredContent = parseStructuredStdout(stdout);
52
+ const result: McpToolCallSuccess = { content, isError: false };
53
+ if (structuredContent !== undefined) {
54
+ result.structuredContent = structuredContent;
55
+ }
56
+ return result;
57
+ }
@@ -0,0 +1,238 @@
1
+ /*
2
+ This module implements the MCP JSON-RPC server over stdio: initialize, tools,
3
+ resources, and ping. Responses are newline-delimited JSON on stdout only.
4
+ */
5
+
6
+ import { cliInvoke } from "../invoke.ts";
7
+ import { CliCommand } from "../types.ts";
8
+ import { buildToolCallSuccess } from "./result.ts";
9
+ import {
10
+ allMcpResources,
11
+ collectMcpTools,
12
+ mcpToolCallToArgv,
13
+ resolveMcpServerInfo,
14
+ } from "./tools.ts";
15
+
16
+ const MCP_PROTOCOL_VERSION = "2024-11-05";
17
+
18
+ /** JSON-RPC request shape from stdin. */
19
+ interface JsonRpcRequest {
20
+ jsonrpc?: string;
21
+ id?: string | number | null;
22
+ method?: string;
23
+ params?: unknown;
24
+ }
25
+
26
+ /** Writes a JSON-RPC response line to stdout. */
27
+ function writeResponse(msg: Record<string, unknown>): void {
28
+ process.stdout.write(JSON.stringify(msg) + "\n");
29
+ }
30
+
31
+ /** Writes a JSON-RPC error response. */
32
+ function writeError(id: string | number | null | undefined, code: number, message: string): void {
33
+ if (id === undefined) {
34
+ return;
35
+ }
36
+ writeResponse({
37
+ jsonrpc: "2.0",
38
+ id,
39
+ error: { code, message },
40
+ });
41
+ }
42
+
43
+ /** Handles one NDJSON request line. */
44
+ async function handleRequestLine(root: CliCommand, line: string): Promise<void> {
45
+ let req: JsonRpcRequest;
46
+ try {
47
+ req = JSON.parse(line) as JsonRpcRequest;
48
+ } catch {
49
+ return;
50
+ }
51
+
52
+ const id = req.id;
53
+ const hasId = id !== undefined;
54
+
55
+ if (req.jsonrpc !== "2.0") {
56
+ if (hasId) {
57
+ writeError(id, -32600, "Invalid Request");
58
+ }
59
+ return;
60
+ }
61
+
62
+ const method = req.method ?? "";
63
+ const params = (req.params ?? {}) as Record<string, unknown>;
64
+
65
+ if (method === "notifications/initialized") {
66
+ return;
67
+ }
68
+
69
+ if (!hasId) {
70
+ return;
71
+ }
72
+
73
+ try {
74
+ if (method === "initialize") {
75
+ const info = resolveMcpServerInfo(root);
76
+ writeResponse({
77
+ jsonrpc: "2.0",
78
+ id,
79
+ result: {
80
+ protocolVersion: MCP_PROTOCOL_VERSION,
81
+ capabilities: { tools: {}, resources: {} },
82
+ serverInfo: { name: info.name, version: info.version },
83
+ },
84
+ });
85
+ return;
86
+ }
87
+
88
+ if (method === "ping") {
89
+ writeResponse({ jsonrpc: "2.0", id, result: {} });
90
+ return;
91
+ }
92
+
93
+ if (method === "tools/list") {
94
+ const tools = collectMcpTools(root).map((t) => ({
95
+ name: t.name,
96
+ description: t.description,
97
+ inputSchema: t.inputSchema,
98
+ }));
99
+ writeResponse({ jsonrpc: "2.0", id, result: { tools } });
100
+ return;
101
+ }
102
+
103
+ if (method === "tools/call") {
104
+ const name = params.name;
105
+ if (typeof name !== "string") {
106
+ writeError(id, -32602, "Invalid params: name required");
107
+ return;
108
+ }
109
+ const rawArgs = params.arguments;
110
+ if (rawArgs !== undefined && (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs))) {
111
+ writeError(id, -32602, "Invalid params: arguments must be an object");
112
+ return;
113
+ }
114
+ const toolList = collectMcpTools(root);
115
+ const tool = toolList.find((t) => t.name === name);
116
+ if (!tool) {
117
+ writeError(id, -32602, `Unknown tool: ${name}`);
118
+ return;
119
+ }
120
+ const missingEnv = (tool.leaf.mcpTool?.requiresEnv ?? []).filter((k) => !process.env[k]);
121
+ if (missingEnv.length > 0) {
122
+ writeResponse({
123
+ jsonrpc: "2.0",
124
+ id,
125
+ result: {
126
+ content: [{ type: "text", text: `Missing required env: ${missingEnv.join(", ")}` }],
127
+ isError: true,
128
+ },
129
+ });
130
+ return;
131
+ }
132
+ const argvResult = mcpToolCallToArgv(root, tool, (rawArgs ?? {}) as Record<string, unknown>);
133
+ if ("error" in argvResult) {
134
+ writeResponse({
135
+ jsonrpc: "2.0",
136
+ id,
137
+ result: {
138
+ content: [{ type: "text", text: argvResult.error }],
139
+ isError: true,
140
+ },
141
+ });
142
+ return;
143
+ }
144
+ const invokeResult = await cliInvoke(root, argvResult);
145
+ if (invokeResult.kind === "ok" && invokeResult.exitCode === 0) {
146
+ writeResponse({
147
+ jsonrpc: "2.0",
148
+ id,
149
+ result: buildToolCallSuccess(invokeResult.stdout, invokeResult.stderr),
150
+ });
151
+ return;
152
+ }
153
+ const errText = invokeResult.stderr.trim() || invokeResult.errorMsg || `Exit code ${invokeResult.exitCode}`;
154
+ writeResponse({
155
+ jsonrpc: "2.0",
156
+ id,
157
+ result: {
158
+ content: [{ type: "text", text: errText }],
159
+ isError: true,
160
+ },
161
+ });
162
+ return;
163
+ }
164
+
165
+ if (method === "resources/list") {
166
+ const resources = allMcpResources(root).map((r) => ({
167
+ uri: r.uri,
168
+ name: r.name,
169
+ description: r.description,
170
+ mimeType: r.mimeType,
171
+ }));
172
+ writeResponse({ jsonrpc: "2.0", id, result: { resources } });
173
+ return;
174
+ }
175
+
176
+ if (method === "resources/read") {
177
+ const uri = params.uri;
178
+ if (typeof uri !== "string") {
179
+ writeError(id, -32602, "Invalid params: uri required");
180
+ return;
181
+ }
182
+ const all = allMcpResources(root);
183
+ const found = all.find((r) => r.uri === uri);
184
+ if (!found) {
185
+ writeError(id, -32602, `Unknown resource: ${uri}`);
186
+ return;
187
+ }
188
+ let text: string;
189
+ try {
190
+ text = found.load();
191
+ } catch (err) {
192
+ const message = err instanceof Error ? err.message : String(err);
193
+ writeError(id, -32603, `Resource load failed: ${message}`);
194
+ return;
195
+ }
196
+ writeResponse({
197
+ jsonrpc: "2.0",
198
+ id,
199
+ result: {
200
+ contents: [
201
+ {
202
+ uri: found.uri,
203
+ mimeType: found.mimeType,
204
+ text,
205
+ },
206
+ ],
207
+ },
208
+ });
209
+ return;
210
+ }
211
+
212
+ writeError(id, -32601, "Method not found");
213
+ } catch (err) {
214
+ const message = err instanceof Error ? err.message : "Internal error";
215
+ writeError(id, -32603, message);
216
+ }
217
+ }
218
+
219
+ /** Runs the MCP NDJSON read loop on stdin until EOF. */
220
+ export async function mcpServeStdioLoop(root: CliCommand): Promise<void> {
221
+ let buffer = "";
222
+ for await (const chunk of Bun.stdin.stream()) {
223
+ buffer += new TextDecoder().decode(chunk);
224
+ let nl: number;
225
+ while ((nl = buffer.indexOf("\n")) !== -1) {
226
+ const line = buffer.slice(0, nl).trim();
227
+ buffer = buffer.slice(nl + 1);
228
+ if (line.length === 0) {
229
+ continue;
230
+ }
231
+ await handleRequestLine(root, line);
232
+ }
233
+ }
234
+ const trailing = buffer.trim();
235
+ if (trailing.length > 0) {
236
+ await handleRequestLine(root, trailing);
237
+ }
238
+ }