@evantahler/mcpcli 0.3.4 → 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/README.md CHANGED
@@ -34,8 +34,8 @@ mcpcli info github
34
34
  # Inspect a specific tool
35
35
  mcpcli info github search_repositories
36
36
 
37
- # Call a tool
38
- mcpcli call github search_repositories '{"query": "mcp server"}'
37
+ # Execute a tool
38
+ mcpcli exec github search_repositories '{"query": "mcp server"}'
39
39
 
40
40
  # Search tools — combines keyword and semantic matching
41
41
  mcpcli search "post a ticket to linear"
@@ -59,8 +59,8 @@ mcpcli search -q "manage pull requests"
59
59
  | `mcpcli search -q <query>` | Semantic search only |
60
60
  | `mcpcli index` | Build/rebuild the search index |
61
61
  | `mcpcli index -i` | Show index status |
62
- | `mcpcli call <server> <tool> [json]` | Validate inputs locally, then execute tool |
63
- | `mcpcli call <server>` | List available tools for a server |
62
+ | `mcpcli exec <server> <tool> [json]` | Validate inputs locally, then execute tool |
63
+ | `mcpcli exec <server>` | List available tools for a server |
64
64
  | `mcpcli auth <server>` | Authenticate with an HTTP MCP server (OAuth) |
65
65
  | `mcpcli auth <server> -s` | Check auth status and token TTL |
66
66
  | `mcpcli auth <server> -r` | Force token refresh |
@@ -193,7 +193,7 @@ Stores OAuth tokens for HTTP MCP servers. You don't edit this directly — manag
193
193
  }
194
194
  ```
195
195
 
196
- Tokens are automatically refreshed when expired (if a refresh token is available). Any command that connects to a server (`call`, `info`, `search`, listing) will refresh tokens transparently. `mcpcli auth <server> --status` shows current token state and TTL.
196
+ Tokens are automatically refreshed when expired (if a refresh token is available). Any command that connects to a server (`exec`, `info`, `search`, listing) will refresh tokens transparently. `mcpcli auth <server> --status` shows current token state and TTL.
197
197
 
198
198
  ### `search.json` — Semantic Search Index (managed automatically)
199
199
 
@@ -308,7 +308,7 @@ The index updates incrementally — only new or changed tools are re-indexed. Th
308
308
 
309
309
  ```bash
310
310
  # See full HTTP traffic
311
- mcpcli -v call arcade Gmail_WhoAmI
311
+ mcpcli -v exec arcade Gmail_WhoAmI
312
312
 
313
313
  # > POST https://api.arcade.dev/mcp/evan-coding
314
314
  # > authorization: Bearer eyJhbGci...
@@ -329,7 +329,7 @@ mcpcli -v call arcade Gmail_WhoAmI
329
329
  # { "content": [ ... ] }
330
330
 
331
331
  # Debug on stderr, clean JSON on stdout
332
- mcpcli -v call arcade Gmail_WhoAmI | jq .
332
+ mcpcli -v exec arcade Gmail_WhoAmI | jq .
333
333
 
334
334
  # Show full auth tokens (unmasked)
335
335
  mcpcli -v -S call arcade Gmail_WhoAmI
@@ -339,19 +339,19 @@ The `>` / `<` convention matches curl — `>` for request, `<` for response. The
339
339
 
340
340
  ## Input Validation
341
341
 
342
- `mcpcli call` validates tool arguments locally before sending them to the server. MCP tools advertise a JSON Schema for their inputs — mcpcli uses this to catch errors fast, without a round-trip.
342
+ `mcpcli exec` validates tool arguments locally before sending them to the server. MCP tools advertise a JSON Schema for their inputs — mcpcli uses this to catch errors fast, without a round-trip.
343
343
 
