@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.
- package/.claude/skills/mcpx.md +2 -0
- package/.cursor/rules/mcpx.mdc +2 -0
- package/README.md +16 -0
- package/package.json +1 -4
- package/src/cli.ts +30 -8
- package/src/client/trace.ts +9 -9
- package/src/context.ts +1 -1
- package/src/output/early-env.ts +26 -0
- package/src/output/format-output.ts +7 -1
- package/src/output/format-table.ts +10 -7
- package/src/output/formatter.ts +110 -89
- package/src/output/logger.ts +35 -16
- package/src/output/theme.ts +123 -0
- package/src/output/tty.ts +101 -0
package/.claude/skills/mcpx.md
CHANGED
|
@@ -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
|
|
package/.cursor/rules/mcpx.mdc
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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) =>
|
|
49
|
-
styleCommandText: (str) =>
|
|
50
|
-
styleSubcommandText: (str) =>
|
|
51
|
-
styleOptionText: (str) =>
|
|
52
|
-
styleArgumentText: (str) =>
|
|
53
|
-
styleDescriptionText: (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
|
-
|
|
113
|
+
logger.error(`error: unknown command '${firstCommand}'. See 'mcpx --help'.`);
|
|
92
114
|
process.exit(1);
|
|
93
115
|
}
|
|
94
116
|
|
package/src/client/trace.ts
CHANGED
|
@@ -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 ?
|
|
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} ${
|
|
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 ?
|
|
80
|
-
logger.writeRaw(`${arrow} ${
|
|
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 ?
|
|
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} ${
|
|
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 ?
|
|
126
|
+
const arrow = isTTY ? glyph.arrowNote : "←";
|
|
127
127
|
const params = m.params ? ` ${JSON.stringify(m.params)}` : "";
|
|
128
|
-
logger.writeRaw(`${arrow} ${
|
|
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
|
-
|
|
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 "./
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
|
|
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) :
|
|
56
|
+
const formatted = termWidth != null ? wrapDescription(desc, pw, termWidth) : theme.muted(desc);
|
|
54
57
|
return `${prefix}${sep}${formatted}`;
|
|
55
58
|
}
|
|
56
59
|
|
package/src/output/formatter.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import 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
|
|
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
|
|
81
|
-
* @param text - raw description text (before
|
|
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
|
|
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}${
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(` ${
|
|
148
|
-
for (const k of absent) lines.push(` ${
|
|
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(
|
|
155
|
-
lines.push(` ${
|
|
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(`${
|
|
171
|
+
lines.push(`${theme.tool("Tools:")} ${theme.muted("none")}`);
|
|
162
172
|
} else {
|
|
163
|
-
lines.push(
|
|
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 = ` ${
|
|
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) :
|
|
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(
|
|
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:
|
|
205
|
-
{ value: (t) => t.tool.name, style:
|
|
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
|
|
233
|
+
return theme.muted(`No tools found for ${serverName}`);
|
|
224
234
|
}
|
|
225
|
-
const header =
|
|
235
|
+
const header = theme.server(serverName);
|
|
226
236
|
const body = formatTable(tools, {
|
|
227
|
-
columns: [{ value: (t) => ` ${t.name}`, style:
|
|
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
|
-
|
|
248
|
-
|
|
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(
|
|
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}${
|
|
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) ?
|
|
273
|
-
const desc = p.description ? ` ${
|
|
274
|
-
return `${pad}${
|
|
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
|
-
|
|
292
|
-
|
|
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(
|
|
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(
|
|
299
|
-
lines.push(
|
|
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,
|
|
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 = `${
|
|
679
|
-
const details = errors.map((e) => ` ${
|
|
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
|
|
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 = `${
|
|
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) :
|
|
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:
|
|
729
|
-
{ value: (r) => r.resource.uri, style:
|
|
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
|
|
766
|
+
return theme.muted(`No resources found for ${serverName}`);
|
|
753
767
|
}
|
|
754
|
-
const header =
|
|
768
|
+
const header = theme.server(serverName);
|
|
755
769
|
const body = formatTable(resources, {
|
|
756
|
-
columns: [{ value: (r) => ` ${r.uri}`, style:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
811
|
-
{ value: (p) => p.prompt.name, style:
|
|
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
|
|
849
|
+
return theme.muted(`No prompts found for ${serverName}`);
|
|
834
850
|
}
|
|
835
851
|
|
|
836
|
-
const header =
|
|
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 = ` ${
|
|
857
|
+
const name = ` ${theme.prompt(p.name.padEnd(maxName))}`;
|
|
842
858
|
const args =
|
|
843
859
|
p.arguments && p.arguments.length > 0
|
|
844
|
-
? ` ${
|
|
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) :
|
|
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
|
-
|
|
877
|
-
|
|
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(`${
|
|
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
|
|
894
|
-
if (
|
|
895
|
-
if (
|
|
896
|
-
return
|
|
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
|
|
910
|
-
{ value: (i) => i.
|
|
911
|
-
{ value: (i) => i
|
|
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
|
|
950
|
+
return theme.success(s);
|
|
932
951
|
case "working":
|
|
933
|
-
return
|
|
952
|
+
return theme.warn(s);
|
|
934
953
|
case "failed":
|
|
935
954
|
case "cancelled":
|
|
936
|
-
return
|
|
955
|
+
return theme.error(s);
|
|
937
956
|
case "input_required":
|
|
938
|
-
return
|
|
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(`${
|
|
946
|
-
lines.push(`${
|
|
947
|
-
if (task.statusMessage) lines.push(`${
|
|
948
|
-
if (task.createdAt) lines.push(`${
|
|
949
|
-
if (task.lastUpdatedAt) lines.push(`${
|
|
950
|
-
if (task.ttl != null) lines.push(`${
|
|
951
|
-
if (task.pollInterval != null)
|
|
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
|
|
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
|
|
994
|
+
return theme.success(s.padEnd(14));
|
|
975
995
|
case "working":
|
|
976
|
-
return
|
|
996
|
+
return theme.warn(s.padEnd(14));
|
|
977
997
|
case "failed":
|
|
978
998
|
case "cancelled":
|
|
979
|
-
return
|
|
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 =
|
|
1008
|
+
const id = theme.path(t.taskId.padEnd(maxId));
|
|
989
1009
|
const status = statusColor(t.status);
|
|
990
|
-
const updated = 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(
|
|
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
|
-
() =>
|
|
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 }, () => `${
|
|
1040
|
+
return formatOutput({ error: message }, () => `${glyph.fail} ${theme.error("error:")} ${message}`, options);
|
|
1020
1041
|
}
|
package/src/output/logger.ts
CHANGED
|
@@ -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
|
|
19
|
+
return theme.muted;
|
|
19
20
|
case "warning":
|
|
20
|
-
return
|
|
21
|
+
return theme.warn;
|
|
21
22
|
case "error":
|
|
22
23
|
case "critical":
|
|
23
24
|
case "alert":
|
|
24
25
|
case "emergency":
|
|
25
|
-
return
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
93
|
-
this.writeStderr(
|
|
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 (
|
|
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,
|
|
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 (
|
|
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
|
+
}
|