@yagni-app/code-staging 1.1.4-staging.1426.1 → 1.1.5-staging.1430.1

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/doctor.d.ts CHANGED
@@ -147,6 +147,29 @@ export interface McpProbe {
147
147
  * pending-approval cases read as `warn`, config errors as `fail` (non-required).
148
148
  */
149
149
  export declare function checkMcpConfig(probe: McpProbe): CheckResult;
150
+ /** Config-only snapshot of the Claude Code MCP bridge (claudeImport.ts). */
151
+ export interface ClaudeImportProbe {
152
+ /** `~/.claude.json` exists (and the bridge is not disabled). */
153
+ exists: boolean;
154
+ /** Set when the file exists but could not be parsed. */
155
+ parseError?: string;
156
+ /** The same one-line notice the session-start banner shows, when the gap is non-empty. */
157
+ notice?: string;
158
+ /** Claude Code server names not configured here. */
159
+ importable: string[];
160
+ /** claude.ai connectors with a public MCP equivalent not configured here. */
161
+ connectorSuggestions: string[];
162
+ /** Claude Code has `.mcp.json` approvals for this checkout that ours lack. */
163
+ approvals: boolean;
164
+ /** The probe itself failed (bundled extension broken, config unreadable) while ~/.claude.json exists. */
165
+ probeError?: string;
166
+ }
167
+ /**
168
+ * Advisory (never required) line for the Claude Code bridge. Absent entirely
169
+ * when there is no `~/.claude.json` — a machine without Claude Code has
170
+ * nothing to say here. Reads config only; never connects.
171
+ */
172
+ export declare function checkClaudeImport(probe: ClaudeImportProbe): CheckResult | null;
150
173
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
151
174
  export interface BashProbe {
152
175
  found: boolean;
@@ -179,6 +202,8 @@ export interface DoctorDeps {
179
202
  probeLatestVersion?: () => Promise<string | null>;
180
203
  /** MCP config snapshot (config-only, no connections). */
181
204
  probeMcp?: () => Promise<McpProbe>;
205
+ /** Claude Code bridge snapshot (config-only). */
206
+ probeClaudeImport?: () => Promise<ClaudeImportProbe>;
182
207
  /** Certificate-authority configuration of the running process. */
183
208
  probeCaTrust?: () => CaTrustProbe;
184
209
  /** OTel gate resolution (env / workspace / repo settings); tests stub it. */
package/dist/doctor.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * Advisory checks (loose perms, missing `gh`) never flip the exit code.
14
14
  */
15
15
  import { existsSync, readFileSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
16
17
  import { delimiter, join } from "node:path";
17
18
  import { credentialsDir } from "./credentials.js";
18
19
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
@@ -20,6 +21,7 @@ import { classifyTokenExpiry } from "./launch.js";
20
21
  import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
21
22
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
22
23
  import { readActiveProfile } from "./profiles.js";
24
+ import { DISTRIBUTION } from "./distribution.js";
23
25
  import { resolveMcpConfigPath } from "./mcpCommand.js";
24
26
  import { MIN_NODE_VERSION, nodeVersionSatisfies } from "./nodeVersion.js";
25
27
  import { EXTRA_CA_ENV, SYSTEM_CA_ENV, SYSTEM_CA_FLAG, childCaEnv, sessionExecArgv, findTlsCertError, nodeOptionsHaveSystemCa, systemCaEnabled, systemCaEnvSupported, systemCaFlagSupported, } from "./tlsTrust.js";
@@ -414,6 +416,52 @@ export function checkMcpConfig(probe) {
414
416
  required: false,
415
417
  };
416
418
  }
419
+ /**
420
+ * Advisory (never required) line for the Claude Code bridge. Absent entirely
421
+ * when there is no `~/.claude.json` — a machine without Claude Code has
422
+ * nothing to say here. Reads config only; never connects.
423
+ */
424
+ export function checkClaudeImport(probe) {
425
+ if (!probe.exists)
426
+ return null;
427
+ if (probe.probeError) {
428
+ return {
429
+ name: "claude code mcp",
430
+ status: "warn",
431
+ detail: `could not compare with ~/.claude.json (${probe.probeError})`,
432
+ hint: "run `yagni mcp add-from-claude` for the full error, or `yagni upgrade` if the extension is missing",
433
+ required: false,
434
+ };
435
+ }
436
+ if (probe.parseError) {
437
+ return {
438
+ name: "claude code mcp",
439
+ status: "warn",
440
+ detail: `~/.claude.json unreadable (${probe.parseError})`,
441
+ hint: "fix the file so Claude Code's MCP servers can be imported",
442
+ required: false,
443
+ };
444
+ }
445
+ const gaps = [];
446
+ if (probe.importable.length > 0) {
447
+ gaps.push(`${probe.importable.length} server${probe.importable.length === 1 ? "" : "s"} not imported (${probe.importable.join(", ")})`);
448
+ }
449
+ if (probe.approvals)
450
+ gaps.push("repo approvals not imported");
451
+ if (probe.connectorSuggestions.length > 0) {
452
+ gaps.push(`${probe.connectorSuggestions.length} claude.ai connector${probe.connectorSuggestions.length === 1 ? "" : "s"} with a public MCP (${probe.connectorSuggestions.join(", ")})`);
453
+ }
454
+ if (gaps.length === 0) {
455
+ return { name: "claude code mcp", status: "ok", detail: "in sync with ~/.claude.json", required: false };
456
+ }
457
+ return {
458
+ name: "claude code mcp",
459
+ status: "warn",
460
+ detail: gaps.join("; "),
461
+ hint: probe.notice ?? "run `yagni mcp add-from-claude`",
462
+ required: false,
463
+ };
464
+ }
417
465
  export function checkBash(probe) {
418
466
  if (!probe.found) {
419
467
  return {
@@ -501,6 +549,43 @@ function defaultProbeStateDir() {
501
549
  return { path, exists: false, mode: null };
502
550
  }
503
551
  }
552
+ async function defaultProbeClaudeImport(env = process.env) {
553
+ const none = { exists: false, importable: [], connectorSuggestions: [], approvals: false };
554
+ if (env.YAGNI_CODE_MCP_DISABLED === "1")
555
+ return none;
556
+ try {
557
+ const mod = (await import(resolveMcpConfigPath()));
558
+ if (typeof mod?.readClaudeMcpInventory !== "function" ||
559
+ typeof mod.claudeImportGap !== "function" ||
560
+ typeof mod.claudeImportNotice !== "function") {
561
+ return none;
562
+ }
563
+ const cwd = process.cwd();
564
+ const repoRoot = mod.resolveProjectRoot(cwd);
565
+ const inventory = mod.readClaudeMcpInventory({ cwd, repoRoot, env });
566
+ if (!inventory.exists)
567
+ return none;
568
+ if (inventory.parseError)
569
+ return { ...none, exists: true, parseError: inventory.parseError };
570
+ const loaded = mod.loadMcpServers(cwd, env);
571
+ const { state } = mod.readProjectApproval(repoRoot);
572
+ const gap = mod.claudeImportGap(inventory, loaded.servers, state, DISTRIBUTION.commandName);
573
+ return {
574
+ exists: true,
575
+ notice: mod.claudeImportNotice(gap, DISTRIBUTION.commandName),
576
+ importable: gap.importable.map((c) => c.name),
577
+ connectorSuggestions: gap.connectorSuggestions.map((c) => c.name),
578
+ approvals: gap.approvals !== undefined,
579
+ };
580
+ }
581
+ catch (err) {
582
+ // Advisory only: a probe that cannot run must never fail doctor — but a
583
+ // machine that HAS ~/.claude.json must not read as "no Claude Code".
584
+ if (!existsSync(join(homedir(), ".claude.json")))
585
+ return none;
586
+ return { ...none, exists: true, probeError: err instanceof Error ? err.message : String(err) };
587
+ }
588
+ }
504
589
  async function defaultProbeMcp(env = process.env) {
505
590
  const disabled = env.YAGNI_CODE_MCP_DISABLED === "1";
506
591
  if (disabled)
@@ -633,6 +718,7 @@ export async function gatherChecks(deps = {}) {
633
718
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
634
719
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
635
720
  const probeMcp = deps.probeMcp ?? (() => defaultProbeMcp());
721
+ const probeClaudeImport = deps.probeClaudeImport ?? (() => defaultProbeClaudeImport());
636
722
  const probeCaTrust = deps.probeCaTrust ?? (() => defaultProbeCaTrust());
637
723
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
638
724
  const platform = deps.platform ?? process.platform;
@@ -665,6 +751,9 @@ export async function gatherChecks(deps = {}) {
665
751
  checks.push(checkStateDir(probeStateDir()));
666
752
  checks.push(checkGh(ghOnPath()));
667
753
  checks.push(checkMcpConfig(await probeMcp()));
754
+ const claudeImport = checkClaudeImport(await probeClaudeImport());
755
+ if (claudeImport)
756
+ checks.push(claudeImport);
668
757
  const resolveOtel = deps.resolveOtel ??
669
758
  ((p) => resolveOtelLaunchWithWorkspace({
670
759
  env: process.env,
@@ -18,9 +18,11 @@
18
18
  * high port (or a fixed `oauth.callbackPort`), redirect_uri path `/callback`,
19
19
  * and validate the returned `state` to prevent CSRF.
20
20
  */
21
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
22
  import { type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
22
23
  import type { AuthorizationServerMetadata, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
23
24
  import type { McpHttpServerConfig } from "./config.js";
25
+ import { type StoredOAuthEntry } from "./authStore.js";
24
26
  export interface AuthDeps {
25
27
  /** Open a URL in the browser; injected for tests, default is a best-effort opener. */
26
28
  openUrl?: (url: string) => Promise<void>;
@@ -57,8 +59,12 @@ export declare const DEFAULT_REDIRECT_PORT = 42000;
57
59
  /**
58
60
  * Build a provider for a server at connect time. The redirect URL is only used
59
61
  * when the SDK needs to (re)authorize; refresh- and access-token paths ignore
60
- * it, so a stable default port is fine here. `authenticate()` binds its own
61
- * fresh port and builds a dedicated provider for the interactive flow.
62
+ * it. A 401 at connect time with no stored client makes the SDK run dynamic
63
+ * client registration through THIS provider, so the port it advertises is the
64
+ * one the server pins — `authenticate()` reads it back from the auth store
65
+ * (`redirectUri`) and reuses it. Precedence: explicit `redirectUrl` >
66
+ * `oauth.callbackPort` > the redirect a previous registration pinned > the
67
+ * stable default port.
62
68
  */
63
69
  export declare function authProviderForServer(serverName: string, config: McpHttpServerConfig, redirectUrl?: string): YagniAuthProvider;
64
70
  /**
@@ -93,8 +99,51 @@ export declare function authenticate(serverName: string, config: McpHttpServerCo
93
99
  timeoutMs?: number;
94
100
  serverUrl?: string;
95
101
  }): Promise<OAuthResult>;
96
- /** Find a free loopback port (OS-assigned). */
97
- export declare function findFreePort(): Promise<number>;
102
+ type CallbackHandler = (req: IncomingMessage, res: ServerResponse) => void;
103
+ export interface RedirectListener {
104
+ server: ReturnType<typeof createServer>;
105
+ port: number;
106
+ /** Install the callback handler (waitForCode); until then non-marker requests get 404. */
107
+ arm(handler: CallbackHandler): void;
108
+ /** Set when the pinned port could not be bound and a fresh one was bound instead: the bind error. */
109
+ pinnedBindError?: string;
110
+ }
111
+ /**
112
+ * Every listener answers this path with a fixed marker, so a second flow that
113
+ * finds the pinned port taken can tell "one of our own callback listeners"
114
+ * (refuse: that flow's client must not be dropped from under it) from "some
115
+ * other program" (fresh port, re-register). Loopback only, by design: the
116
+ * answer is a constant and names nothing — not the server, not the flow.
117
+ */
118
+ export declare const LISTENER_MARKER_PATH = "/.yagni-oauth-listener";
119
+ export declare const LISTENER_MARKER_HEADER = "x-yagni-oauth-listener";
120
+ /**
121
+ * Bind the loopback listener for an interactive flow and return the socket
122
+ * we hold, so the redirect_uri we advertise is the port we are listening on
123
+ * — decided by an actual bind, not a probe. A fixed `oauth.callbackPort`
124
+ * always wins (the operator registered it; a bind failure there is the
125
+ * error, not a fallback). Otherwise reuse the port a previous dynamic
126
+ * registration pinned when it can still be bound — the authorization server
127
+ * will reject any other redirect_uri for that client. When it cannot: one of
128
+ * our own listeners holding it means a concurrent flow for this server (an
129
+ * error; its client stays); anything else means a fresh OS-assigned port,
130
+ * and the caller re-registers the client. The probe's verdict is logged as
131
+ * `oauth_pinned_port_probe` so the later `oauth_client_reregister` reason
132
+ * is auditable.
133
+ */
134
+ export declare function bindRedirectListener(serverName: string, config: McpHttpServerConfig, stored: Pick<StoredOAuthEntry, "clientId" | "redirectUri"> | undefined): Promise<RedirectListener>;
135
+ export interface ListenerProbe {
136
+ /** `ours`: one of our callback listeners answered; `foreign`: something else answered; `no_answer`: refused, hung, or garbled. */
137
+ outcome: "ours" | "foreign" | "no_answer";
138
+ /** What the probe saw, for the log. */
139
+ detail: string;
140
+ }
141
+ /** Ask whoever holds `port` whether it is one of our callback listeners. Bounded (500ms), never throws. */
142
+ export declare function probeListener(port: number): Promise<ListenerProbe>;
143
+ /** Bind `port` (0 = OS-assigned) on 127.0.0.1; rejects when it cannot be bound. */
144
+ export declare function listenOnLoopback(port: number): Promise<RedirectListener>;
145
+ /** The port of a stored loopback redirect_uri, or undefined when it is not ours to parse. */
146
+ export declare function portOfRedirectUri(redirectUri: string | undefined): number | undefined;
98
147
  export declare function buildRedirectUri(port: number): string;
99
148
  type CodeOutcomeEvent = "oauth_callback_code" | "oauth_callback_error" | "oauth_callback_state_mismatch" | "oauth_timeout" | "oauth_cancelled";
100
149
  export interface WaitForCodeOpts {