@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.
Files changed (54) hide show
  1. package/dist/approval/prompt.d.ts +7 -1
  2. package/dist/approval/prompt.js +52 -17
  3. package/dist/cli/commands/mcp.js +106 -7
  4. package/dist/cli/commands/skills.js +10 -2
  5. package/dist/cli/repl.js +9 -3
  6. package/dist/components/frame.d.ts +6 -3
  7. package/dist/components/frame.js +21 -23
  8. package/dist/components/fuzzy.js +5 -1
  9. package/dist/components/select.js +4 -1
  10. package/dist/config/credentials.d.ts +9 -0
  11. package/dist/config/credentials.js +29 -1
  12. package/dist/config/manager.js +30 -3
  13. package/dist/config/schema.d.ts +182 -8
  14. package/dist/config/schema.js +43 -5
  15. package/dist/errors/constructors.d.ts +29 -0
  16. package/dist/errors/constructors.js +69 -0
  17. package/dist/errors/types.d.ts +15 -0
  18. package/dist/errors/types.js +18 -0
  19. package/dist/mcp/http-transport.d.ts +89 -0
  20. package/dist/mcp/http-transport.js +299 -0
  21. package/dist/mcp/index.d.ts +4 -2
  22. package/dist/mcp/index.js +3 -1
  23. package/dist/mcp/service.d.ts +19 -2
  24. package/dist/mcp/service.js +92 -20
  25. package/dist/mcp/trust-gate.d.ts +35 -11
  26. package/dist/mcp/trust-gate.js +87 -22
  27. package/dist/mcp/trust.d.ts +12 -2
  28. package/dist/mcp/trust.js +26 -2
  29. package/dist/mcp/types.d.ts +10 -0
  30. package/dist/mcp/url-guard.d.ts +48 -0
  31. package/dist/mcp/url-guard.js +62 -0
  32. package/dist/net/ip-guard.d.ts +55 -0
  33. package/dist/net/ip-guard.js +229 -0
  34. package/dist/render/capabilities.d.ts +11 -0
  35. package/dist/render/capabilities.js +19 -3
  36. package/dist/render/diff.d.ts +1 -1
  37. package/dist/render/diff.js +23 -7
  38. package/dist/render/index.d.ts +5 -2
  39. package/dist/render/index.js +9 -2
  40. package/dist/render/layout.d.ts +59 -0
  41. package/dist/render/layout.js +158 -0
  42. package/dist/render/motion.d.ts +76 -0
  43. package/dist/render/motion.js +94 -0
  44. package/dist/render/resize.d.ts +36 -0
  45. package/dist/render/resize.js +45 -0
  46. package/dist/render/state.d.ts +13 -0
  47. package/dist/render/state.js +38 -0
  48. package/dist/render/tty-renderer.d.ts +25 -3
  49. package/dist/render/tty-renderer.js +94 -32
  50. package/dist/render/types.d.ts +15 -1
  51. package/dist/web/ssrf.d.ts +8 -22
  52. package/dist/web/ssrf.js +11 -183
  53. package/dist/web/types.d.ts +4 -2
  54. 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;
@@ -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 three degradations (U.11): the mark carries it by *shape*
41
- // (`!` vs `?`) for NO_COLOR, and the label carries it by *word*
42
- // (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone, and legible
43
- // to a screen reader that would otherwise read `!` as "exclamation mark".
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(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
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
- return [
68
- ` ${t.muted("$")} ${request.action.command ?? ""}`,
69
- ` ${t.muted(`in ${request.cwd}`)}`,
70
- ].join("\n");
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
- return ` ${t.muted(`external MCP server "${request.action.server ?? ""}" runs unsandboxed with your privileges`)}`;
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. */
@@ -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 { shouldUseColor } from "../../errors/index.js";
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
- logger.print(t.danger(t.strong(`trusting ${names.length} MCP server${names.length === 1 ? "" : "s"} — these run their own code UNSANDBOXED with your privileges:`)));
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 will require re-trusting."));
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
- return cfg.url ? `(url: ${cfg.url})` : "(no transport)";
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
- for (const { source, dir } of status.sources) {
38
- logger.print(` ${t.kv(source, t.muted(dir), 8)}`);
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
- logger.print(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`);
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 declare function stripAnsi(text: string): string;
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;
@@ -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
- /** SGR escape sequences (the only ANSI the components emit via picocolors). */
7
- // eslint-disable-next-line no-control-regex
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. A row that fits passes through
20
- * styled; an overflowing row is truncated on its stripped text (style is
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 fit = (line) => {
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(lines) {
42
+ render: paint,
43
+ clear() {
43
44
  erase();
44
- if (lines.length === 0)
45
- return;
46
- write(lines.map(fit).join("\n"));
47
- drawn = lines.length;
45
+ lastLines = [];
46
+ unsubscribe?.();
48
47
  },
49
- clear: erase,
50
48
  };
51
49
  }
@@ -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
- const label = highlightMatch(row.label, row.match.positions, t);
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
- const label = toLabel(item);
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.keys[provider] = key;
86
+ mutate(store);
59
87
  writeFileSync(file, JSON.stringify(store, null, 2) + "\n", {
60
88
  encoding: "utf8",
61
89
  mode: 0o600,
@@ -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
- merged = deepMerge(merged, readJsonFile(opts.configPath));
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
- merged = deepMerge(merged, readJsonFile(pPath));
101
+ const obj = readJsonFile(pPath);
102
+ rejectProjectScopeHeaders(obj, pPath);
103
+ merged = deepMerge(merged, obj);
77
104
  sources.project = pPath;
78
105
  }
79
106
  }