@evantahler/mcpx 0.21.6 → 0.21.8

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.
@@ -199,6 +199,8 @@ mcpx deauth <server> # remove stored auth
199
199
  | `-d, --with-descriptions` | Include tool descriptions in list output |
200
200
  | `-c, --config <path>` | Specify config file location |
201
201
  | `-N, --no-interactive` | Decline server elicitation requests (for scripted usage) |
202
+ | `--no-color` | Disable ANSI colors (also honored via `NO_COLOR=1`) |
203
+ | `--force-color` | Force ANSI colors even when piped (also `FORCE_COLOR=1`) |
202
204
  | `-S, --show-secrets` | Show full auth tokens in verbose output (unmasked) |
203
205
  | `-l, --log-level <level>` | Minimum server log level to display (default: `warning`) |
204
206
 
@@ -193,6 +193,8 @@ mcpx deauth <server> # remove stored auth
193
193
  | `-d, --with-descriptions` | Include tool descriptions in list output |
194
194
  | `-c, --config <path>` | Specify config file location |
195
195
  | `-N, --no-interactive` | Decline server elicitation requests (for scripted usage) |
196
+ | `--no-color` | Disable ANSI colors (also honored via `NO_COLOR=1`) |
197
+ | `--force-color` | Force ANSI colors even when piped (also `FORCE_COLOR=1`) |
196
198
  | `-S, --show-secrets` | Show full auth tokens in verbose output (unmasked) |
197
199
  | `-l, --log-level <level>` | Minimum server log level to display (default: `warning`) |
198
200
 
package/README.md CHANGED
@@ -134,8 +134,24 @@ mcpx search -n 5 "manage pull requests"
134
134
  | `-j, --json` | Force JSON output (default when piped) |
135
135
  | `-F, --format <format>` | Output format: `json` or `markdown` |
136
136
  | `-N, --no-interactive` | Decline server elicitation requests (for scripted usage) |
137
+ | `--no-color` | Disable ANSI colors in output |
138
+ | `--force-color` | Force ANSI colors even when piped |
137
139
  | `-l, --log-level <level>` | Minimum server log level to display (default: `warning`) |
138
140
 