344
344
  ```bash
345
345
  # Missing required field — caught locally
346
- mcpcli call github create_issue '{"title": "bug"}'
346
+ mcpcli exec github create_issue '{"title": "bug"}'
347
347
  # => error: missing required field "repo" (github/create_issue)
348
348
 
349
349
  # Wrong type — caught locally
350
- mcpcli call github create_issue '{"repo": "foo", "title": 123}'
350
+ mcpcli exec github create_issue '{"repo": "foo", "title": 123}'
351
351
  # => error: "title" must be a string, got number (github/create_issue)
352
352
 
353
353
  # Valid — sent to server
354
- mcpcli call github create_issue '{"repo": "foo", "title": "bug"}'
354
+ mcpcli exec github create_issue '{"repo": "foo", "title": "bug"}'
355
355
  # => { ... }
356
356
  ```
357
357
 
@@ -362,7 +362,7 @@ Validation covers:
362
362
  - **Enum values** — rejects values not in the allowed set
363
363
  - **Nested objects** — validates recursively
364
364
 
365
- If a tool's `inputSchema` is unavailable (some servers don't provide one), the call proceeds without local validation.
365
+ If a tool's `inputSchema` is unavailable (some servers don't provide one), execution proceeds without local validation.
366
366
 
367
367
  ## Shell Output & Piping
368
368
 
@@ -379,26 +379,26 @@ mcpcli info github | jq '.tools[].name'
379
379
  mcpcli info github --json
380
380
  ```
381
381
 
382
- Tool call results are always JSON, designed for chaining:
382
+ Tool results are always JSON, designed for chaining:
383
383
 
384
384
  ```bash
385
385
  # Search repos and read the first result
386
- mcpcli call github search_repositories '{"query":"mcp"}' \
386
+ mcpcli exec github search_repositories '{"query":"mcp"}' \
387
387
  | jq -r '.content[0].text | fromjson | .items[0].full_name' \
388
- | xargs -I {} mcpcli call github get_file_contents '{"owner":"{}","path":"README.md"}'
388
+ | xargs -I {} mcpcli exec github get_file_contents '{"owner":"{}","path":"README.md"}'
389
389
 
390
390
  # Conditional execution
391
- mcpcli call filesystem list_directory '{"path":"."}' \
391
+ mcpcli exec filesystem list_directory '{"path":"."}' \
392
392
  | jq -e '.content[0].text | contains("package.json")' \
393
- && mcpcli call filesystem read_file '{"path":"./package.json"}'
393
+ && mcpcli exec filesystem read_file '{"path":"./package.json"}'
394
394
  ```
395
395
 
396
396
  Stdin works for tool arguments:
397
397
 
398
398
  ```bash
399
- echo '{"path":"./README.md"}' | mcpcli call filesystem read_file
399
+ echo '{"path":"./README.md"}' | mcpcli exec filesystem read_file
400
400
 
401
- cat params.json | mcpcli call server tool
401
+ cat params.json | mcpcli exec server tool
402
402
  ```
403
403
 
404
404
  ## Agent Integration
@@ -416,7 +416,7 @@ Then in any Claude Code session, the agent can use `/mcpcli` or the skill trigge
416
416
 
417
417
  1. **Search first** — `mcpcli search "<intent>"` to find relevant tools
418
418
  2. **Inspect** — `mcpcli info <server> <tool>` to get the schema before calling
419
- 3. **Call** — `mcpcli call <server> <tool> '<json>'` to execute
419
+ 3. **Execute** — `mcpcli exec <server> <tool> '<json>'` to execute
420
420
 
421
421
  This keeps tool schemas out of the system prompt entirely. The agent discovers what it needs on-demand, saving tokens and context window space.
422
422
 
@@ -432,10 +432,10 @@ To discover tools:
432
432
  mcpcli search -k "<pattern>" # keyword/glob only
433
433
  mcpcli info <server> <tool> # tool schema
434
434
 
435
- To call tools:
436
- mcpcli call <server> <tool> '<json args>'
435
+ To execute tools:
436
+ mcpcli exec <server> <tool> '<json args>'
437
437
 
438
- Always search before calling — don't assume tool names.
438
+ Always search before executing — don't assume tool names.
439
439
  ```
440
440
 
441
441
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpcli",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/mcpcli.md CHANGED
@@ -22,19 +22,19 @@ mcpcli info <server> <tool>
22
22
 
23
23
  This shows parameters, types, required fields, and the full JSON Schema.
