Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/cli/client-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface ClientSnippetInput {
serverName: string;
configPath: string;
launcher: ClientLauncher;
/** Validated names only; client JSON never receives environment values. */
requiredEnvironmentVariables?: readonly string[];
/** True when Miftah may resolve those names from configured environment files at runtime. */
environmentFilesConfigured?: boolean;
}

export interface ClientSnippet {
Expand Down Expand Up @@ -57,9 +61,16 @@ const targetLabels: Record<ClientName, string> = {
cursor: "Cursor .cursor/mcp.json",
vscode: "VS Code .vscode/mcp.json"
};
const clientDisplayNames: Record<ClientName, string> = {
"claude-desktop": "Claude Desktop",
"claude-code": "Claude Code",
cursor: "Cursor",
vscode: "VS Code"
};

const claudeCodePermissionTarget = { label: "Claude Code settings permissions" };
const literalClaudeCodeServerName = /^[A-Za-z0-9-]+$/u;
const environmentVariableName = /^[A-Za-z_][A-Za-z0-9_]*$/u;
const clientProfileGuidance =
"One Miftah connector serves every named profile in this configuration. Merge this one entry, then select accounts through Miftah instead of adding duplicate client entries. The generated JSON contains launcher and configuration-path metadata, never credential values. A generated entry does not prove that a credential works or belongs to the intended account.";

Expand Down Expand Up @@ -118,6 +129,35 @@ function validateInput(input: ClientSnippetInput): void {
if (launcherArguments.some((argument) => argument === "--config" || argument.startsWith("--config="))) {
inputError("Launcher arguments must not include '--config'; the snippet supplies it.");
}
if (
input.requiredEnvironmentVariables !== undefined &&
(
!Array.isArray(input.requiredEnvironmentVariables) ||
input.requiredEnvironmentVariables.some((name) => typeof name !== "string" || !environmentVariableName.test(name))
)
) {
inputError("Every required environment variable must use a valid environment variable name.");
}
if (
input.environmentFilesConfigured !== undefined &&
typeof input.environmentFilesConfigured !== "boolean"
) {
inputError("Environment-file configuration must be a boolean.");
}
}

function guidance(client: ClientName, input: ClientSnippetInput): string {
const required = [...new Set(input.requiredEnvironmentVariables ?? [])].sort();
if (required.length === 0) return clientProfileGuidance;
const names = required.join(", ");
const readinessInstruction = input.environmentFilesConfigured === true
? `make sure the Miftah process it launches can resolve ${names} from its inherited environment or configured secrets.envFiles.`
: `make sure it passes ${names} to the Miftah process it launches.`;
return (
`${clientProfileGuidance} ` +
`Before restarting ${clientDisplayNames[client]}, ${readinessInstruction} ` +
"The generated JSON does not set or contain that secret."
);
}

function renderedServer(input: ClientSnippetInput): { command: string; args: string[] } {
Expand Down Expand Up @@ -157,7 +197,7 @@ export function renderClientSnippet(client: ClientName, input: ClientSnippetInpu
return {
client,
target: { label: targetLabels[client] },
guidance: clientProfileGuidance,
guidance: guidance(client, input),
json: JSON.stringify(renderConfiguration(client, input), undefined, 2)
};
}
Expand Down
45 changes: 29 additions & 16 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
type SetupConfigurationPlan,
type SetupConfigurationPreview
} from "../setup/setup-configuration.js";
import type { SetupCompletionClientHandoff } from "../setup/setup-completion.js";
import {
environmentReferencesFromConfig,
type SetupCompletionClientHandoff
} from "../setup/setup-completion.js";
import type { SetupDraftInput, SetupDraftStore } from "../setup/setup-draft.js";
import {
CLIENT_NAMES,
Expand Down Expand Up @@ -60,6 +63,8 @@ export interface InitCommandContext {
readonly output: Writable & { readonly isTTY?: boolean };
readonly cwd: string;
readonly launcher: ClientLauncher;
/** Guided setup defers generated client JSON until readiness boundaries are shown. */
readonly deferClientHandoff?: boolean;
/** Internal test/runtime seam for endpoint-first OAuth metadata discovery. */
readonly nativeOAuthFetch?: FetchLike;
/** Shared private checkpoint store used only by the guided `setup` command. */
Expand Down Expand Up @@ -92,8 +97,10 @@ export interface InitCommandResult {
readonly output: string;
readonly config: MiftahConfig;
readonly providerAdapter?: ProviderAdapterDefinition;
/** Whether this invocation displayed copy-only client JSON. */
/** Whether copy-only client JSON was generated for display or guided deferral. */
readonly clientHandoff?: SetupCompletionClientHandoff;
/** Names/path-only client handoff deferred by guided setup; it never contains credential values. */
readonly deferredClientHandoff?: string;
}

/**
Expand Down Expand Up @@ -530,7 +537,9 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla
snippets = renderClientSnippets(values.client, {
serverName: config.name,
configPath: output,
launcher: context.launcher
launcher: context.launcher,
requiredEnvironmentVariables: environmentReferencesFromConfig(config),
environmentFilesConfigured: (config.secrets?.envFiles?.length ?? 0) > 0
});
} catch (error) {
if (error instanceof ClientSnippetError) throw new CliUsageError(error.message);
Expand Down Expand Up @@ -591,24 +600,22 @@ function writeProviderAdapterGuidance(output: Writable, adapter: ProviderAdapter
);
}

/** Writes copy-paste client configuration and optional Claude Code review guidance without modifying client settings. */
function writeSnippets(
output: Writable,
/** Formats copy-paste client configuration and optional Claude Code review guidance without modifying client settings. */
function formatSnippets(
snippets: readonly ClientSnippet[],
claudeCodePermissionGuidance: ClaudeCodePermissionGuidance | undefined
): void {
): string {
let output = "";
for (const snippet of snippets) {
output.write(formatClientSnippetHandoff(snippet));
output += formatClientSnippetHandoff(snippet);
}
if (claudeCodePermissionGuidance === undefined) return;
if (claudeCodePermissionGuidance === undefined) return output;
if (claudeCodePermissionGuidance.kind === "manual") {
output.write(`${claudeCodePermissionGuidance.target.label}:\n${claudeCodePermissionGuidance.message}\n`);
return;
return output + `${claudeCodePermissionGuidance.target.label}:\n${claudeCodePermissionGuidance.message}\n`;
}
output.write(
return output +
`${claudeCodePermissionGuidance.target.label}:\n${claudeCodePermissionGuidance.json}\n` +
"Manually merge this fragment into .claude/settings.local.json, .claude/settings.json, or ~/.claude/settings.json. It is client-side defense in depth; Miftah enforces authorization.\n"
);
"Manually merge this fragment into .claude/settings.local.json, .claude/settings.json, or ~/.claude/settings.json. It is client-side defense in depth; Miftah enforces authorization.\n";
}

/** Creates a strict catalog config and optionally prints copy-paste client snippets. */
Expand All @@ -629,11 +636,17 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC
}
context.output.write(`Created ${plan.output}\n`);
writeProviderAdapterGuidance(context.output, plan.providerAdapter);
writeSnippets(context.output, plan.snippets, plan.claudeCodePermissionGuidance);
const clientHandoffOutput = formatSnippets(plan.snippets, plan.claudeCodePermissionGuidance);
if (context.deferClientHandoff !== true && clientHandoffOutput !== "") {
context.output.write(clientHandoffOutput);
}
return {
output: plan.output,
config: plan.config,
providerAdapter: plan.providerAdapter,
clientHandoff: plan.snippets.length === 0 ? "not-generated" : "shown"
clientHandoff: plan.snippets.length === 0 ? "not-generated" : "shown",
...(context.deferClientHandoff === true && clientHandoffOutput !== ""
? { deferredClientHandoff: clientHandoffOutput }
: {})
};
}
5 changes: 4 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { startConsoleServer } from "../console/console-server.js";
import { openSystemBrowser } from "../console/open-browser.js";
import { ConsoleDashboardApplicationService } from "../console/console-dashboard-application-service.js";
import { FileSetupDraftStore } from "../setup/setup-draft.js";
import { environmentReferencesFromConfig } from "../setup/setup-completion.js";

function oauthSelector(args: { readonly connection?: string; readonly profile?: string; readonly upstream?: string }) {
return {
Expand Down Expand Up @@ -297,7 +298,9 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
: renderClientSnippets(clientSelection(args.client), {
serverName: config.name,
configPath: resolvePath(args.config),
launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] }
launcher: { command: process.execPath, args: [fileURLToPath(import.meta.url), "serve"] },
requiredEnvironmentVariables: environmentReferencesFromConfig(config),
environmentFilesConfigured: (config.secrets?.envFiles?.length ?? 0) > 0
});
process.stdout.write(`${JSON.stringify({ connections, ...(snippets === undefined ? {} : { snippets }) }, null, 2)}\n`);
return;
Expand Down
47 changes: 39 additions & 8 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import { runProviderAccountSetup } from "./setup-provider-account.js";
import { runEnvironmentProfileSetup } from "./setup-environment-profile.js";
import {
createSetupCompletion,
inspectConfigEnvironment,
type SetupCompletionClientHandoff,
type SetupEnvironmentReadiness,
type SetupCompletionVerification
} from "../setup/setup-completion.js";
import type { SetupDraft, SetupDraftStore } from "../setup/setup-draft.js";
Expand Down Expand Up @@ -341,19 +343,31 @@ function writeCliSetupCompletion(
readonly profile?: string;
readonly configPath?: string;
readonly includeClientHandoff?: boolean;
readonly environment?: SetupEnvironmentReadiness;
readonly deferredClientHandoff?: string;
}
): void {
const completion = createSetupCompletion({
surface: "cli",
verification: input.verification,
clientHandoff: input.clientHandoff,
...(input.profile === undefined ? {} : { profile: input.profile }),
...(input.configPath === undefined ? {} : { configPath: input.configPath })
...(input.configPath === undefined ? {} : { configPath: input.configPath }),
...(input.environment === undefined ? {} : { environment: input.environment })
});
context.output.write(`${completion.verification.message}\n`);
if (completion.verification.nextAction !== undefined) {
context.output.write(`${completion.verification.nextAction}\n`);
}
if (completion.environment !== undefined) {
context.output.write(`${completion.environment.message}\n`);
if (completion.environment.nextAction !== undefined) {
context.output.write(`${completion.environment.nextAction}\n`);
}
}
if (input.deferredClientHandoff !== undefined) {
context.output.write(input.deferredClientHandoff);
}
if (input.includeClientHandoff !== false) {
context.output.write(`${completion.clientHandoff.message}\n`);
}
Expand All @@ -364,6 +378,7 @@ async function finishCreatedSetup(
context: InitCommandContext,
created: InitCommandResult
): Promise<SetupCommandResult> {
const environment = inspectConfigEnvironment(created.config);
if (
created.config.version === "3" &&
created.config.upstream?.transport === "streamable-http" &&
Expand All @@ -377,7 +392,11 @@ async function finishCreatedSetup(
writeCliSetupCompletion(context, {
verification: "not-declared",
clientHandoff: created.clientHandoff ?? "not-generated",
configPath: created.output
configPath: created.output,
environment,
...(created.deferredClientHandoff === undefined
? {}
: { deferredClientHandoff: created.deferredClientHandoff })
});
return { verification: "not-applicable", exitCode: 0, reports: [] };
}
Expand All @@ -388,7 +407,11 @@ async function finishCreatedSetup(
verification: "skipped",
clientHandoff: created.clientHandoff ?? "not-generated",
profile: created.config.defaultProfile,
configPath: created.output
configPath: created.output,
environment,
...(created.deferredClientHandoff === undefined
? {}
: { deferredClientHandoff: created.deferredClientHandoff })
});
return { verification: "skipped", exitCode: 0, reports: [] };
}
Expand All @@ -398,7 +421,11 @@ async function finishCreatedSetup(
verification: "incomplete",
clientHandoff: created.clientHandoff ?? "not-generated",
profile: created.config.defaultProfile,
configPath: created.output
configPath: created.output,
environment,
...(created.deferredClientHandoff === undefined
? {}
: { deferredClientHandoff: created.deferredClientHandoff })
});
return { verification: "incomplete", exitCode: 1, reports: [] };
}
Expand All @@ -421,7 +448,11 @@ async function finishCreatedSetup(
verification: incomplete ? "incomplete" : "complete",
clientHandoff: created.clientHandoff ?? "not-generated",
...(incomplete ? { profile: created.config.defaultProfile } : {}),
configPath: created.output
configPath: created.output,
environment,
...(created.deferredClientHandoff === undefined
? {}
: { deferredClientHandoff: created.deferredClientHandoff })
});
return { verification: incomplete ? "incomplete" : "complete", exitCode: incomplete ? 1 : 0, reports };
}
Expand Down Expand Up @@ -519,7 +550,7 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
interactive: true,
name: draft.name,
preset: draft.preset
}, context);
}, { ...context, deferClientHandoff: true });
await discardPublishedSetupDraft(store, draft.revision, context);
return finishCreatedSetup(options, context, created);
}
Expand Down Expand Up @@ -711,7 +742,7 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
output: imported.output,
interactive: true,
...(imported.client === undefined ? {} : { client: imported.client })
}, context);
}, { ...context, deferClientHandoff: true });
}
if (recoveredManualConfiguration === undefined && startingPoint === "remote-sign-in") {
await runNativeOAuthSetup(options, context, {
Expand Down Expand Up @@ -747,7 +778,7 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
...options,
interactive: true,
...(guidedPreset === undefined ? {} : { preset: guidedPreset })
}, initContext);
}, { ...initContext, deferClientHandoff: true });
if (checkpointedDraft !== undefined && checkpointStore !== undefined) {
await discardPublishedSetupDraft(checkpointStore, checkpointedDraft.revision, context);
}
Expand Down
15 changes: 12 additions & 3 deletions src/console/console-application-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import {
publishFirstRunSetupConfigurationPlan
} from "../setup/setup-configuration.js";
import type { SetupConfigurationPreview } from "../setup/setup-configuration.js";
import { createSetupCompletion, type SetupCompletion } from "../setup/setup-completion.js";
import {
createSetupCompletion,
environmentReferencesFromConfig,
inspectConfigEnvironment,
type SetupCompletion
} from "../setup/setup-completion.js";
import {
planNativeOAuthFirstRunConfiguration,
runNativeOAuthAccountAddition,
Expand Down Expand Up @@ -457,10 +462,12 @@ function safeAuditRecord(value: unknown): ConsoleAuditRecord | undefined {
function firstRunSetupCompletion(config: MiftahConfig): SetupCompletion {
const metadata = consoleInitializedConfigMetadata(config);
const hasDeclaredSafeCheck = (metadata.authentication?.readinessTargets?.length ?? 0) > 0;
const environment = inspectConfigEnvironment(config);
return createSetupCompletion({
surface: "console",
verification: hasDeclaredSafeCheck ? "available" : "not-declared",
clientHandoff: "available"
clientHandoff: "available",
environment
});
}

Expand Down Expand Up @@ -929,7 +936,9 @@ export class ConsoleApplicationService implements ConsoleControlApplication {
return renderClientSnippets(selection, {
serverName: config.name,
configPath: resolvePath(this.configPath),
launcher: this.launcher
launcher: this.launcher,
requiredEnvironmentVariables: environmentReferencesFromConfig(config),
environmentFilesConfigured: (config.secrets?.envFiles?.length ?? 0) > 0
});
}

Expand Down
Loading