141
+ ### Output & colors
142
+
143
+ mcpx auto-detects whether stdout/stderr are interactive and adapts:
144
+
145
+ - TTY → colored, formatted output (tables, headers, badges).
146
+ - Non-TTY / piped → JSON.
147
+
148
+ Color emission honors the standard env vars and matching flags:
149
+
150
+ - `NO_COLOR=1` or `--no-color` — disable ANSI colors.
151
+ - `FORCE_COLOR=1` or `--force-color` — enable ANSI colors even when piped.
152
+ - `--json` / `-j` — JSON output, no colors.
153
+ - `CI=true` — treated as non-interactive (spinners off).
154
+
139
155
  Server log messages (`notifications/message`) are displayed on stderr with level-appropriate coloring. Valid levels (in ascending severity): `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. When a server declares logging capability, mcpx sends `logging/setLevel` to request messages at the configured threshold and above.
140
156
 
141
157
  ## Managing Servers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.21.6",
3
+ "version": "0.21.8",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,8 +60,5 @@
60
60
  "@biomejs/biome": "^2.4.14",
61
61
  "@types/bun": "latest",
62
62
  "typescript": "^6"
63
- },
64
- "peerDependencies": {
65
- "typescript": "^6"
66
63
  }
67
64
  }
package/src/cli.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { bold, cyan, dim, green, yellow } from "ansis";
3
+ // MUST be first: translates --no-color / --json / --force-color argv flags into
4
+ // NO_COLOR / FORCE_COLOR env vars before any ansis-using module loads. ansis
5
+ // decides its color level at module load and cannot be reconfigured afterwards.
6
+ import "./output/early-env.ts";
7
+
4
8
  import { program } from "commander";
5
9
  import pkg from "../package.json";
6
10
  import { registerAddCommand } from "./commands/add.ts";
@@ -22,11 +26,27 @@ import { registerSkillCommand } from "./commands/skill.ts";
22
26
  import { registerTaskCommand } from "./commands/task.ts";
23
27
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
24
28
  import { logger } from "./output/logger.ts";
29
+ import { theme } from "./output/theme.ts";
30
+ import { detectMode, setMode } from "./output/tty.ts";
25
31
  import { ExitError, installSignalHandlers } from "./shutdown.ts";
26
32
  import { maybeCheckForUpdate } from "./update/background.ts";
27
33
 
28
34
  installSignalHandlers();
29
35
 
36
+ // Resolve output mode (TTY/color/json/verbose) from env + raw argv before
37
+ // commander parses anything, so the help screen and any early prints honor
38
+ // --no-color / --json / NO_COLOR / FORCE_COLOR. The mode is frozen via setMode
39
+ // and propagated to ansis.level so direct ansis calls also respect it.
40
+ const rawArgv = process.argv.slice(2);
41
+ setMode(
42
+ detectMode({
43
+ json: rawArgv.includes("--json") || rawArgv.includes("-j"),
44
+ verbose: rawArgv.includes("--verbose") || rawArgv.includes("-v"),
45
+ noColor: rawArgv.includes("--no-color"),
46
+ forceColor: rawArgv.includes("--force-color"),
47
+ }),
48
+ );
49
+
30
50
  program
31
51
  .name("mcpx")
32
52
  .description("A command-line interface for MCP servers. curl for MCP.")
@@ -38,6 +58,8 @@ program
38
58
  .option("-v, --verbose", "show HTTP details and JSON-RPC protocol messages")
39
59
  .option("-S, --show-secrets", "show full auth tokens in verbose output")
40
60
  .option("-N, --no-interactive", "decline server elicitation requests")
61
+ .option("--no-color", "disable ANSI color output (also honored via NO_COLOR=1)")
62
+ .option("--force-color", "force ANSI color output even when piped (also honored via FORCE_COLOR=1)")
41
63
  .option(
42
64
  "-l, --log-level <level>",
43
65
  "minimum server log level (debug|info|notice|warning|error|critical|alert|emergency)",
@@ -45,12 +67,12 @@ program
45
67
  );
46
68
 
47
69
  program.configureHelp({
48
- styleTitle: (str) => bold(str),
49
- styleCommandText: (str) => cyan(str),
50
- styleSubcommandText: (str) => cyan(str),
51
- styleOptionText: (str) => yellow(str),
52
- styleArgumentText: (str) => green(str),
53
- styleDescriptionText: (str) => dim(str),
70
+ styleTitle: (str) => theme.tool(str),
71
+ styleCommandText: (str) => theme.path(str),
72
+ styleSubcommandText: (str) => theme.path(str),
73
+ styleOptionText: (str) => theme.warn(str),
74
+ styleArgumentText: (str) => theme.param(str),
75
+ styleDescriptionText: (str) => theme.muted(str),
54
76
  });
55
77
 
56
78
  registerListCommand(program);
@@ -88,7 +110,7 @@ for (let i = 0; i < cliArgs.length; i++) {
88
110
  break;
89
111
  }
90
112
  if (firstCommand && !knownCommands.has(firstCommand)) {
91
- console.error(`error: unknown command '${firstCommand}'. See 'mcpx --help'.`);
113
+ logger.error(`error: unknown command '${firstCommand}'. See 'mcpx --help'.`);
92
114
  process.exit(1);
93
115
  }
94
116
 
@@ -1,7 +1,7 @@
1
1
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
2
  import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
3
- import { cyan, dim, green, red, yellow } from "ansis";
4
3
  import { logger } from "../output/logger.ts";
4
+ import { glyph, theme } from "../output/theme.ts";
5
5
 
6
6
  export interface TraceOptions {
7
7
  json: boolean;
@@ -70,14 +70,14 @@ function logOutgoing(
70
70
 
71
71
  if ("id" in message && "method" in message) {
72
72
  const m = message as { id: string | number; method: string; params?: unknown };
73
- const arrow = isTTY ? cyan("→") : "→";
73
+ const arrow = isTTY ? glyph.arrowOut : "→";
74
74
  const detail = summarizeParams(m.method, m.params);
75
75
  const detailStr = detail ? ` ${detail}` : "";
76
- logger.writeRaw(`${arrow} ${dim(`${m.method} (id: ${m.id})${detailStr}`)}\n`);
76
+ logger.writeRaw(`${arrow} ${theme.muted(`${m.method} (id: ${m.id})${detailStr}`)}\n`);
77
77
  } else if ("method" in message) {
78
78
  const m = message as { method: string };
79
- const arrow = isTTY ? cyan("→") : "→";
80
- logger.writeRaw(`${arrow} ${dim(m.method)}\n`);
79
+ const arrow = isTTY ? glyph.arrowOut : "→";
80
+ logger.writeRaw(`${arrow} ${theme.muted(m.method)}\n`);
81
81
  }
82
82
  }
83
83
 
@@ -109,11 +109,11 @@ function logIncoming(
109
109
  }
110
110
 
111
111
  const isError = m.error !== undefined;
112
- const arrow = isTTY ? (isError ? red("←") : green("←")) : "←";
112
+ const arrow = isTTY ? (isError ? glyph.arrowErr : glyph.arrowIn) : "←";
113
113
  const timing = elapsed !== undefined ? ` [${elapsed}ms]` : "";
114
114
  const summary = summarizeResult(method, m.result);
115
115
  const summaryStr = summary ? ` — ${summary}` : "";
116
- logger.writeRaw(`${arrow} ${dim(`${method} (id: ${m.id})${timing}${summaryStr}`)}\n`);
116
+ logger.writeRaw(`${arrow} ${theme.muted(`${method} (id: ${m.id})${timing}${summaryStr}`)}\n`);
117
117
  } else if ("method" in message) {
118
118
  // Notification (incoming)
119
119
  const m = message as { method: string; params?: unknown };
@@ -123,9 +123,9 @@ function logIncoming(
123
123
  return;
124
124
  }
125
125
 
126
- const arrow = isTTY ? yellow("←") : "←";
126
+ const arrow = isTTY ? glyph.arrowNote : "←";
127
127
  const params = m.params ? ` ${JSON.stringify(m.params)}` : "";
128
- logger.writeRaw(`${arrow} ${dim(`${m.method}${params}`)}\n`);
128
+ logger.writeRaw(`${arrow} ${theme.muted(`${m.method}${params}`)}\n`);
129
129
  }
130
130
  }
131
131
 
package/src/context.ts CHANGED
@@ -38,7 +38,7 @@ export async function getContext(program: Command): Promise<AppContext> {
38
38
 
39
39
  const formatFlag = opts.format as string | undefined;
40
40
  if (formatFlag && !VALID_FORMATS.includes(formatFlag as OutputFormat)) {
41
- console.error(`error: Invalid format "${formatFlag}". Use: ${VALID_FORMATS.join(", ")}`);
41
+ logger.error(`error: Invalid format "${formatFlag}". Use: ${VALID_FORMATS.join(", ")}`);
42
42
  process.exit(1);
43
43
  }
44
44
  const format = formatFlag as OutputFormat | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Early env munging — MUST be imported FIRST in src/cli.ts, before any other
3
+ * module that pulls in ansis (transitively or directly). ansis decides its
4
+ * color level at module load by inspecting NO_COLOR / FORCE_COLOR / TTY, and
5
+ * the decision cannot be changed afterwards. So we translate --no-color /
6
+ * --json / --force-color command-line flags into env vars here, before ansis
7
+ * loads.
8
+ */
9
+
10
+ const argv = process.argv.slice(2);
11
+ const hasFlag = (flag: string): boolean => argv.includes(flag);
12
+ const hasFlagOr = (flags: readonly string[]): boolean => flags.some((f) => argv.includes(f));
13
+
14
+ if (hasFlag("--no-color") || hasFlagOr(["--json", "-j"])) {
15
+ // Setting NO_COLOR to any non-empty string disables ansis colors.
16
+ // (We do not touch NO_COLOR if it was already set externally.)
17
+ if (!process.env.NO_COLOR || process.env.NO_COLOR === "") {
18
+ process.env.NO_COLOR = "1";
19
+ }
20
+ }
21
+
22
+ if (hasFlag("--force-color")) {
23
+ if (!process.env.FORCE_COLOR || process.env.FORCE_COLOR === "") {
24
+ process.env.FORCE_COLOR = "1";
25
+ }
26
+ }
@@ -1,5 +1,11 @@
1
1
  import type { FormatOptions } from "./formatter.ts";
2
- import { isInteractive } from "./formatter.ts";
2
+ import { isInteractive as ttyIsInteractive } from "./tty.ts";
3
+
4
+ /** TTY-aware interactivity check, with an explicit JSON override. */
5
+ function isInteractive(options: FormatOptions): boolean {
6
+ if (options.json) return false;
7
+ return ttyIsInteractive();
8
+ }
3
9
 
4
10
  /**
5
11
  * Format output with automatic JSON/interactive branching.
@@ -1,5 +1,6 @@
1
- import ansis, { dim } from "ansis";
1
+ import ansis from "ansis";
2
2
  import { wrapDescription } from "./formatter.ts";
3
+ import { theme } from "./theme.ts";
3
4
 
4
5
  export interface Column<T> {
5
6
  value: (item: T) => string;
@@ -29,28 +30,30 @@ function getTerminalWidth(): number | undefined {
29
30
  */
30
31
  export function formatTable<T>(items: T[], options: TableOptions<T>): string {
31
32
  if (items.length === 0) {
32
- return dim(options.emptyMessage ?? "No items found");
33
+ return theme.muted(options.emptyMessage ?? "No items found");
33
34
  }
34
35
 
35
36
  const sep = options.separator ?? " ";
36
37
  const termWidth = getTerminalWidth();
37
38
 
38
- // Calculate max width for each column
39
- const maxWidths = options.columns.map((col) => Math.max(...items.map((item) => col.value(item).length)));
39
+ // Calculate max width for each column (account for any ANSI from the value
40
+ // fn though most value fns return plain strings, theme.pill* return ANSI).
41
+ const maxWidths = options.columns.map((col) => Math.max(...items.map((item) => visibleLength(col.value(item)))));
40
42
 
41
43
  return items
42
44
  .map((item) => {
43
45
  const parts = options.columns.map((col, i) => {
44
46
  const raw = col.value(item);
45
- const pad = maxWidths[i]! - raw.length;
46
- return col.style(raw) + " ".repeat(Math.max(0, pad));
47
+ const styled = col.style(raw);
48
+ const pad = (maxWidths[i] ?? 0) - visibleLength(raw);
49
+ return styled + " ".repeat(Math.max(0, pad));
47
50
  });
48
51
  const prefix = parts.join(sep);
49
52
 
50
53
  const desc = options.description?.(item);
51
54
  if (desc) {
52
55
  const pw = visibleLength(prefix) + sep.length;
53
- const formatted = termWidth != null ? wrapDescription(desc, pw, termWidth) : dim(desc);
56
+ const formatted = termWidth != null ? wrapDescription(desc, pw, termWidth) : theme.muted(desc);
54
57
  return `${prefix}${sep}${formatted}`;
55
58
  }
56
59
 
@@ -1,10 +1,12 @@
1
- import ansis, { bold, cyan, dim, green, red, yellow } from "ansis";
1
+ import ansis from "ansis";
2
2
  import type { PromptWithServer, ResourceWithServer, ToolWithServer } from "../client/manager.ts";
3
3
  import type { Prompt, Resource, Tool } from "../config/schemas.ts";
4
4
  import type { SearchResult } from "../search/index.ts";
5
5
  import type { ValidationError } from "../validation/schema.ts";
6
6
  import { formatOutput } from "./format-output.ts";
7
7
  import { formatTable } from "./format-table.ts";
8
+ import { glyph, theme, underline } from "./theme.ts";
9
+ import { isInteractive as ttyIsInteractive } from "./tty.ts";
8
10
 
9
11
  export const VALID_FORMATS = ["json", "markdown"] as const;
10
12
 
@@ -29,7 +31,7 @@ export interface UnifiedItem {
29
31
  /** Check if stdout is a TTY (interactive terminal) */
30
32
  export function isInteractive(options: FormatOptions): boolean {
31
33
  if (options.json) return false;
32
- return process.stdout.isTTY ?? false;
34
+ return ttyIsInteractive();
33
35
  }
34
36
 
35
37
  /** Get terminal width, or undefined if not a TTY. Subtracts 1 for safety margin. */
@@ -77,8 +79,8 @@ function wrapLines(text: string, maxWidth: number): string[] {
77
79
 
78
80
  /**
79
81
  * Word-wrap a description string to fit within the available terminal width.
80
- * Returns dim()-wrapped text with continuation lines indented to prefixWidth.
81
- * @param text - raw description text (before dim())
82
+ * Returns theme.muted()-wrapped text with continuation lines indented to prefixWidth.
83
+ * @param text - raw description text (before theme.muted())
82
84
  * @param prefixWidth - visible character width of everything before the description
83
85
  * @param termWidth - terminal width in columns
84
86
  */
@@ -90,16 +92,16 @@ export function wrapDescription(text: string, prefixWidth: number, termWidth: nu
90
92
  const fallbackIndent = Math.min(prefixWidth, 4);
91
93
  const fallbackAvail = termWidth - fallbackIndent;
92
94
  if (fallbackAvail < 20) {
93
- return dim(text.length > termWidth ? `${text.slice(0, termWidth - 3)}...` : text);
95
+ return theme.muted(text.length > termWidth ? `${text.slice(0, termWidth - 3)}...` : text);
94
96
  }
95
97
  const wrapped = wrapLines(text, fallbackAvail);
96
98
  const indent = " ".repeat(fallbackIndent);
97
- return wrapped.map((l) => `\n${indent}${dim(l)}`).join("");
99
+ return wrapped.map((l) => `\n${indent}${theme.muted(l)}`).join("");
98
100
  }
99
101
 
100
102
  const wrapped = wrapLines(text, available);
101
103
  const indent = " ".repeat(prefixWidth);
102
- return wrapped.map((l, i) => (i === 0 ? dim(l) : `\n${indent}${dim(l)}`)).join("");
104
+ return wrapped.map((l, i) => (i === 0 ? theme.muted(l) : `\n${indent}${theme.muted(l)}`)).join("");
103
105
  }
104
106
 
105
107
  export interface ServerOverview {
@@ -129,47 +131,55 @@ export function formatServerOverview(overview: ServerOverview, options: FormatOp
129
131
  () => {
130
132
  const lines: string[] = [];
131
133
 
132
- // Header: server name + version
133
- const header = cyan.bold(overview.serverName);
134
+ // Header: server name + version, with a dim underline
135
+ const header = theme.server(overview.serverName);
136
+ let headerLine: string;
137
+ let headerVisible: number;
134
138
  if (overview.version) {
135
- lines.push(`${header} ${dim(`v${overview.version.version}`)} ${dim(`(${overview.version.name})`)}`);
139
+ const versionStr = `v${overview.version.version}`;
140
+ const nameStr = `(${overview.version.name})`;
141
+ headerLine = `${header} ${theme.muted(versionStr)} ${theme.muted(nameStr)}`;
142
+ headerVisible = overview.serverName.length + 2 + versionStr.length + 2 + nameStr.length;
136
143
  } else {
137
- lines.push(header);
144
+ headerLine = header;
145
+ headerVisible = overview.serverName.length;
138
146
  }
147
+ lines.push(headerLine);
148
+ lines.push(underline(headerVisible));
139
149
 
140
150
  // Capabilities
141
151
  if (overview.capabilities) {
142
152
  lines.push("");
143
- lines.push(bold("Capabilities:"));
153
+ lines.push(theme.tool("Capabilities:"));
144
154
  const caps = overview.capabilities;
145
155
  const present = KNOWN_CAPABILITIES.filter((k) => k in caps);
146
156
  const absent = KNOWN_CAPABILITIES.filter((k) => !(k in caps));
147
- for (const k of present) lines.push(` ${green("✓")} ${k}`);
148
- for (const k of absent) lines.push(` ${dim("✗")} ${dim(k)}`);
157
+ for (const k of present) lines.push(` ${glyph.ok} ${k}`);
158
+ for (const k of absent) lines.push(` ${theme.muted("✗")} ${theme.muted(k)}`);
149
159
  }
150
160
 
151
161
  // Instructions
152
162
  if (overview.instructions) {
153
163
  lines.push("");
154
- lines.push(bold("Instructions:"));
155
- lines.push(` ${dim(overview.instructions)}`);
164
+ lines.push(theme.tool("Instructions:"));
165
+ lines.push(` ${theme.muted(overview.instructions)}`);
156
166
  }
157
167
 
158
168
  // Tools
159
169
  lines.push("");
160
170
  if (overview.tools.length === 0) {
161
- lines.push(`${bold("Tools:")} ${dim("none")}`);
171
+ lines.push(`${theme.tool("Tools:")} ${theme.muted("none")}`);
162
172
  } else {
163
- lines.push(bold(`Tools (${overview.tools.length}):`));
173
+ lines.push(theme.tool(`Tools (${overview.tools.length}):`));
164
174
  const maxName = Math.max(...overview.tools.map((t) => t.name.length));
165
175
  const termWidth = getTerminalWidth();
166
176
  for (let i = 0; i < overview.tools.length; i++) {
167
177
  const t = overview.tools[i]!;
168
178
  if (i > 0) lines.push("");
169
- const name = ` ${bold(t.name.padEnd(maxName))}`;
179
+ const name = ` ${theme.tool(t.name.padEnd(maxName))}`;
170
180
  if (t.description) {
171
181
  const pw = visibleLength(name) + 2;
172
- const desc = termWidth != null ? wrapDescription(t.description, pw, termWidth) : dim(t.description);
182
+ const desc = termWidth != null ? wrapDescription(t.description, pw, termWidth) : theme.muted(t.description);
173
183
  lines.push(`${name} ${desc}`);
174
184
  } else {
175
185
  lines.push(name);
@@ -182,7 +192,7 @@ export function formatServerOverview(overview: ServerOverview, options: FormatOp
182
192
  counts.push(`Resources: ${overview.resourceCount}`);
183
193
  counts.push(`Prompts: ${overview.promptCount}`);
184
194
  lines.push("");
185
- lines.push(dim(counts.join(" | ")));
195
+ lines.push(theme.muted(counts.join(" | ")));
186
196
 
187
197
  return lines.join("\n");
188
198
  },
@@ -201,8 +211,8 @@ export function formatToolList(tools: ToolWithServer[], options: FormatOptions):
201
211
  () =>
202
212
  formatTable(tools, {
203
213
  columns: [
204
- { value: (t) => t.server, style: cyan },
205
- { value: (t) => t.tool.name, style: bold },
214
+ { value: (t) => t.server, style: theme.path },
215
+ { value: (t) => t.tool.name, style: theme.tool },
206
216
  ],
207
217
  description: options.withDescriptions ? (t) => t.tool.description : undefined,
208
218
  emptyMessage: "No tools found",
@@ -220,11 +230,11 @@ export function formatServerTools(serverName: string, tools: Tool[], options: Fo
220
230
  },
221
231
  () => {
222
232
  if (tools.length === 0) {
223
- return dim(`No tools found for ${serverName}`);
233
+ return theme.muted(`No tools found for ${serverName}`);
224
234
  }
225
- const header = cyan.bold(serverName);
235
+ const header = theme.server(serverName);
226
236
  const body = formatTable(tools, {
227
- columns: [{ value: (t) => ` ${t.name}`, style: bold }],
237
+ columns: [{ value: (t) => ` ${t.name}`, style: theme.tool }],
228
238
  description: (t) => t.description,
229
239
  });
230
240
  return `${header}\n${body}`;
@@ -244,10 +254,12 @@ export function formatToolSchema(serverName: string, tool: Tool, options: Format
244
254
  },
245
255
  () => {
246
256
  const lines: string[] = [];
247
- lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
248
- if (tool.description) lines.push(dim(tool.description));
257
+ const headerText = `${serverName}/${tool.name}`;
258
+ lines.push(`${theme.path(serverName)}/${theme.tool(tool.name)}`);
259
+ lines.push(underline(headerText.length));
260
+ if (tool.description) lines.push(theme.muted(tool.description));
249
261
  lines.push("");
250
- lines.push(bold("Input Schema:"));
262
+ lines.push(theme.tool("Input Schema:"));
251
263
  lines.push(formatSchema(tool.inputSchema, 2));
252
264
  return lines.join("\n");
253
265
  },
@@ -262,16 +274,16 @@ function formatSchema(schema: Tool["inputSchema"], indent: number): string {
262
274
  const required = new Set(schema.required ?? []);
263
275
 
264
276
  if (Object.keys(properties).length === 0) {
265
- return `${pad}${dim("(no parameters)")}`;
277
+ return `${pad}${theme.muted("(no parameters)")}`;
266
278
  }
267
279
 
268
280
  return Object.entries(properties)
269
281
  .map(([name, prop]) => {
270
282
  const p = prop as Record<string, unknown>;
271
283
  const type = (p.type as string) ?? "any";
272
- const req = required.has(name) ? red("*") : "";
273
- const desc = p.description ? ` ${dim(String(p.description))}` : "";
274
- return `${pad}${green(name)}${req} ${dim(`(${type})`)}${desc}`;
284
+ const req = required.has(name) ? theme.error("*") : "";
285
+ const desc = p.description ? ` ${theme.muted(String(p.description))}` : "";
286
+ return `${pad}${theme.success(name)}${req} ${theme.muted(`(${type})`)}${desc}`;
275
287
  })
276
288
  .join("\n");
277
289
  }
@@ -288,15 +300,17 @@ export function formatToolHelp(serverName: string, tool: Tool, options: FormatOp
288
300
  },
289
301
  () => {
290
302
  const lines: string[] = [];
291
- lines.push(`${cyan(serverName)}/${bold(tool.name)}`);
292
- if (tool.description) lines.push(dim(tool.description));
303
+ const headerText = `${serverName}/${tool.name}`;
304
+ lines.push(`${theme.path(serverName)}/${theme.tool(tool.name)}`);
305
+ lines.push(underline(headerText.length));
306
+ if (tool.description) lines.push(theme.muted(tool.description));
293
307
  lines.push("");
294
- lines.push(bold("Parameters:"));
308
+ lines.push(theme.tool("Parameters:"));
295
309
  lines.push(formatSchema(tool.inputSchema, 2));
296
310
  const example = generateExample(tool.inputSchema);
297
311
  lines.push("");
298
- lines.push(bold("Example:"));
299
- lines.push(dim(` mcpx call ${serverName} ${tool.name} '${JSON.stringify(example)}'`));
312
+ lines.push(theme.tool("Example:"));
313
+ lines.push(theme.muted(` mcpx call ${serverName} ${tool.name} '${JSON.stringify(example)}'`));
300
314
  return lines.join("\n");
301
315
  },
302
316
  options,
@@ -458,7 +472,7 @@ function resetUrlPlaceholders(): void {
458
472
 
459
473
  function restoreUrlPlaceholders(ansiOutput: string): string {
460
474
  for (const [token, url] of urlMap) {
461
- ansiOutput = ansiOutput.replace(token, `\x1b[34m\x1b[4m${url}\x1b[24m\x1b[39m`);
475
+ ansiOutput = ansiOutput.replace(token, theme.url(url));
462
476
  }
463
477
  return ansiOutput;
464
478
  }
@@ -675,8 +689,8 @@ export function formatValidationErrors(
675
689
  return formatOutput(
676
690
  { error: "validation", server: serverName, tool: toolName, details: errors },
677
691
  () => {
678
- const header = `${red("error:")} invalid arguments for ${cyan(serverName)}/${bold(toolName)}`;
679
- const details = errors.map((e) => ` ${yellow(e.path)}: ${e.message}`).join("\n");
692
+ const header = `${glyph.fail} ${theme.error("error:")} invalid arguments for ${theme.path(serverName)}/${theme.tool(toolName)}`;
693
+ const details = errors.map((e) => ` ${theme.warn(e.path)}: ${e.message}`).join("\n");
680
694
  return `${header}\n${details}`;
681
695
  },
682
696
  options,
@@ -689,7 +703,7 @@ export function formatSearchResults(results: SearchResult[], options: FormatOpti
689
703
  results,
690
704
  () => {
691
705
  if (results.length === 0) {
692
- return dim("No matching tools found");
706
+ return theme.muted("No matching tools found");
693
707
  }
694
708
 
695
709
  const termWidth = getTerminalWidth();
@@ -697,14 +711,14 @@ export function formatSearchResults(results: SearchResult[], options: FormatOpti
697
711
 
698
712
  return results
699
713
  .map((r) => {
700
- const header = `${cyan(r.server)} ${bold(r.tool)} ${yellow(r.score.toFixed(2))}`;
714
+ const header = `${theme.path(r.server)} ${theme.tool(r.tool)} ${theme.warn(r.score.toFixed(2))}`;
701
715
  const fullDesc = r.description
702
716
  .split("\n")
703
717
  .map((l) => l.trim())
704
718
  .filter((l) => l.length > 0)
705
719
  .join(" ");
706
720
  const indent = " ".repeat(descIndent);
707
- const desc = termWidth != null ? wrapDescription(fullDesc, descIndent, termWidth) : dim(fullDesc);
721
+ const desc = termWidth != null ? wrapDescription(fullDesc, descIndent, termWidth) : theme.muted(fullDesc);
708
722
  return `${header}\n${indent}${desc}`;
709
723
  })
710
724
  .join("\n\n");
@@ -725,8 +739,8 @@ export function formatResourceList(resources: ResourceWithServer[], options: For
725
739
  () =>
726
740
  formatTable(resources, {
727
741
  columns: [
728
- { value: (r) => r.server, style: cyan },
729
- { value: (r) => r.resource.uri, style: bold },
742
+ { value: (r) => r.server, style: theme.path },
743
+ { value: (r) => r.resource.uri, style: theme.tool },
730
744
  ],
731
745
  description: options.withDescriptions ? (r) => r.resource.description : undefined,
732
746
  emptyMessage: "No resources found",
@@ -749,11 +763,11 @@ export function formatServerResources(serverName: string, resources: Resource[],
749
763
  },
750
764
  () => {
751
765
  if (resources.length === 0) {
752
- return dim(`No resources found for ${serverName}`);
766
+ return theme.muted(`No resources found for ${serverName}`);
753
767
  }
754
- const header = cyan.bold(serverName);
768
+ const header = theme.server(serverName);
755
769
  const body = formatTable(resources, {
756
- columns: [{ value: (r) => ` ${r.uri}`, style: bold }],
770
+ columns: [{ value: (r) => ` ${r.uri}`, style: theme.tool }],
757
771
  description: (r) => r.description,
758
772
  });
759
773
  return `${header}\n${body}`;
@@ -775,17 +789,19 @@ export function formatResourceContents(
775
789
  const contents =
776
790
  (result as { contents?: Array<{ text?: string; blob?: string; mimeType?: string }> })?.contents ?? [];
777
791
  const lines: string[] = [];
778
- lines.push(`${cyan(serverName)}/${bold(uri)}`);
792
+ const headerText = `${serverName}/${uri}`;
793
+ lines.push(`${theme.path(serverName)}/${theme.resource(uri)}`);
794
+ lines.push(underline(headerText.length));
779
795
  lines.push("");
780
796
 
781
797
  if (contents.length === 0) {
782
- lines.push(dim("(empty)"));
798
+ lines.push(theme.muted("(empty)"));
783
799
  } else {
784
800
  for (const item of contents) {
785
801
  if (item.text !== undefined) {
786
802
  lines.push(item.text);
787
803
  } else if (item.blob !== undefined) {
788
- lines.push(dim(`<binary blob, ${item.blob.length} bytes base64>`));
804
+ lines.push(theme.muted(`<binary blob, ${item.blob.length} bytes base64>`));
789
805
  }
790
806
  }
791
807
  }
@@ -807,8 +823,8 @@ export function formatPromptList(prompts: PromptWithServer[], options: FormatOpt
807
823
  () =>
808
824
  formatTable(prompts, {
809
825
  columns: [
810
- { value: (p) => p.server, style: cyan },
811
- { value: (p) => p.prompt.name, style: bold },
826
+ { value: (p) => p.server, style: theme.path },
827
+ { value: (p) => p.prompt.name, style: theme.prompt },
812
828
  ],
813
829
  description: options.withDescriptions ? (p) => p.prompt.description : undefined,
814
830
  emptyMessage: "No prompts found",
@@ -830,23 +846,23 @@ export function formatServerPrompts(serverName: string, prompts: Prompt[], optio
830
846
  },
831
847
  () => {
832
848
  if (prompts.length === 0) {
833
- return dim(`No prompts found for ${serverName}`);
849
+ return theme.muted(`No prompts found for ${serverName}`);
834
850
  }
835
851
 
836
- const header = cyan.bold(serverName);
852
+ const header = theme.server(serverName);
837
853
  const maxName = Math.max(...prompts.map((p) => p.name.length));
838
854
  const termWidth = getTerminalWidth();
839
855
 
840
856
  const lines = prompts.map((p) => {
841
- const name = ` ${bold(p.name.padEnd(maxName))}`;
857
+ const name = ` ${theme.prompt(p.name.padEnd(maxName))}`;
842
858
  const args =
843
859
  p.arguments && p.arguments.length > 0
844
- ? ` ${dim(`(${p.arguments.map((a) => (a.required ? a.name : `[${a.name}]`)).join(", ")})`)}`
860
+ ? ` ${theme.muted(`(${p.arguments.map((a) => (a.required ? a.name : `[${a.name}]`)).join(", ")})`)}`
845
861
  : "";
846
862
  if (p.description) {
847
863
  const prefix = `${name}${args}`;
848
864
  const pw = visibleLength(prefix) + 2;
849
- const desc = termWidth != null ? wrapDescription(p.description, pw, termWidth) : dim(p.description);
865
+ const desc = termWidth != null ? wrapDescription(p.description, pw, termWidth) : theme.muted(p.description);
850
866
  return `${prefix} ${desc}`;
851
867
  }
852
868
  return `${name}${args}`;
@@ -873,11 +889,13 @@ export function formatPromptMessages(
873
889
  messages?: Array<{ role: string; content: { type: string; text?: string } }>;
874
890
  };
875
891
  const lines: string[] = [];
876
- lines.push(`${cyan(serverName)}/${bold(name)}`);
877
- if (r.description) lines.push(dim(r.description));
892
+ const headerText = `${serverName}/${name}`;
893
+ lines.push(`${theme.path(serverName)}/${theme.prompt(name)}`);
894
+ lines.push(underline(headerText.length));
895
+ if (r.description) lines.push(theme.muted(r.description));
878
896
  lines.push("");
879
897
  for (const msg of r.messages ?? []) {
880
- lines.push(`${bold(msg.role)}:`);
898
+ lines.push(`${theme.tool(msg.role)}:`);
881
899
  if (msg.content.text !== undefined) {
882
900
  lines.push(` ${msg.content.text}`);
883
901
  }
@@ -890,11 +908,12 @@ export function formatPromptMessages(
890
908
 
891
909
  /** Format a unified list of tools, resources, and prompts across servers */
892
910
  export function formatUnifiedList(items: UnifiedItem[], options: FormatOptions): string {
893
- const typeLabel = (t: string) => {
894
- if (t === "tool") return green(t);
895
- if (t === "resource") return cyan(t);
896
- return yellow(t);
911
+ const typePill = (i: UnifiedItem): string => {
912
+ if (i.type === "tool") return theme.pillTool(i.type);
913
+ if (i.type === "resource") return theme.pillResource(i.type);
914
+ return theme.pillPrompt(i.type);
897
915
  };
916
+ const nameStyle = (i: UnifiedItem) => (i.type === "prompt" ? theme.prompt(i.name) : theme.tool(i.name));
898
917
 
899
918
  return formatOutput(
900
919
  items.map((i) => ({
@@ -906,9 +925,9 @@ export function formatUnifiedList(items: UnifiedItem[], options: FormatOptions):
906
925
  () =>
907
926
  formatTable(items, {
908
927
  columns: [
909
- { value: (i) => i.server, style: cyan },
910
- { value: (i) => i.type, style: typeLabel },
911
- { value: (i) => i.name, style: bold },
928
+ { value: (i) => typePill(i), style: (s) => s },
929
+ { value: (i) => i.server, style: theme.path },
930
+ { value: (i) => nameStyle(i), style: (s) => s },
912
931
  ],
913
932
  description: options.withDescriptions ? (i) => i.description : undefined,
914
933
  emptyMessage: "No tools, resources, or prompts found",
@@ -928,27 +947,28 @@ export function formatTaskStatus(
928
947
  const statusColor = (s: string) => {
929
948
  switch (s) {
930
949
  case "completed":
931
- return green(s);
950
+ return theme.success(s);
932
951
  case "working":
933
- return yellow(s);
952
+ return theme.warn(s);
934
953
  case "failed":
935
954
  case "cancelled":
936
- return red(s);
955
+ return theme.error(s);
937
956
  case "input_required":
938
- return yellow(s);
957
+ return theme.warn(s);
939
958
  default:
940
959
  return s;
941
960
  }
942
961
  };
943
962
 
944
963
  const lines: string[] = [];
945
- lines.push(`${bold("Task:")} ${cyan(task.taskId)}`);
946
- lines.push(`${bold("Status:")} ${statusColor(task.status)}`);
947
- if (task.statusMessage) lines.push(`${bold("Message:")} ${dim(String(task.statusMessage))}`);
948
- if (task.createdAt) lines.push(`${bold("Created:")} ${dim(String(task.createdAt))}`);
949
- if (task.lastUpdatedAt) lines.push(`${bold("Updated:")} ${dim(String(task.lastUpdatedAt))}`);
950
- if (task.ttl != null) lines.push(`${bold("TTL:")} ${dim(`${String(task.ttl)}ms`)}`);
951
- if (task.pollInterval != null) lines.push(`${bold("Poll interval:")} ${dim(`${String(task.pollInterval)}ms`)}`);
964
+ lines.push(`${theme.tool("Task:")} ${theme.path(task.taskId)}`);
965
+ lines.push(`${theme.tool("Status:")} ${statusColor(task.status)}`);
966
+ if (task.statusMessage) lines.push(`${theme.tool("Message:")} ${theme.muted(String(task.statusMessage))}`);
967
+ if (task.createdAt) lines.push(`${theme.tool("Created:")} ${theme.muted(String(task.createdAt))}`);
968
+ if (task.lastUpdatedAt) lines.push(`${theme.tool("Updated:")} ${theme.muted(String(task.lastUpdatedAt))}`);
969
+ if (task.ttl != null) lines.push(`${theme.tool("TTL:")} ${theme.muted(`${String(task.ttl)}ms`)}`);
970
+ if (task.pollInterval != null)
971
+ lines.push(`${theme.tool("Poll interval:")} ${theme.muted(`${String(task.pollInterval)}ms`)}`);
952
972
  return lines.join("\n");
953
973
  },
954
974
  options,
@@ -965,18 +985,18 @@ export function formatTasksList(
965
985
  { tasks, ...(nextCursor ? { nextCursor } : {}) },
966
986
  () => {
967
987
  if (tasks.length === 0) {
968
- return dim("No tasks found");
988
+ return theme.muted("No tasks found");
969
989
  }
970
990
 
971
991
  const statusColor = (s: string) => {
972
992
  switch (s) {
973
993
  case "completed":
974
- return green(s.padEnd(14));
994
+ return theme.success(s.padEnd(14));
975
995
  case "working":
976
- return yellow(s.padEnd(14));
996
+ return theme.warn(s.padEnd(14));
977
997
  case "failed":
978
998
  case "cancelled":
979
- return red(s.padEnd(14));
999
+ return theme.error(s.padEnd(14));
980
1000
  default:
981
1001
  return s.padEnd(14);
982
1002
  }
@@ -985,15 +1005,15 @@ export function formatTasksList(
985
1005
  const maxId = Math.max(...tasks.map((t) => t.taskId.length));
986
1006
 
987
1007
  const lines = tasks.map((t) => {
988
- const id = cyan(t.taskId.padEnd(maxId));
1008
+ const id = theme.path(t.taskId.padEnd(maxId));
989
1009
  const status = statusColor(t.status);
990
- const updated = t.lastUpdatedAt ? dim(String(t.lastUpdatedAt)) : "";
1010
+ const updated = t.lastUpdatedAt ? theme.muted(String(t.lastUpdatedAt)) : "";
991
1011
  return `${id} ${status} ${updated}`;
992
1012
  });
993
1013
 
994
1014
  if (nextCursor) {
995
1015
  lines.push("");
996
- lines.push(dim(`Next cursor: ${nextCursor}`));
1016
+ lines.push(theme.muted(`Next cursor: ${nextCursor}`));
997
1017
  }
998
1018
 
999
1019
  return lines.join("\n");
@@ -1009,12 +1029,13 @@ export function formatTaskCreated(
1009
1029
  ): string {
1010
1030
  return formatOutput(
1011
1031
  { task },
1012
- () => `${green("Task created:")} ${cyan(task.taskId)} ${dim(`(status: ${task.status})`)}`,
1032
+ () =>
1033
+ `${glyph.ok} ${theme.success("Task created:")} ${theme.taskId(task.taskId)} ${theme.muted(`(status: ${task.status})`)}`,
1013
1034
  options,
1014
1035
  );
1015
1036
  }
1016
1037
 
1017
1038
  /** Format an error message */
1018
1039
  export function formatError(message: string, options: FormatOptions): string {
1019
- return formatOutput({ error: message }, () => `${red("error:")} ${message}`, options);
1040
+ return formatOutput({ error: message }, () => `${glyph.fail} ${theme.error("error:")} ${message}`, options);
1020
1041
  }
@@ -1,6 +1,7 @@
1
- import { dim, red, yellow } from "ansis";
2
1
  import { createSpinner } from "nanospinner";
3
2
  import type { FormatOptions } from "./formatter.ts";
3
+ import { glyph, styleStackLine, theme } from "./theme.ts";
4
+ import { detectMode, isJson, isVerbose, setMode, useSpinner } from "./tty.ts";
4
5
 
5
6
  /** MCP log levels ordered by severity (RFC 5424) */
6
7
  const LOG_LEVELS = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] as const;
@@ -15,14 +16,14 @@ function logLevelIndex(level: string): number {
15
16
  function colorForLevel(level: string): (s: string) => string {
16
17
  switch (level) {
17
18
  case "debug":
18
- return dim;
19
+ return theme.muted;
19
20
  case "warning":
20
- return yellow;
21
+ return theme.warn;
21
22
  case "error":
22
23
  case "critical":
23
24
  case "alert":
24
25
  case "emergency":
25
- return red;
26
+ return theme.error;
26
27
  default:
27
28
  return (s: string) => s;
28
29
  }
@@ -49,14 +50,21 @@ class Logger {
49
50
  return Logger.instance;
50
51
  }
51
52
 
52
- /** Set format options (called once during context setup) */
53
+ /** Set format options (called once during context setup). Also re-resolves the
54
+ * output mode so verbose/json flags parsed by commander update the global mode. */
53
55
  configure(options: FormatOptions): void {
54
56
  this.formatOptions = options;
57
+ setMode(
58
+ detectMode({
59
+ json: !!options.json,
60
+ verbose: !!options.verbose,
61
+ }),
62
+ );
55
63
  }
56
64
 
57
65
  /** Whether interactive output is suppressed (JSON mode or non-TTY stderr) */
58
66
  private isSilent(): boolean {
59
- return !!this.formatOptions.json || !(process.stderr.isTTY ?? false);
67
+ return isJson() || !(process.stderr.isTTY ?? false);
60
68
  }
61
69
 
62
70
  /** Write a line to stderr, pausing any active spinner around the write */
@@ -73,24 +81,37 @@ class Logger {
73
81
  /** Info-level message (dim text on stderr). Suppressed in JSON/non-TTY mode. */
74
82
  info(msg: string): void {
75
83
  if (this.isSilent()) return;
76
- this.writeStderr(dim(msg));
84
+ this.writeStderr(theme.muted(msg));
85
+ }
86
+
87
+ /** Success message (green ✓ on stderr). Suppressed in JSON/non-TTY mode. */
88
+ success(msg: string): void {
89
+ if (this.isSilent()) return;
90
+ this.writeStderr(`${glyph.ok} ${msg}`);
77
91
  }
78
92
 
79
93
  /** Warning message (yellow text on stderr). Suppressed in JSON/non-TTY mode. */
80
94
  warn(msg: string): void {
81
95
  if (this.isSilent()) return;
82
- this.writeStderr(yellow(msg));
96
+ this.writeStderr(`${glyph.warn} ${theme.warn(msg)}`);
83
97
  }
84
98
 
85
99
  /** Error message (red text on stderr). Always writes. */
86
100
  error(msg: string): void {
87
- this.writeStderr(red(msg));
101
+ // If the message looks like a stack trace, style each frame line.
102
+ if (msg.includes("\n") && /\n\s*at\s/.test(msg)) {
103
+ const [first, ...rest] = msg.split("\n");
104
+ const styled = [theme.error(first ?? ""), ...rest.map(styleStackLine)].join("\n");
105
+ this.writeStderr(styled);
106
+ return;
107
+ }
108
+ this.writeStderr(theme.error(msg));
88
109
  }
89
110
 
90
111
  /** Debug/verbose message (dim text on stderr). Only when verbose is enabled. */
91
112
  debug(msg: string): void {
92
- if (!this.formatOptions.verbose || this.isSilent()) return;
93
- this.writeStderr(dim(msg));
113
+ if (!isVerbose() || this.isSilent()) return;
114
+ this.writeStderr(theme.muted(msg));
94
115
  }
95
116
 
96
117
  /** Write a raw string to stderr. Spinner-aware but no formatting or newline added. */
@@ -109,7 +130,7 @@ class Logger {
109
130
  const minLevel = this.formatOptions.logLevel ?? "warning";
110
131
  if (logLevelIndex(params.level) < logLevelIndex(minLevel)) return;
111
132
 
112
- if (this.formatOptions.json) {
133
+ if (isJson()) {
113
134
  // JSON mode: structured object to stderr
114
135
  const obj = { server: serverName, ...params };
115
136
  process.stderr.write(`${JSON.stringify(obj)}\n`);
@@ -126,11 +147,9 @@ class Logger {
126
147
  }
127
148
 
128
149
  /** Start a spinner. Returns the Spinner interface. */
129
- startSpinner(text: string, options?: FormatOptions): Spinner {
130
- const opts = options ?? this.formatOptions;
131
-
150
+ startSpinner(text: string, _options?: FormatOptions): Spinner {
132
151
  // No spinner in JSON/piped/verbose mode — verbose writeRaw output conflicts with spinner rendering
133
- if (opts.json || opts.verbose || !(process.stderr.isTTY ?? false)) {
152
+ if (!useSpinner() || !(process.stderr.isTTY ?? false)) {
134
153
  return { update() {}, success() {}, error() {}, stop() {} };
135
154
  }
136
155
 
@@ -0,0 +1,123 @@
1
+ import ansis, { bold, cyan, dim, green, magenta, red, yellow } from "ansis";
2
+ import { useColor } from "./tty.ts";
3
+
4
+ /**
5
+ * Semantic color tokens for the mcpx CLI. Output modules consume these names
6
+ * (theme.server, theme.success) rather than raw ansis colors so the palette
7
+ * can shift in one place.
8
+ *
9
+ * Each token also gates on useColor() at call time, so flags parsed after
10
+ * module load (e.g. --no-color via context.ts) still suppress output. Direct
11
+ * ansis usage elsewhere is governed by the env vars early-env.ts sets before
12
+ * ansis loads.
13
+ */
14
+
15
+ const ESC_URL_OPEN = "\x1b[34m\x1b[4m";
16
+ const ESC_URL_CLOSE = "\x1b[24m\x1b[39m";
17
+
18
+ const wrap = (fn: (s: string) => string) => (s: string) => (useColor() ? fn(s) : s);
19
+
20
+ // Entity tokens — used for nouns the user references
21
+ export const server = wrap(cyan.bold);
22
+ export const tool = wrap(bold);
23
+ export const resource = wrap(cyan);
24
+ export const prompt = wrap(magenta);
25
+ export const taskId = wrap(cyan);
26
+
27
+ // Semantic tokens — used for status / severity
28
+ export const success = wrap(green);
29
+ export const warn = wrap(yellow);
30
+ export const error = wrap(red);
31
+ export const muted = wrap(dim);
32
+
33
+ // Detail tokens — finer-grained styling
34
+ export const path = wrap(cyan);
35
+ export const param = wrap(green);
36
+ export const scalar = wrap(yellow);
37
+ export const required = wrap(red);
38
+ export const url = (s: string): string => (useColor() ? `${ESC_URL_OPEN}${s}${ESC_URL_CLOSE}` : s);
39
+ export const label = (s: string): string => (useColor() ? bold(`${s}:`) : `${s}:`);
40
+
41
+ // Pills — high-contrast inverted badges for type labels (drop into tables)
42
+ const pillize = (bgFn: (s: string) => string) => (s: string) =>
43
+ useColor() ? bgFn(` ${s.toUpperCase()} `) : ` ${s.toUpperCase()} `;
44
+ export const pillTool = pillize((s) => ansis.bgGreen.black(s));
45
+ export const pillResource = pillize((s) => ansis.bgCyan.black(s));
46
+ export const pillPrompt = pillize((s) => ansis.bgMagenta.black(s));
47
+
48
+ // Glyph tokens — single-character status indicators. Defined as getters so
49
+ // they re-evaluate the color decision on access.
50
+ export const glyph = {
51
+ get ok() {
52
+ return useColor() ? green("✓") : "✓";
53
+ },
54
+ get fail() {
55
+ return useColor() ? red("✗") : "✗";
56
+ },
57
+ get warn() {
58
+ return useColor() ? yellow("⚠") : "⚠";
59
+ },
60
+ get info() {
61
+ return useColor() ? dim("ℹ") : "ℹ";
62
+ },
63
+ get bullet() {
64
+ return useColor() ? dim("•") : "•";
65
+ },
66
+ get arrowOut() {
67
+ return useColor() ? cyan("→") : "→";
68
+ },
69
+ get arrowIn() {
70
+ return useColor() ? green("←") : "←";
71
+ },
72
+ get arrowErr() {
73
+ return useColor() ? red("←") : "←";
74
+ },
75
+ get arrowNote() {
76
+ return useColor() ? yellow("←") : "←";
77
+ },
78
+ };
79
+
80
+ // Render a dim underline `─` of matching visible width (used under headers).
81
+ export function underline(visibleWidth: number): string {
82
+ return muted("─".repeat(Math.max(0, visibleWidth)));
83
+ }
84
+
85
+ /** Stack-trace styling: dim "at", bold function name, cyan path, scalar line:col. */
86
+ export function styleStackLine(line: string): string {
87
+ if (!useColor()) return line;
88
+ const m = line.match(/^(\s*at\s+)(.+?)\s+\((.+?):(\d+):(\d+)\)\s*$/);
89
+ if (m) {
90
+ const [, atPart, fn, file, ln, col] = m;
91
+ return `${dim(atPart ?? "")}${bold(fn ?? "")} (${cyan(file ?? "")}:${yellow(ln ?? "")}:${yellow(col ?? "")})`;
92
+ }
93
+ const m2 = line.match(/^(\s*at\s+)(.+?):(\d+):(\d+)\s*$/);
94
+ if (m2) {
95
+ const [, atPart, file, ln, col] = m2;
96
+ return `${dim(atPart ?? "")}${cyan(file ?? "")}:${yellow(ln ?? "")}:${yellow(col ?? "")}`;
97
+ }
98
+ return line;
99
+ }
100
+
101
+ export const theme = {
102
+ server,
103
+ tool,
104
+ resource,
105
+ prompt,
106
+ taskId,
107
+ success,
108
+ warn,
109
+ error,
110
+ muted,
111
+ path,
112
+ param,
113
+ scalar,
114
+ required,
115
+ url,
116
+ label,
117
+ pillTool,
118
+ pillResource,
119
+ pillPrompt,
120
+ glyph,
121
+ underline,
122
+ styleStackLine,
123
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Single source of truth for whether the CLI is running interactively and
3
+ * whether ANSI colors should be emitted. All output modules consult these
4
+ * helpers — no module inspects process.stdout / env vars directly.
5
+ *
6
+ * Resolution (read once at startup via detectMode, then frozen via setMode):
7
+ * stdout.isTTY && stderr.isTTY && !json → interactive
8
+ * anything else → non-interactive
9
+ * CI=true → forces non-interactive
10
+ * --no-color or NO_COLOR=1 → disables ANSI even if interactive
11
+ * FORCE_COLOR → forces ANSI on regardless
12
+ *
13
+ * Direct ansis usage outside theme.ts is governed by env vars set by
14
+ * early-env.ts (which runs before ansis loads). Theme tokens additionally
15
+ * gate on useColor() at call time, so post-load flag updates also apply.
16
+ */
17
+
18
+ export interface OutputMode {
19
+ interactive: boolean;
20
+ color: boolean;
21
+ json: boolean;
22
+ verbose: boolean;
23
+ }
24
+
25
+ export interface DetectModeOptions {
26
+ json?: boolean;
27
+ noColor?: boolean;
28
+ forceColor?: boolean;
29
+ verbose?: boolean;
30
+ }
31
+
32
+ let mode: OutputMode | null = null;
33
+ let lockedColorChoice: { noColor?: boolean; forceColor?: boolean } = {};
34
+
35
+ function isTruthyEnv(v: string | undefined): boolean {
36
+ if (!v) return false;
37
+ const lower = v.toLowerCase();
38
+ return lower !== "0" && lower !== "false" && lower !== "";
39
+ }
40
+
41
+ export function detectMode(opts: DetectModeOptions = {}): OutputMode {
42
+ const json = !!opts.json;
43
+ const verbose = !!opts.verbose;
44
+ const stdoutTty = !!(process.stdout.isTTY ?? false);
45
+ const stderrTty = !!(process.stderr.isTTY ?? false);
46
+ const ci = isTruthyEnv(process.env.CI);
47
+
48
+ const interactive = !json && !ci && stdoutTty && stderrTty;
49
+
50
+ // Color choices passed once (typically by cli.ts at startup) are remembered,
51
+ // so subsequent re-detections (e.g. logger.configure after commander parses)
52
+ // don't forget about --no-color / --force-color flags from argv.
53
+ if (opts.noColor !== undefined) lockedColorChoice.noColor = opts.noColor;
54
+ if (opts.forceColor !== undefined) lockedColorChoice.forceColor = opts.forceColor;
55
+
56
+ const noColorEnv = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "";
57
+ const forceColor = !!lockedColorChoice.forceColor || isTruthyEnv(process.env.FORCE_COLOR);
58
+ const noColorFlag = !!lockedColorChoice.noColor;
59
+
60
+ let color: boolean;
61
+ if (forceColor) color = true;
62
+ else if (noColorFlag || noColorEnv || json) color = false;
63
+ else color = stderrTty || stdoutTty;
64
+
65
+ return { interactive, color, json, verbose };
66
+ }
67
+
68
+ export function setMode(m: OutputMode): void {
69
+ mode = m;
70
+ }
71
+
72
+ export function getMode(): OutputMode {
73
+ if (!mode) mode = detectMode();
74
+ return mode;
75
+ }
76
+
77
+ export function useColor(): boolean {
78
+ return getMode().color;
79
+ }
80
+
81
+ export function isInteractive(): boolean {
82
+ return getMode().interactive;
83
+ }
84
+
85
+ export function useSpinner(): boolean {
86
+ return getMode().interactive && !getMode().verbose;
87
+ }
88
+
89
+ export function isJson(): boolean {
90
+ return getMode().json;
91
+ }
92
+
93
+ export function isVerbose(): boolean {
94
+ return getMode().verbose;
95
+ }
96
+
97
+ /** Test helper: clear cached mode so the next getMode() re-detects. */
98
+ export function resetMode(): void {
99
+ mode = null;
100
+ lockedColorChoice = {};
101
+ }