24
24
 
25
- ## 3. Call the tool
25
+ ## 3. Execute the tool
26
26
 
27
27
  ```bash
28
- mcpcli call <server> <tool> '<json args>'
28
+ mcpcli exec <server> <tool> '<json args>'
29
29
  ```
30
30
 
31
31
  ## Rules
32
32
 
33
- - Always search before calling — don't assume tool names exist
34
- - Always inspect the schema before calling — validate you have the right arguments
33
+ - Always search before executing — don't assume tool names exist
34
+ - Always inspect the schema before executing — validate you have the right arguments
35
35
  - Use `mcpcli search -k` for exact name matching
36
36
  - Pipe results through `jq` when you need to extract specific fields
37
- - Use `-v` for verbose HTTP debugging if a call fails unexpectedly
37
+ - Use `-v` for verbose HTTP debugging if an exec fails unexpectedly
38
38
 
39
39
  ## Examples
40
40
 
@@ -46,15 +46,15 @@ mcpcli search "send a message"
46
46
  mcpcli info arcade Slack_SendMessage
47
47
 
48
48
  # Send a message
49
- mcpcli call arcade Slack_SendMessage '{"channel":"#general","message":"hello"}'
49
+ mcpcli exec arcade Slack_SendMessage '{"channel":"#general","message":"hello"}'
50
50
 
51
51
  # Chain commands — search repos and read the first result
52
- mcpcli call github search_repositories '{"query":"mcp"}' \
52
+ mcpcli exec github search_repositories '{"query":"mcp"}' \
53
53
  | jq -r '.content[0].text | fromjson | .items[0].full_name' \
54
- | xargs -I {} mcpcli call github get_file_contents '{"owner":"{}","path":"README.md"}'
54
+ | xargs -I {} mcpcli exec github get_file_contents '{"owner":"{}","path":"README.md"}'
55
55
 
56
56
  # Read args from stdin
