@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.
- package/dist/pmcp.mjs +14 -0
- package/dist/src/commands.mjs +98 -0
- package/dist/src/config.mjs +197 -0
- package/dist/src/errors.mjs +173 -0
- package/dist/src/main.mjs +2300 -0
- package/dist/src/plan.mjs +828 -0
- package/dist/src/render.mjs +215 -0
- package/package.json +34 -0
|
@@ -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
|
+
}
|