@ahrzb/personal-mcp-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * cli/src/render.ts — §10's presentation helpers: pure text formatting, no I/O, no argv,
3
+ * no environment reads. Every TTY/color decision a caller makes (from `stream.isTTY`,
4
+ * `NO_COLOR`, `--no-color`) arrives here as a plain boolean — this module never reads
5
+ * `process.env` or a stream itself, which is what makes every function below trivially
6
+ * testable without faking a terminal.
7
+ *
8
+ * The output contract this module exists to uphold (§10 "Output contract"): color,
9
+ * truncation, and non-ASCII decoration (`…`) only on a TTY — piped output is complete,
10
+ * plain, and never shears a value to fit a column.
11
+ *
12
+ * deps: picocolors · wrap-ansi
13
+ */
14
+
15
+ import * as pc from "picocolors";
16
+ import wrapAnsi from "wrap-ansi";
17
+
18
+ /** The picocolors instance `styling` returns — every formatter no-ops when disabled. */
19
+
20
+
21
+ /**
22
+ * The color gate: `enabled` is a plain boolean the CALLER derives from
23
+ * `stream.isTTY && !NO_COLOR && !--no-color` (§10) — this function does not read any of
24
+ * those itself, so a test never needs to fake a terminal to exercise color output.
25
+ */
26
+ export function styling(enabled ) {
27
+ return pc.createColors(enabled);
28
+ }
29
+
30
+ /**
31
+ * Wraps `text` to `width` columns (wrap-ansi, soft wrap — a word longer than the column
32
+ * count overflows rather than losing characters, §10's "never shears" guarantee) and
33
+ * indents every resulting line by `indent` spaces. `width` is the FULL line budget;
34
+ * the wrap column count is `width - indent`.
35
+ */
36
+ export function wrapText(text , width , indent = 0) {
37
+ const pad = " ".repeat(Math.max(0, indent));
38
+ const columns = Math.max(1, width - indent);
39
+ return wrapAnsi(text, columns, { trim: true })
40
+ .split("\n")
41
+ .map((line) => pad + line)
42
+ .join("\n");
43
+ }
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+ /**
66
+ * Width-aware column layout: every column is padded to its widest cell (header included),
67
+ * one row per line. On a TTY, a column with a `maxWidths` entry is capped to it and a
68
+ * cell that overflows is cut short with a trailing `…`; off a TTY, `maxWidths` is ignored
69
+ * entirely — the output is the complete, plain table (§10).
70
+ */
71
+ export function columnize(rows , opts ) {
72
+ const gap = " ".repeat(opts.gap ?? 2);
73
+ const allRows = opts.headers === undefined ? rows : [opts.headers, ...rows];
74
+ const cols = allRows.reduce((max, row) => Math.max(max, row.length), 0);
75
+ const widths = [];
76
+ for (let i = 0; i < cols; i++) {
77
+ let width = 0;
78
+ for (const row of allRows) width = Math.max(width, (row[i] ?? "").length);
79
+ const cap = opts.maxWidths?.[i];
80
+ if (opts.tty && cap !== undefined) width = Math.min(width, cap);
81
+ widths[i] = width;
82
+ }
83
+ const renderRow = (row , rowIndex ) =>
84
+ row
85
+ .map((cell, i) => {
86
+ const width = widths[i] ?? 0;
87
+ const truncated = opts.tty && cell.length > width ? `${cell.slice(0, Math.max(0, width - 1))}…` : cell;
88
+ const painted =
89
+ opts.style === undefined || rowIndex < 0 || truncated === "" ? truncated : opts.style(truncated, i, rowIndex);
90
+ // The last column is never padded — trailing spaces on every row are pointless noise.
91
+ return i === cols - 1 ? painted : painted + " ".repeat(Math.max(0, width - truncated.length));
92
+ })
93
+ .join(gap)
94
+ // …and neither is the padding an EMPTY trailing cell leaves behind (a row that stops
95
+ // short of the widest one, an optional last column). No line this module emits ends
96
+ // in whitespace.
97
+ .trimEnd();
98
+ const offset = opts.headers === undefined ? 0 : 1;
99
+ return allRows.map((row, index) => renderRow(row, index - offset)).join("\n");
100
+ }
101
+
102
+ const DEFAULT_LINE_WIDTH = 80;
103
+
104
+ /**
105
+ * One catalog row: `name` padded to `width`, then the description's FIRST line only —
106
+ * a catalog line is one line by design, so a multi-line description is not shown in
107
+ * full here (that is `wrapText`'s or `schemaTable`'s job, for the leaf view). On a TTY
108
+ * the first line is cut short with `…` to fit `lineWidth`; off a TTY it prints whole.
109
+ */
110
+ export function catalogLine(name , description , width , tty , lineWidth = DEFAULT_LINE_WIDTH) {
111
+ const namePad = name.padEnd(width);
112
+ const firstLine = description.split("\n")[0] ?? "";
113
+ const budget = lineWidth - namePad.length - 1;
114
+ const shown = tty && budget > 0 && firstLine.length > budget ? `${firstLine.slice(0, Math.max(0, budget - 1))}…` : firstLine;
115
+ return `${namePad} ${shown}`;
116
+ }
117
+
118
+ /** The subset of JSON Schema this module renders; anything else falls back to raw JSON. */
119
+
120
+
121
+
122
+
123
+
124
+
125
+
126
+
127
+
128
+
129
+
130
+ function isPlainObject(value ) {
131
+ return typeof value === "object" && value !== null && !Array.isArray(value);
132
+ }
133
+
134
+ /** A property is "nested" when laying it out needs more than one table row. */
135
+ function isNested(prop ) {
136
+ if (prop.type === "object" && isPlainObject(prop.properties)) return true;
137
+ if (prop.type === "array" && isPlainObject(prop.items) && prop.items.type === "object") return true;
138
+ return false;
139
+ }
140
+
141
+ /** enum / description / default composed into one description-column string. */
142
+ function describeProp(prop ) {
143
+ const parts = [];
144
+ if (Array.isArray(prop.enum)) parts.push(prop.enum.map((value) => JSON.stringify(value)).join(" | "));
145
+ if (typeof prop.description === "string" && prop.description !== "") parts.push(prop.description);
146
+ if (prop.default !== undefined) parts.push(`(default ${JSON.stringify(prop.default)})`);
147
+ return parts.join(" ");
148
+ }
149
+
150
+ function typeLabel(prop ) {
151
+ if (prop.type === "array") return `array<${(prop.items )?.type ?? "any"}>`;
152
+ return String(prop.type ?? "");
153
+ }
154
+
155
+ /** The indented-tree form for a schema carrying at least one nested property. */
156
+ function schemaTree(schema , depth ) {
157
+ const indent = " ".repeat(depth);
158
+ const required = new Set(schema.required ?? []);
159
+ const lines = [];
160
+ for (const [name, prop] of Object.entries(schema.properties ?? {})) {
161
+ const bits = [typeLabel(prop), required.has(name) ? "required" : "", describeProp(prop)].filter((bit) => bit !== "");
162
+ lines.push(`${indent}${name} ${bits.join(" ")}`.trimEnd());
163
+ if (isNested(prop)) {
164
+ const child = prop.type === "array" ? (prop.items ) : prop;
165
+ lines.push(schemaTree(child, depth + 1));
166
+ }
167
+ }
168
+ return lines.join("\n");
169
+ }
170
+
171
+ /**
172
+ * A JSON Schema object → a rendering: a FLAT object schema (every property primitive)
173
+ * becomes aligned `name / type / required / description` rows (`columnize`, no header —
174
+ * a leaf's argument list is not itself a table with column titles, §10's mock); a schema
175
+ * with at least one nested (object- or object-array-typed) property becomes an indented
176
+ * tree instead; anything that is not an object schema with `properties` at all — no
177
+ * `type: "object"`, a primitive schema, an array schema, `properties` missing or not an
178
+ * object — falls back to `JSON.stringify(schema, null, 2)` verbatim, so nothing this
179
+ * renderer cannot lay out is ever silently dropped.
180
+ */
181
+ export function schemaTable(schema , tty = true) {
182
+ if (!isPlainObject(schema) || schema.type !== "object" || !isPlainObject(schema.properties)) {
183
+ return JSON.stringify(schema, null, 2);
184
+ }
185
+ const s = schema ;
186
+ if (Object.values(s.properties).some(isNested)) return schemaTree(s, 0);
187
+ const required = new Set(s.required ?? []);
188
+ const rows = Object.entries(s.properties).map(([name, prop]) => [
189
+ name,
190
+ typeLabel(prop),
191
+ required.has(name) ? "required" : "",
192
+ describeProp(prop),
193
+ ]);
194
+ return columnize(rows, { tty });
195
+ }
196
+
197
+ const JSON_TOKEN = /("(?:\\u[0-9a-fA-F]{4}|\\[^u]|[^\\"])*"(?:\s*:)?)|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
198
+
199
+ /**
200
+ * `JSON.stringify(value, null, 2)`, lightly colored when `colored` is true: keys cyan,
201
+ * strings green, numbers magenta, `true`/`false` yellow, `null` gray. `colored: false`
202
+ * (or `styling(false)`'s underlying gate) returns the plain stringified text unchanged —
203
+ * this function creates its own picocolors instance rather than taking one, since every
204
+ * picocolors formatter is already a safe no-op when its instance is disabled.
205
+ */
206
+ export function renderJson(value , colored ) {
207
+ const json = JSON.stringify(value, null, 2);
208
+ const c = pc.createColors(colored);
209
+ return json.replace(JSON_TOKEN, (match, key ) => {
210
+ if (key !== undefined) return key.endsWith(":") ? c.cyan(key) : c.green(key);
211
+ if (match === "true" || match === "false") return c.yellow(match);
212
+ if (match === "null") return c.gray(match);
213
+ return c.magenta(match);
214
+ });
215
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@ahrzb/personal-mcp-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for a personal-mcps hub — services, accounts, tokens, approvals, audit, YAML diff/apply.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ahrzb/personal-mcps.git",
8
+ "directory": "cli"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "pmcp": "dist/pmcp.mjs"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22.18"
19
+ },
20
+ "dependencies": {
21
+ "@clack/prompts": "^1.7.0",
22
+ "commander": "^15.0.0",
23
+ "picocolors": "^1.1.1",
24
+ "smol-toml": "^1.4.0",
25
+ "wrap-ansi": "^10.0.1",
26
+ "yaml": "^2.8.0"
27
+ },
28
+ "scripts": {
29
+ "prepublishOnly": "node build.mjs"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }