@cruxy/cli 0.25.0 → 0.27.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/approval/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/cli/commands/mcp.js +106 -7
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.js +9 -3
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- package/dist/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- package/dist/render/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +5 -2
- package/dist/render/index.js +9 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +25 -3
- package/dist/render/tty-renderer.js +94 -32
- package/dist/render/types.d.ts +15 -1
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -29,6 +29,12 @@ export interface PromptIO {
|
|
|
29
29
|
readLine(): Promise<string>;
|
|
30
30
|
/** Whether to emit ANSI color. */
|
|
31
31
|
color: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Terminal columns (U.12). Optional so every existing PromptIO literal is
|
|
34
|
+
* unchanged; when absent the render resolves the width itself. Threaded so a
|
|
35
|
+
* narrow prompt reflows the command and never truncates the risk marker.
|
|
36
|
+
*/
|
|
37
|
+
columns?: number;
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -37,6 +43,6 @@ export interface PromptIO {
|
|
|
37
43
|
*/
|
|
38
44
|
export declare function promptForApproval(request: ApprovalRequest, io: PromptIO): Promise<PromptChoice>;
|
|
39
45
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
40
|
-
export declare function render(request: ApprovalRequest, color: boolean): string;
|
|
46
|
+
export declare function render(request: ApprovalRequest, color: boolean, columns?: number): string;
|
|
41
47
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
42
48
|
export declare function defaultPromptIO(color: boolean): PromptIO;
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readSingleKey } from "../components/input.js";
|
|
3
3
|
import { renderActionPreview } from "../render/diff.js";
|
|
4
|
+
import { resolveColumns } from "../render/capabilities.js";
|
|
5
|
+
import { fitMiddle, reflow, visibleWidth } from "../render/layout.js";
|
|
4
6
|
import { themeForColor } from "../theme/index.js";
|
|
5
7
|
/**
|
|
6
8
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -8,7 +10,7 @@ import { themeForColor } from "../theme/index.js";
|
|
|
8
10
|
* is a reject.
|
|
9
11
|
*/
|
|
10
12
|
export async function promptForApproval(request, io) {
|
|
11
|
-
io.write(render(request, io.color));
|
|
13
|
+
io.write(render(request, io.color, io.columns ?? resolveColumns()));
|
|
12
14
|
const key = (await io.readKey()).toLowerCase();
|
|
13
15
|
io.write("\n");
|
|
14
16
|
switch (key) {
|
|
@@ -34,21 +36,41 @@ export async function promptForApproval(request, io) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
37
|
-
export function render(request, color) {
|
|
39
|
+
export function render(request, color, columns = resolveColumns()) {
|
|
38
40
|
const t = themeForColor(color);
|
|
39
41
|
const destructive = request.tier === "destructive";
|
|
40
|
-
// Risk survives all
|
|
41
|
-
// (`!` vs `?`) for NO_COLOR,
|
|
42
|
-
// (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone
|
|
43
|
-
//
|
|
42
|
+
// Risk survives all FOUR degradations now (U.11 color/unicode/reader + U.12
|
|
43
|
+
// width): the mark carries it by *shape* (`!` vs `?`) for NO_COLOR, the label
|
|
44
|
+
// by *word* (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone —
|
|
45
|
+
// and under narrow width the header line that holds both is emitted WHOLE,
|
|
46
|
+
// never passed through a right-truncating fit that could drop the label.
|
|
44
47
|
const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
|
|
45
48
|
const label = tierLabel(request.tier, t);
|
|
46
49
|
const lines = [];
|
|
47
|
-
lines.push(
|
|
48
|
-
lines.push(detail(request, t));
|
|
50
|
+
lines.push(...header(request.summary, mark, label, t, columns));
|
|
51
|
+
lines.push(detail(request, t, columns));
|
|
49
52
|
lines.push(choices(request.scope, t));
|
|
50
53
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
51
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The header, width-aware (U.12). Wide: the one inline line `! cruxy wants to
|
|
57
|
+
* <summary> (destructive)`. Narrow: the risk (mark + tier label) stands on its
|
|
58
|
+
* own line — emitted whole, never truncated — and the summary reflows beneath
|
|
59
|
+
* it, so the security-relevant part is always visible while the description
|
|
60
|
+
* wraps rather than soft-wrapping into a torn line.
|
|
61
|
+
*/
|
|
62
|
+
function header(summary, mark, label, t, width) {
|
|
63
|
+
const inline = `${mark} cruxy wants to ${t.strong(summary)}${label}`;
|
|
64
|
+
if (visibleWidth(inline) <= width)
|
|
65
|
+
return [inline];
|
|
66
|
+
// The risk line (mark + tier word, e.g. `! (destructive)`) is short and is
|
|
67
|
+
// NEVER passed through fit(): if it overflows an absurdly narrow terminal it
|
|
68
|
+
// wraps (nothing dropped) rather than losing its tier. The action prose +
|
|
69
|
+
// summary reflow beneath it.
|
|
70
|
+
const riskLine = `${mark}${label}`;
|
|
71
|
+
const body = reflow(`cruxy wants to ${summary}`, Math.max(1, width - 2)).map((l) => ` ${t.strong(l)}`);
|
|
72
|
+
return [riskLine, ...body];
|
|
73
|
+
}
|
|
52
74
|
/** The worded risk tag, colored by tier — always present, so meaning never
|
|
53
75
|
* rides on the `!`/`?` shape or its color alone. */
|
|
54
76
|
function tierLabel(tier, t) {
|
|
@@ -62,17 +84,28 @@ function tierLabel(tier, t) {
|
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
65
|
-
function detail(request, t) {
|
|
87
|
+
function detail(request, t, width) {
|
|
66
88
|
if (request.action.kind === "shell" || request.action.kind === "test") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
// The command is REFLOWED, never truncated (U.12): you always see the whole
|
|
90
|
+
// thing you are authorizing — it wraps across as many lines as it needs. The
|
|
91
|
+
// cwd (a path) middle-truncates so its leaf survives.
|
|
92
|
+
const command = request.action.command ?? "";
|
|
93
|
+
const wrapped = reflow(command, Math.max(1, width - 4));
|
|
94
|
+
const cmdLines = [
|
|
95
|
+
` ${t.muted("$")} ${wrapped[0] ?? ""}`,
|
|
96
|
+
...wrapped.slice(1).map((l) => ` ${l}`),
|
|
97
|
+
];
|
|
98
|
+
const cwd = fitMiddle(request.cwd, Math.max(1, width - 5), t.glyph.ellipsis);
|
|
99
|
+
return [...cmdLines, ` ${t.muted(`in ${cwd}`)}`].join("\n");
|
|
71
100
|
}
|
|
72
101
|
if (request.action.kind === "mcp") {
|
|
73
102
|
// The server runs UNSANDBOXED with the user's privileges — say so at the
|
|
74
|
-
// point of the call, not just at trust time.
|
|
75
|
-
|
|
103
|
+
// point of the call, not just at trust time. Reflowed so the whole warning
|
|
104
|
+
// survives narrow width (it must not be the part that gets clipped).
|
|
105
|
+
const note = `external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`;
|
|
106
|
+
return reflow(note, Math.max(1, width - 2))
|
|
107
|
+
.map((l) => ` ${t.muted(l)}`)
|
|
108
|
+
.join("\n");
|
|
76
109
|
}
|
|
77
110
|
if (request.action.kind === "vcs" && request.action.root) {
|
|
78
111
|
// C.26 Step 4 (⚖︎JC-4): name the acting root alongside the resolved owner/repo
|
|
@@ -80,12 +113,12 @@ function detail(request, t) {
|
|
|
80
113
|
// PR acts in and the real API destination before approving.
|
|
81
114
|
return [
|
|
82
115
|
` ${t.muted(`root ${request.action.root}`)}`,
|
|
83
|
-
renderActionPreview(request.action.preview, t),
|
|
116
|
+
renderActionPreview(request.action.preview, t, width),
|
|
84
117
|
]
|
|
85
118
|
.filter((l) => l !== "")
|
|
86
119
|
.join("\n");
|
|
87
120
|
}
|
|
88
|
-
return renderActionPreview(request.action.preview, t);
|
|
121
|
+
return renderActionPreview(request.action.preview, t, width);
|
|
89
122
|
}
|
|
90
123
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
91
124
|
function choices(scope, t) {
|
|
@@ -117,6 +150,8 @@ export function defaultPromptIO(color) {
|
|
|
117
150
|
readKey: () => readSingleKey(),
|
|
118
151
|
readLine: readLineFromStdin,
|
|
119
152
|
color,
|
|
153
|
+
// The prompt writes to stderr — width from stderr's own columns (U.12).
|
|
154
|
+
columns: resolveColumns(process.stderr),
|
|
120
155
|
};
|
|
121
156
|
}
|
|
122
157
|
/** Read one line in cooked mode; "" on EOF. */
|
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { loadConfig } from "../../config/index.js";
|
|
4
|
-
import {
|
|
3
|
+
import { loadConfig, writeMcpCredential } from "../../config/index.js";
|
|
4
|
+
import { defaultOnboardingIO } from "../../onboarding/index.js";
|
|
5
|
+
import { interactiveRequired, shouldUseColor } from "../../errors/index.js";
|
|
5
6
|
import { themeForColor } from "../../theme/index.js";
|
|
6
|
-
import { fileMcpTrustStore, fingerprintMcpServers, isMcpTrusted, } from "../../mcp/index.js";
|
|
7
|
+
import { fileMcpTrustStore, fingerprintMcpServers, isMcpTrusted, resolveMcpEndpoints, } from "../../mcp/index.js";
|
|
8
|
+
import { BlockedHostError, defaultResolveHost, HostUnresolvedError, } from "../../net/ip-guard.js";
|
|
7
9
|
import { logger } from "../../utils/logger.js";
|
|
8
10
|
/**
|
|
9
11
|
* `cruxy mcp` — inspect and control MCP server integration (C.27). `list` shows
|
|
@@ -36,11 +38,16 @@ export function mcpCommand() {
|
|
|
36
38
|
for (const [name, cfg] of Object.entries(servers)) {
|
|
37
39
|
logger.print(` ${t.strong(name)} ${t.muted(describeServer(cfg))}`);
|
|
38
40
|
}
|
|
41
|
+
if (trusted && Object.values(servers).some((c) => c.url)) {
|
|
42
|
+
// JC-D: url servers are also bound to their resolved IP set, re-checked at
|
|
43
|
+
// connect; a changed IP set re-gates even though the static status is "trusted".
|
|
44
|
+
logger.print(t.muted("\n note: url servers are re-verified against their trusted IP set on connect"));
|
|
45
|
+
}
|
|
39
46
|
});
|
|
40
47
|
cmd
|
|
41
48
|
.command("trust [path]")
|
|
42
49
|
.description("trust this repo's MCP integrations (they run with your full privileges)")
|
|
43
|
-
.action((target) => {
|
|
50
|
+
.action(async (target) => {
|
|
44
51
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
45
52
|
const { config } = loadConfig();
|
|
46
53
|
const servers = config.mcp.servers;
|
|
@@ -50,17 +57,91 @@ export function mcpCommand() {
|
|
|
50
57
|
logger.print(t.muted(`no MCP servers configured under ${root}`));
|
|
51
58
|
return;
|
|
52
59
|
}
|
|
53
|
-
|
|
60
|
+
const hasStdio = Object.values(servers).some((c) => c.command);
|
|
61
|
+
const hasUrl = Object.values(servers).some((c) => c.url);
|
|
62
|
+
logger.print(t.danger(t.strong(`trusting ${names.length} MCP server${names.length === 1 ? "" : "s"} for ${root}:`)));
|
|
54
63
|
for (const [name, cfg] of Object.entries(servers)) {
|
|
55
64
|
logger.print(` ${t.strong(name)} ${t.muted(describeServer(cfg))}`);
|
|
56
65
|
}
|
|
66
|
+
if (hasStdio) {
|
|
67
|
+
logger.print(t.muted(" stdio servers run their code UNSANDBOXED with your full privileges."));
|
|
68
|
+
}
|
|
69
|
+
if (hasUrl) {
|
|
70
|
+
logger.print(t.muted(" url servers receive your tool arguments over the network; trust binds\n" +
|
|
71
|
+
" the URL + its current IP set (weaker than a local binary — see docs)."));
|
|
72
|
+
// C.27c: a server that also RECEIVES a credential is a bigger grant —
|
|
73
|
+
// name it so the grant is explicit at trust time.
|
|
74
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
75
|
+
if (!cfg.credentialRef && !cfg.headers)
|
|
76
|
+
continue;
|
|
77
|
+
const what = cfg.credentialRef
|
|
78
|
+
? `your "${cfg.credentialRef}" credential`
|
|
79
|
+
: "your configured auth headers";
|
|
80
|
+
logger.print(t.danger(` "${name}" will RECEIVE ${what} on every request.`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// JC-D: capture + SSRF-validate each url server's endpoint set and bind it
|
|
84
|
+
// into the decision. A refused URL (SSRF/scheme) fails closed — no record.
|
|
85
|
+
let endpoints = {};
|
|
86
|
+
try {
|
|
87
|
+
endpoints = await resolveMcpEndpoints(servers, defaultResolveHost);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof BlockedHostError ||
|
|
91
|
+
err instanceof HostUnresolvedError) {
|
|
92
|
+
logger.print(t.danger(`refused to trust — a url server could not be validated: ${err.message}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
57
97
|
fileMcpTrustStore().record({
|
|
58
98
|
root,
|
|
59
99
|
fingerprint: fingerprintMcpServers(servers),
|
|
60
100
|
at: new Date().toISOString(),
|
|
101
|
+
endpoints,
|
|
61
102
|
});
|
|
62
103
|
logger.print(`${t.success("trusted")} — cruxy will connect these servers for ${root}. ` +
|
|
63
|
-
t.muted("changing the config
|
|
104
|
+
t.muted("changing the config (or a url's IP set) requires re-trusting."));
|
|
105
|
+
});
|
|
106
|
+
cmd
|
|
107
|
+
.command("login <server>")
|
|
108
|
+
.description("set a bearer credential for a remote MCP integration (owner-only in ~/.cruxy)")
|
|
109
|
+
.action(async (server) => {
|
|
110
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
111
|
+
const { config } = loadConfig();
|
|
112
|
+
const cfg = config.mcp.servers[server];
|
|
113
|
+
if (!cfg) {
|
|
114
|
+
logger.print(t.danger(`no MCP server "${server}" is configured (mcp.servers)`));
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!cfg.url) {
|
|
119
|
+
logger.print(t.danger(`server "${server}" is a stdio server — it has no bearer credential ` +
|
|
120
|
+
"(stdio servers take secrets via `env`)"));
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// The store is keyed by the credential NAME the config references; default
|
|
125
|
+
// to the server id when the config names none.
|
|
126
|
+
const ref = cfg.credentialRef ?? server;
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
// Never read a secret from a pipe silently — same discipline as onboarding.
|
|
129
|
+
throw interactiveRequired(`a credential for MCP server "${server}"`, [
|
|
130
|
+
"run this in an interactive terminal",
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
const io = defaultOnboardingIO(shouldUseColor(process.stderr));
|
|
134
|
+
io.write(`${t.strong(`credential for "${server}"`)} ${t.muted(`(stored as "${ref}" in ~/.cruxy/credentials.json, 0600)`)}\n`);
|
|
135
|
+
io.write(`${t.muted("paste the bearer token (input hidden): ")}`);
|
|
136
|
+
const token = (await io.readSecret()).trim();
|
|
137
|
+
if (!token) {
|
|
138
|
+
logger.print(t.warning("no token entered — nothing was stored"));
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
writeMcpCredential(ref, token);
|
|
143
|
+
logger.print(`${t.success("stored")} — cruxy will send this credential to "${server}" over https only. ` +
|
|
144
|
+
t.muted("re-run to rotate; the value is never shown or logged."));
|
|
64
145
|
});
|
|
65
146
|
cmd
|
|
66
147
|
.command("untrust [path]")
|
|
@@ -83,5 +164,23 @@ function describeServer(cfg) {
|
|
|
83
164
|
if (cfg.command) {
|
|
84
165
|
return `(stdio: ${[cfg.command, ...(cfg.args ?? [])].join(" ")})`;
|
|
85
166
|
}
|
|
86
|
-
|
|
167
|
+
if (!cfg.url)
|
|
168
|
+
return "(no transport)";
|
|
169
|
+
return `(url: ${cfg.url}${describeAuth(cfg)})`;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A REDACTED auth descriptor for display (C.27c) — names the credential and
|
|
173
|
+
* counts raw headers, but NEVER prints a header value or a token. Display
|
|
174
|
+
* redaction is defense-in-depth; the load-bearing guard is that project config
|
|
175
|
+
* can't carry a value at all.
|
|
176
|
+
*/
|
|
177
|
+
function describeAuth(cfg) {
|
|
178
|
+
const parts = [];
|
|
179
|
+
if (cfg.credentialRef)
|
|
180
|
+
parts.push(`credential: ${cfg.credentialRef}`);
|
|
181
|
+
const headerCount = cfg.headers ? Object.keys(cfg.headers).length : 0;
|
|
182
|
+
if (headerCount > 0) {
|
|
183
|
+
parts.push(`${headerCount} header${headerCount === 1 ? "" : "s"} (redacted)`);
|
|
184
|
+
}
|
|
185
|
+
return parts.length ? `, ${parts.join(", ")}` : "";
|
|
87
186
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { getSkillService, resetSkillServices } from "../../skills/index.js";
|
|
3
3
|
import { shouldUseColor } from "../../errors/index.js";
|
|
4
|
+
import { kvStack, resolveColumns } from "../../render/index.js";
|
|
4
5
|
import { themeForColor } from "../../theme/index.js";
|
|
5
6
|
import { logger } from "../../utils/logger.js";
|
|
6
7
|
/**
|
|
@@ -34,8 +35,15 @@ export function skillsCommand() {
|
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
logger.print(`\n${t.heading("sources")} ${t.muted("(precedence, high to low)")}`);
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Aligned `source dir` columns when there's room; at narrow width the
|
|
39
|
+
// dir paths would collide with the key column, so kvStack stacks each
|
|
40
|
+
// pair instead (U.12) — the paths stay readable rather than truncating.
|
|
41
|
+
const rows = status.sources.map((s) => ({
|
|
42
|
+
key: s.source,
|
|
43
|
+
value: t.muted(s.dir),
|
|
44
|
+
}));
|
|
45
|
+
for (const line of kvStack(rows, resolveColumns(process.stdout) - 2, t)) {
|
|
46
|
+
logger.print(` ${line}`);
|
|
39
47
|
}
|
|
40
48
|
logger.print("");
|
|
41
49
|
if (status.errors.length === 0) {
|
package/dist/cli/repl.js
CHANGED
|
@@ -5,10 +5,14 @@ import { runGatedShell } from "../tools/shell/exec.js";
|
|
|
5
5
|
import { addRootToWorkspace } from "../workspace/index.js";
|
|
6
6
|
import { themeForColor } from "../theme/index.js";
|
|
7
7
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
8
|
-
import { createRenderer } from "../render/index.js";
|
|
8
|
+
import { createRenderer, fit, resolveColumns, } from "../render/index.js";
|
|
9
9
|
import { logger } from "../utils/logger.js";
|
|
10
10
|
/** The REPL prompts on stdout; its chrome resolves against stdout's color. */
|
|
11
11
|
const theme = themeForColor(shouldUseColor(process.stdout));
|
|
12
|
+
/** Fit a committed REPL line to stdout's current width (U.12), id/status-first. */
|
|
13
|
+
function fitOut(line) {
|
|
14
|
+
return fit(line, resolveColumns(process.stdout), theme.glyph.ellipsis);
|
|
15
|
+
}
|
|
12
16
|
const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
|
|
13
17
|
/**
|
|
14
18
|
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
@@ -185,7 +189,9 @@ function handleJobsList(session) {
|
|
|
185
189
|
? theme.muted(` — needs approval: ${j.pendingApproval}`)
|
|
186
190
|
: "";
|
|
187
191
|
const err = j.error ? theme.muted(` (${j.error})`) : "";
|
|
188
|
-
|
|
192
|
+
// Fit id-first so the job id + status always survive; the label/notes tail
|
|
193
|
+
// truncates with an honest ellipsis at narrow width (U.12).
|
|
194
|
+
logger.print(fitOut(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`));
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
/** Print one job's log (`/logs <id>`). */
|
|
@@ -207,7 +213,7 @@ function handleJobLogs(input, session) {
|
|
|
207
213
|
}
|
|
208
214
|
for (const line of log.lines) {
|
|
209
215
|
const text = line.stream === "err" ? theme.danger(line.text) : line.text;
|
|
210
|
-
logger.print(text);
|
|
216
|
+
logger.print(fitOut(text));
|
|
211
217
|
}
|
|
212
218
|
logger.print(theme.muted(`(${log.status})`));
|
|
213
219
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { stripAnsi } from "../render/layout.js";
|
|
1
2
|
import type { RenderCapabilities } from "../render/index.js";
|
|
2
|
-
/** The visible text of a possibly-styled row. */
|
|
3
|
-
export
|
|
3
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
4
|
+
export { stripAnsi };
|
|
4
5
|
/**
|
|
5
6
|
* The transient multi-line region interactive components draw into (U.7) —
|
|
6
7
|
* the multi-row analog of the TTY renderer's single managed status line, with
|
|
@@ -12,6 +13,8 @@ export declare function stripAnsi(text: string): string;
|
|
|
12
13
|
* soft-wrap; wrapped rows would break erasure and leave artifacts.
|
|
13
14
|
* - `clear()` removes the frame entirely — after a component resolves, the
|
|
14
15
|
* screen holds zero leftover bytes from the interaction.
|
|
16
|
+
* - On resize (U.12) the frame reflows its current rows in place at the new
|
|
17
|
+
* width; committed output above is untouched. `clear()` also unsubscribes.
|
|
15
18
|
*
|
|
16
19
|
* Requires cursor control (`caps.cursor`); components guard on that before
|
|
17
20
|
* constructing one.
|
|
@@ -19,7 +22,7 @@ export declare function stripAnsi(text: string): string;
|
|
|
19
22
|
export interface Frame {
|
|
20
23
|
/** Repaint the frame with these rows (erases the previous paint first). */
|
|
21
24
|
render(lines: string[]): void;
|
|
22
|
-
/** Erase the frame completely. Idempotent. */
|
|
25
|
+
/** Erase the frame completely and release the resize subscription. Idempotent. */
|
|
23
26
|
clear(): void;
|
|
24
27
|
}
|
|
25
28
|
export declare function createFrame(write: (text: string) => void, caps: RenderCapabilities): Frame;
|
package/dist/components/frame.js
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
|
+
import { fit, stripAnsi } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
/** Erase the current line and return the cursor to column 0 (same as U.2). */
|
|
3
4
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
4
5
|
/** Move the cursor up one row. */
|
|
5
6
|
const CURSOR_UP = "\x1b[1A";
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
const SGR = /\x1b\[[0-9;]*m/g;
|
|
9
|
-
/** The visible text of a possibly-styled row. */
|
|
10
|
-
export function stripAnsi(text) {
|
|
11
|
-
return text.replace(SGR, "");
|
|
12
|
-
}
|
|
7
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
8
|
+
export { stripAnsi };
|
|
13
9
|
export function createFrame(write, caps) {
|
|
14
10
|
let drawn = 0;
|
|
11
|
+
let lastLines = [];
|
|
15
12
|
const ellipsis = resolveTheme(caps).glyph.ellipsis;
|
|
16
13
|
/**
|
|
17
14
|
* Truncate to width-1 (cursor rests after the last cell; a full-width row
|
|
18
15
|
* would auto-wrap on some terminals). Width is measured on VISIBLE
|
|
19
|
-
* characters — rows may carry ANSI color
|
|
20
|
-
* styled
|
|
21
|
-
* dropped rather than risking a cut escape sequence).
|
|
16
|
+
* characters (U.12 {@link fit}) — rows may carry ANSI color; a row that fits
|
|
17
|
+
* passes through styled, an overflowing row is truncated on its visible text.
|
|
22
18
|
*/
|
|
23
|
-
const
|
|
24
|
-
const room = Math.max(1, caps.width - 1);
|
|
25
|
-
const plain = stripAnsi(line);
|
|
26
|
-
if (plain.length <= room)
|
|
27
|
-
return line;
|
|
28
|
-
return plain.slice(0, room - 1) + ellipsis;
|
|
29
|
-
};
|
|
19
|
+
const fitRow = (line) => fit(line, Math.max(1, caps.width - 1), ellipsis);
|
|
30
20
|
const erase = () => {
|
|
31
21
|
if (drawn === 0)
|
|
32
22
|
return;
|
|
@@ -38,14 +28,22 @@ export function createFrame(write, caps) {
|
|
|
38
28
|
write(out);
|
|
39
29
|
drawn = 0;
|
|
40
30
|
};
|
|
31
|
+
const paint = (lines) => {
|
|
32
|
+
erase();
|
|
33
|
+
lastLines = lines;
|
|
34
|
+
if (lines.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
write(lines.map(fitRow).join("\n"));
|
|
37
|
+
drawn = lines.length;
|
|
38
|
+
};
|
|
39
|
+
// Reflow the live frame at the new width; committed output above is immutable.
|
|
40
|
+
const unsubscribe = caps.onResize?.(() => paint(lastLines)) ?? null;
|
|
41
41
|
return {
|
|
42
|
-
render
|
|
42
|
+
render: paint,
|
|
43
|
+
clear() {
|
|
43
44
|
erase();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
write(lines.map(fit).join("\n"));
|
|
47
|
-
drawn = lines.length;
|
|
45
|
+
lastLines = [];
|
|
46
|
+
unsubscribe?.();
|
|
48
47
|
},
|
|
49
|
-
clear: erase,
|
|
50
48
|
};
|
|
51
49
|
}
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -117,7 +118,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
117
118
|
for (const [i, row] of visible.entries()) {
|
|
118
119
|
const selected = top + i === cursor;
|
|
119
120
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
120
|
-
|
|
121
|
+
// Middle-truncate so a long hit keeps its basename at narrow width
|
|
122
|
+
// (U.12); the match highlight survives when the label fits and is
|
|
123
|
+
// dropped (plain text) only when it must truncate — identity over decor.
|
|
124
|
+
const label = fitMiddle(highlightMatch(row.label, row.match.positions, t), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
121
125
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
122
126
|
}
|
|
123
127
|
const hidden = ranked.length - visible.length;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -29,7 +30,9 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
|
|
|
29
30
|
for (const [i, item] of visible.entries()) {
|
|
30
31
|
const selected = top + i === cursor;
|
|
31
32
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
32
|
-
|
|
33
|
+
// Middle-truncate the label so a long path keeps its basename (identity)
|
|
34
|
+
// at narrow width (U.12); reserve the marker + space.
|
|
35
|
+
const label = fitMiddle(toLabel(item), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
33
36
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
34
37
|
}
|
|
35
38
|
const hidden = items.length - visible.length;
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
export declare function credentialsPath(): string;
|
|
3
3
|
/** The stored key for `provider`, or `undefined`. Never throws. */
|
|
4
4
|
export declare function readCredential(provider: string, file?: string): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* The stored MCP bearer token for credential name `ref`, or `undefined` (C.27c).
|
|
7
|
+
* Reads ONLY this owner-only store — never the environment, never any config file
|
|
8
|
+
* — so a project config that merely NAMES a credential can never widen where the
|
|
9
|
+
* secret is sourced from. Never throws.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readMcpCredential(ref: string, file?: string): string | undefined;
|
|
12
|
+
/** Persist MCP bearer token for credential name `ref`. Same 0600/0700 as keys. */
|
|
13
|
+
export declare function writeMcpCredential(ref: string, token: string, file?: string): void;
|
|
5
14
|
/**
|
|
6
15
|
* Persist `key` for `provider`, merging into any existing store. The file is
|
|
7
16
|
* written `0600` and its directory `0700` so the secret is owner-only — enforced
|
|
@@ -39,12 +39,40 @@ export function readCredential(provider, file = credentialsPath()) {
|
|
|
39
39
|
const key = store?.keys[provider];
|
|
40
40
|
return typeof key === "string" && key !== "" ? key : undefined;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The stored MCP bearer token for credential name `ref`, or `undefined` (C.27c).
|
|
44
|
+
* Reads ONLY this owner-only store — never the environment, never any config file
|
|
45
|
+
* — so a project config that merely NAMES a credential can never widen where the
|
|
46
|
+
* secret is sourced from. Never throws.
|
|
47
|
+
*/
|
|
48
|
+
export function readMcpCredential(ref, file = credentialsPath()) {
|
|
49
|
+
const store = readStore(file);
|
|
50
|
+
const token = store?.mcp?.[ref];
|
|
51
|
+
return typeof token === "string" && token !== "" ? token : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Persist MCP bearer token for credential name `ref`. Same 0600/0700 as keys. */
|
|
54
|
+
export function writeMcpCredential(ref, token, file = credentialsPath()) {
|
|
55
|
+
writeInto(file, (store) => {
|
|
56
|
+
store.mcp ??= {};
|
|
57
|
+
store.mcp[ref] = token;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
42
60
|
/**
|
|
43
61
|
* Persist `key` for `provider`, merging into any existing store. The file is
|
|
44
62
|
* written `0600` and its directory `0700` so the secret is owner-only — enforced
|
|
45
63
|
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
46
64
|
*/
|
|
47
65
|
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
66
|
+
writeInto(file, (store) => {
|
|
67
|
+
store.keys[provider] = key;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Merge `mutate` into the store and persist it owner-only: dir `0700`, file
|
|
72
|
+
* `0600`, enforced with an explicit `chmod` after write (mkdir/write modes are
|
|
73
|
+
* umask-masked). The one write path shared by every credential namespace.
|
|
74
|
+
*/
|
|
75
|
+
function writeInto(file, mutate) {
|
|
48
76
|
const dir = dirname(file);
|
|
49
77
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
78
|
try {
|
|
@@ -55,7 +83,7 @@ export function writeCredential(provider, key, file = credentialsPath()) {
|
|
|
55
83
|
}
|
|
56
84
|
const store = readStore(file) ?? { version: CREDENTIALS_VERSION, keys: {} };
|
|
57
85
|
store.version = CREDENTIALS_VERSION;
|
|
58
|
-
store
|
|
86
|
+
mutate(store);
|
|
59
87
|
writeFileSync(file, JSON.stringify(store, null, 2) + "\n", {
|
|
60
88
|
encoding: "utf8",
|
|
61
89
|
mode: 0o600,
|
package/dist/config/manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { configInvalid, configParse } from "../errors/index.js";
|
|
3
|
+
import { configInvalid, configParse, mcpProjectHeaders, } from "../errors/index.js";
|
|
4
4
|
import { CruxyConfigSchema } from "./schema.js";
|
|
5
5
|
import { globalConfigPath, findProjectConfig } from "./paths.js";
|
|
6
6
|
import { readCredential } from "./credentials.js";
|
|
@@ -36,6 +36,29 @@ function readJsonFile(path) {
|
|
|
36
36
|
throw err;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* §0 credential-safety gate (C.27c): a project-scope config file must not set raw
|
|
41
|
+
* `mcp.servers.<id>.headers`. A live header value is a secret, and this file is a
|
|
42
|
+
* possibly-cloned repo, so a raw header here is REFUSED — loudly, naming the
|
|
43
|
+
* server — never merged and never silently dropped (a silent drop would let the
|
|
44
|
+
* repo believe auth is wired up when it is not). Only user-scope config
|
|
45
|
+
* (`~/.cruxy/config.json`) may carry raw headers; every other file (a discovered
|
|
46
|
+
* project config, or an explicit `--config` that may point into a repo) is
|
|
47
|
+
* project scope. A project config may still NAME a credential via `credentialRef`.
|
|
48
|
+
*/
|
|
49
|
+
function rejectProjectScopeHeaders(obj, file) {
|
|
50
|
+
const mcp = obj.mcp;
|
|
51
|
+
if (!isPlainObject(mcp))
|
|
52
|
+
return;
|
|
53
|
+
const servers = mcp.servers;
|
|
54
|
+
if (!isPlainObject(servers))
|
|
55
|
+
return;
|
|
56
|
+
for (const [id, server] of Object.entries(servers)) {
|
|
57
|
+
if (isPlainObject(server) && "headers" in server) {
|
|
58
|
+
throw mcpProjectHeaders(id, file);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
39
62
|
/** Overrides sourced from environment variables. */
|
|
40
63
|
function envOverrides() {
|
|
41
64
|
const out = {};
|
|
@@ -67,13 +90,17 @@ export function loadConfig(opts = {}) {
|
|
|
67
90
|
sources.global = gPath;
|
|
68
91
|
}
|
|
69
92
|
if (opts.configPath) {
|
|
70
|
-
|
|
93
|
+
const obj = readJsonFile(opts.configPath);
|
|
94
|
+
rejectProjectScopeHeaders(obj, opts.configPath);
|
|
95
|
+
merged = deepMerge(merged, obj);
|
|
71
96
|
sources.explicit = opts.configPath;
|
|
72
97
|
}
|
|
73
98
|
else {
|
|
74
99
|
const pPath = findProjectConfig(opts.cwd);
|
|
75
100
|
if (pPath) {
|
|
76
|
-
|
|
101
|
+
const obj = readJsonFile(pPath);
|
|
102
|
+
rejectProjectScopeHeaders(obj, pPath);
|
|
103
|
+
merged = deepMerge(merged, obj);
|
|
77
104
|
sources.project = pPath;
|
|
78
105
|
}
|
|
79
106
|
}
|