57
- echo '{"path":"./README.md"}' | mcpcli call filesystem read_file
57
+ echo '{"path":"./README.md"}' | mcpcli exec filesystem read_file
58
58
  ```
59
59
 
60
60
  ## Authentication
@@ -76,8 +76,8 @@ mcpcli deauth <server> # remove stored auth
76
76
  | `mcpcli -d` | List with descriptions |
77
77
  | `mcpcli info <server>` | Show tools for a server |
78
78
  | `mcpcli info <server> <tool>` | Show tool schema |
79
- | `mcpcli call <server>` | List tools for a server |
80
- | `mcpcli call <server> <tool> '<json>'` | Execute a tool |
79
+ | `mcpcli exec <server>` | List tools for a server |
80
+ | `mcpcli exec <server> <tool> '<json>'` | Execute a tool |
81
81
  | `mcpcli search "<query>"` | Search tools (keyword + semantic) |
82
82
  | `mcpcli search -k "<pattern>"` | Keyword/glob search only |
83
83
  | `mcpcli search -q "<query>"` | Semantic search only |
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { program } from "commander";
4
4
  import { registerListCommand } from "./commands/list.ts";
5
5
  import { registerInfoCommand } from "./commands/info.ts";
6
6
  import { registerSearchCommand } from "./commands/search.ts";
7
- import { registerCallCommand } from "./commands/call.ts";
7
+ import { registerExecCommand } from "./commands/exec.ts";
8
8
  import { registerAuthCommand, registerDeauthCommand } from "./commands/auth.ts";
9
9
  import { registerIndexCommand } from "./commands/index.ts";
10
10
  import { registerAddCommand } from "./commands/add.ts";
@@ -27,7 +27,7 @@ program
27
27
  registerListCommand(program);
28
28
  registerInfoCommand(program);
29
29
  registerSearchCommand(program);
30
- registerCallCommand(program);
30
+ registerExecCommand(program);
31
31
  registerAuthCommand(program);
32
32
  registerDeauthCommand(program);
33
33
  registerIndexCommand(program);
@@ -2,6 +2,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
2
2
  import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3
3
  import { dim } from "ansis";
4
4
  import type { HttpServerConfig } from "../config/schemas.ts";
5
+ import { logger } from "../output/logger.ts";
5
6
 
6
7
  type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
7
8
 
@@ -51,7 +52,7 @@ function createDebugFetch(showSecrets: boolean): FetchLike {
51
52
  }
52
53
 
53
54
  function log(line: string) {
54
- process.stderr.write(line + "\n");
55
+ logger.writeRaw(line + "\n");
55
56
  }
56
57
 
57
58
  function logHeaders(
@@ -13,7 +13,7 @@ import type {
13
13
  import type { AuthFile } from "../config/schemas.ts";
14
14
  import { saveAuth } from "../config/loader.ts";
15
15
  import type { FormatOptions } from "../output/formatter.ts";
16
- import { startSpinner } from "../output/spinner.ts";
16
+ import { logger } from "../output/logger.ts";
17
17
 
18
18
  export class McpOAuthProvider implements OAuthClientProvider {
19
19
  private serverName: string;
@@ -83,10 +83,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
83
83
  async redirectToAuthorization(url: URL): Promise<void> {
84
84
  const urlStr = url.toString();
85
85
 
86
- if (process.stderr.isTTY) {
87
- const { dim } = await import("ansis");
88
- process.stderr.write(`${dim(urlStr)}\n`);
89
- }
86
+ logger.info(urlStr);
90
87
 
91
88
  const cmd =
92
89
  process.platform === "darwin"
@@ -195,6 +192,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
195
192
  });
196
193
 
197
194
  await this.saveTokens(tokens);
195
+
196
+ logger.info(`Token refreshed for "${this.serverName}"`);
198
197
  }
199
198
  }
200
199
 
@@ -269,7 +268,7 @@ export async function tryOAuthIfSupported(
269
268
  if (!oauthSupported) return false;
270
269
 
271
270
  const provider = new McpOAuthProvider({ serverName, configDir, auth });
272
- const spinner = startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
271
+ const spinner = logger.startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
273
272
  try {
274
273
  await runOAuthFlow(serverUrl, provider);
275
274
  spinner.success(`Authenticated with "${serverName}"`);
@@ -3,7 +3,7 @@ import { getContext } from "../context.ts";
3
3
  import { isHttpServer } from "../config/schemas.ts";
4
4
  import { saveAuth } from "../config/loader.ts";
5
5
  import { McpOAuthProvider, runOAuthFlow } from "../client/oauth.ts";
6
- import { startSpinner } from "../output/spinner.ts";
6
+ import { logger } from "../output/logger.ts";
7
7
  import { runIndex } from "./index.ts";
8
8
 
9
9
  export function registerAuthCommand(program: Command) {
@@ -41,7 +41,7 @@ export function registerAuthCommand(program: Command) {
41
41
  }
42
42
 
43
43
  if (options.refresh) {
44
- const spinner = startSpinner(`Refreshing token for "${server}"…`, formatOptions);
44
+ const spinner = logger.startSpinner(`Refreshing token for "${server}"…`, formatOptions);
45
45
  try {
46
46
  await provider.refreshIfNeeded(serverConfig.url);
47
47
  spinner.success(`Token refreshed for "${server}"`);
@@ -53,7 +53,7 @@ export function registerAuthCommand(program: Command) {
53
53
  }
54
54
 
55
55
  // Default: full OAuth flow
56
- const spinner = startSpinner(`Authenticating with "${server}"…`, formatOptions);
56
+ const spinner = logger.startSpinner(`Authenticating with "${server}"…`, formatOptions);
57
57
  try {
58
58
  await runOAuthFlow(serverConfig.url, provider);
59
59
  spinner.success(`Authenticated with "${server}"`);
@@ -6,12 +6,12 @@ import {
6
6
  formatServerTools,
7
7
  formatValidationErrors,
8
8
  } from "../output/formatter.ts";
9
- import { startSpinner } from "../output/spinner.ts";
9
+ import { logger } from "../output/logger.ts";
10
10
  import { validateToolInput } from "../validation/schema.ts";
11
11
 
12
- export function registerCallCommand(program: Command) {
12
+ export function registerExecCommand(program: Command) {
13
13
  program
14
- .command("call <server> [tool] [args]")
14
+ .command("exec <server> [tool] [args]")
15
15
  .description("execute a tool (omit tool name to list available tools)")
16
16
  .action(async (server: string, tool: string | undefined, argsStr: string | undefined) => {
17
17
  const { manager, formatOptions } = await getContext(program);
@@ -52,7 +52,7 @@ export function registerCallCommand(program: Command) {
52
52
  }
53
53
  }
54
54
 
55
- const spinner = startSpinner(`Calling ${server}/${tool}...`, formatOptions);
55
+ const spinner = logger.startSpinner(`Executing ${server}/${tool}...`, formatOptions);
56
56
  const result = await manager.callTool(server, tool, args);
57
57
  spinner.stop();
58
58
  console.log(formatCallResult(result, formatOptions));
@@ -1,16 +1,16 @@
1
1
  import type { Command } from "commander";
2
- import { dim, yellow } from "ansis";
2
+ import { yellow } from "ansis";
3
3
  import { getContext } from "../context.ts";
4
4
  import { buildSearchIndex } from "../search/indexer.ts";
5
5
  import { getStaleServers } from "../search/staleness.ts";
6
6
  import { saveSearchIndex } from "../config/loader.ts";
7
7
  import { formatError } from "../output/formatter.ts";
8
- import { startSpinner } from "../output/spinner.ts";
8
+ import { logger } from "../output/logger.ts";
9
9
 
10
10
  /** Run the search index build. Reusable from other commands (e.g. add). */
11
11
  export async function runIndex(program: Command): Promise<void> {
12
12
  const { config, manager, formatOptions } = await getContext(program);
13
- const spinner = startSpinner("Connecting to servers...", formatOptions);
13
+ const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
14
14
 
15
15
  try {
16
16
  const start = performance.now();
@@ -22,9 +22,7 @@ export async function runIndex(program: Command): Promise<void> {
22
22
  await saveSearchIndex(config.configDir, index);
23
23
  spinner.success(`Indexed ${index.tools.length} tools in ${elapsed}s`);
24
24
 
25
- if (process.stderr.isTTY) {
26
- process.stderr.write(dim(`Saved to ${config.configDir}/search.json\n`));
27
- }
25
+ logger.info(`Saved to ${config.configDir}/search.json`);
28
26
  } catch (err) {
29
27
  spinner.error("Indexing failed");
30
28
  console.error(formatError(String(err), formatOptions));
@@ -1,7 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { getContext } from "../context.ts";
3
3
  import { formatServerTools, formatToolSchema, formatError } from "../output/formatter.ts";
4
- import { startSpinner } from "../output/spinner.ts";
4
+ import { logger } from "../output/logger.ts";
5
5
 
6
6
  export function registerInfoCommand(program: Command) {
7
7
  program
@@ -10,7 +10,7 @@ export function registerInfoCommand(program: Command) {
10
10
  .action(async (server: string, tool: string | undefined) => {
11
11
  const { manager, formatOptions } = await getContext(program);
12
12
  const target = tool ? `${server}/${tool}` : server;
13
- const spinner = startSpinner(`Connecting to ${target}...`, formatOptions);
13
+ const spinner = logger.startSpinner(`Connecting to ${target}...`, formatOptions);
14
14
  try {
15
15
  if (tool) {
16
16
  const toolSchema = await manager.getToolSchema(server, tool);
@@ -1,12 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { getContext } from "../context.ts";
3
3
  import { formatToolList, formatError } from "../output/formatter.ts";
4
- import { startSpinner } from "../output/spinner.ts";
4
+ import { logger } from "../output/logger.ts";
5
5
 
6
6
  export function registerListCommand(program: Command) {
7
7
  program.action(async () => {
8
8
  const { manager, formatOptions } = await getContext(program);
9
- const spinner = startSpinner("Connecting to servers...", formatOptions);
9
+ const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
10
10
  try {
11
11
  const { tools, errors } = await manager.getAllTools();
12
12
  spinner.stop();
@@ -1,10 +1,9 @@
1
1
  import type { Command } from "commander";
2
- import { yellow } from "ansis";
3
2
  import { getContext } from "../context.ts";
4
3
  import { search } from "../search/index.ts";
5
4
  import { getStaleServers } from "../search/staleness.ts";
6
5
  import { formatError, formatSearchResults } from "../output/formatter.ts";
7
- import { startSpinner } from "../output/spinner.ts";
6
+ import { logger } from "../output/logger.ts";
8
7
 
9
8
  export function registerSearchCommand(program: Command) {
10
9
  program
@@ -23,14 +22,12 @@ export function registerSearchCommand(program: Command) {
23
22
 
24
23
  const stale = getStaleServers(config.searchIndex, config.servers);
25
24
  if (stale.length > 0) {
26
- process.stderr.write(
27
- yellow(
28
- `Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index\n`,
29
- ),
25
+ logger.warn(
26
+ `Warning: index has tools for removed servers: ${stale.join(", ")}. Run: mcpcli index`,
30
27
  );
31
28
  }
32
29
 
33
- const spinner = startSpinner("Searching...", formatOptions);
30
+ const spinner = logger.startSpinner("Searching...", formatOptions);
34
31
 
35
32
  try {
36
33
  const results = await search(query, config.searchIndex, {
package/src/context.ts CHANGED
@@ -3,6 +3,7 @@ import { loadConfig, type LoadConfigOptions } from "./config/loader.ts";
3
3
  import { ServerManager } from "./client/manager.ts";
4
4
  import type { Config } from "./config/schemas.ts";
5
5
  import type { FormatOptions } from "./output/formatter.ts";
6
+ import { logger } from "./output/logger.ts";
6
7
 
7
8
  export interface AppContext {
8
9
  config: Config;
@@ -46,5 +47,7 @@ export async function getContext(program: Command): Promise<AppContext> {
46
47
  showSecrets,
47
48
  };
48
49
 
50
+ logger.configure(formatOptions);
51
+
49
52
  return { config, manager, formatOptions };
50
53
  }
@@ -0,0 +1,114 @@
1
+ import { createSpinner } from "nanospinner";
2
+ import { dim, yellow, red } from "ansis";
3
+ import type { FormatOptions } from "./formatter.ts";
4
+
5
+ export interface Spinner {
6
+ update(text: string): void;
7
+ success(text?: string): void;
8
+ error(text?: string): void;
9
+ stop(): void;
10
+ }
11
+
12
+ class Logger {
13
+ private static instance: Logger;
14
+ private activeSpinner: ReturnType<typeof createSpinner> | null = null;
15
+ private formatOptions: FormatOptions = {};
16
+
17
+ private constructor() {}
18
+
19
+ static getInstance(): Logger {
20
+ if (!Logger.instance) {
21
+ Logger.instance = new Logger();
22
+ }
23
+ return Logger.instance;
24
+ }
25
+
26
+ /** Set format options (called once during context setup) */
27
+ configure(options: FormatOptions): void {
28
+ this.formatOptions = options;
29
+ }
30
+
31
+ /** Whether interactive output is suppressed (JSON mode or non-TTY stderr) */
32
+ private isSilent(): boolean {
33
+ return !!this.formatOptions.json || !(process.stderr.isTTY ?? false);
34
+ }
35
+
36
+ /** Write a line to stderr, pausing any active spinner around the write */
37
+ private writeStderr(msg: string): void {
38
+ if (this.activeSpinner) {
39
+ this.activeSpinner.clear();
40
+ process.stderr.write(msg + "\n");
41
+ this.activeSpinner.render();
42
+ } else {
43
+ process.stderr.write(msg + "\n");
44
+ }
45
+ }
46
+
47
+ /** Info-level message (dim text on stderr). Suppressed in JSON/non-TTY mode. */
48
+ info(msg: string): void {
49
+ if (this.isSilent()) return;
50
+ this.writeStderr(dim(msg));
51
+ }
52
+
53
+ /** Warning message (yellow text on stderr). Suppressed in JSON/non-TTY mode. */
54
+ warn(msg: string): void {
55
+ if (this.isSilent()) return;
56
+ this.writeStderr(yellow(msg));
57
+ }
58
+
59
+ /** Error message (red text on stderr). Always writes. */
60
+ error(msg: string): void {
61
+ this.writeStderr(red(msg));
62
+ }
63
+
64
+ /** Debug/verbose message (dim text on stderr). Only when verbose is enabled. */
65
+ debug(msg: string): void {
66
+ if (!this.formatOptions.verbose || this.isSilent()) return;
67
+ this.writeStderr(dim(msg));
68
+ }
69
+
70
+ /** Write a raw string to stderr. Spinner-aware but no formatting or newline added. */
71
+ writeRaw(msg: string): void {
72
+ if (this.activeSpinner) {
73
+ this.activeSpinner.clear();
74
+ process.stderr.write(msg);
75
+ this.activeSpinner.render();
76
+ } else {
77
+ process.stderr.write(msg);
78
+ }
79
+ }
80
+
81
+ /** Start a spinner. Returns the Spinner interface. */
82
+ startSpinner(text: string, options?: FormatOptions): Spinner {
83
+ const opts = options ?? this.formatOptions;
84
+
85
+ // No spinner in JSON/piped mode
86
+ if (opts.json || !(process.stderr.isTTY ?? false)) {
87
+ return { update() {}, success() {}, error() {}, stop() {} };
88
+ }
89
+
90
+ const spinner = createSpinner(text, { stream: process.stderr }).start();
91
+ this.activeSpinner = spinner;
92
+
93
+ return {
94
+ update: (text: string) => {
95
+ spinner.update({ text });
96
+ },
97
+ success: (text?: string) => {
98
+ spinner.success({ text });
99
+ this.activeSpinner = null;
100
+ },
101
+ error: (text?: string) => {
102
+ spinner.error({ text });
103
+ this.activeSpinner = null;
104
+ },
105
+ stop: () => {
106
+ spinner.stop();
107
+ this.activeSpinner = null;
108
+ },
109
+ };
110
+ }
111
+ }
112
+
113
+ /** The singleton logger instance */
114
+ export const logger = Logger.getInstance();
@@ -1,6 +1,7 @@
1
1
  import type { ServerManager, ToolWithServer } from "../client/manager.ts";
2
2
  import type { SearchIndex, IndexedTool } from "../config/schemas.ts";
3
3
  import { generateEmbedding } from "./semantic.ts";
4
+ import { logger } from "../output/logger.ts";
4
5
 
5
6
  /** Extract keywords from a tool name by splitting on separators and camelCase */
6
7
  export function extractKeywords(name: string): string[] {
@@ -70,7 +71,7 @@ export async function buildSearchIndex(
70
71
 
71
72
  if (errors.length > 0) {
72
73
  for (const err of errors) {
73
- process.stderr.write(`warning: ${err.server}: ${err.message}\n`);
74
+ logger.warn(`${err.server}: ${err.message}`);
74
75
  }
75
76
  }
76
77
 
@@ -1,39 +0,0 @@
1
- import { createSpinner } from "nanospinner";
2
- import type { FormatOptions } from "./formatter.ts";
3
-
4
- export interface Spinner {
5
- update(text: string): void;
6
- success(text?: string): void;
7
- error(text?: string): void;
8
- stop(): void;
9
- }
10
-
11
- /** Create a spinner that only renders in interactive mode */
12
- export function startSpinner(text: string, options: FormatOptions): Spinner {
13
- // No spinner in JSON/piped mode
14
- if (options.json || !(process.stderr.isTTY ?? false)) {
15
- return {
16
- update() {},
17
- success() {},
18
- error() {},
19
- stop() {},
20
- };
21
- }
22
-
23
- const spinner = createSpinner(text, { stream: process.stderr }).start();
24
-
25
- return {
26
- update(text: string) {
27
- spinner.update({ text });
28
- },
29
- success(text?: string) {
30
- spinner.success({ text });
31
- },
32
- error(text?: string) {
33
- spinner.error({ text });
34
- },
35
- stop() {
36
- spinner.stop();
37
- },
38
- };
39
- }