fix(setup): omit aligned client configurations from plan

The setup plan was still advertising configure_client actions for clients
whose MCP registration already matched the desired command and Hopper
environment. As a result, an otherwise idempotent rerun requested
confirmation for changes that would not be applied.

- add SetupHost#clientNeedsConfigure to read a client config without
  mutating it
- filter detected clients in discoverSetupActions before building the plan
- align the package verifier's "aligned" run with the platform-specific
  status so Linux (where the fake Hopper path is unsupported) is expected
  to report needs_human while keeping an empty plan
This commit is contained in:
morluto
2026-07-14 22:00:42 +00:00
parent a5976396eb
commit a86da4ff3f
3 changed files with 78 additions and 4 deletions
+2 -2
View File
@@ -387,8 +387,8 @@ try {
second.status !== status ||
firstExecution.status !== (status === "ready" ? 0 : 1) ||
secondExecution.status !== (status === "ready" ? 0 : 1) ||
aligned.status !== "ready" ||
alignedExecution.status !== 0 ||
aligned.status !== status ||
alignedExecution.status !== (status === "ready" ? 0 : 1) ||
aligned.plannedActions.length !== 0 ||
aligned.appliedActions.length !== 0 ||
second.appliedActions.length !== 0
+72 -2
View File
@@ -75,6 +75,11 @@ export interface SetupHost {
hopperPath: string | undefined,
command: readonly string[],
): Promise<ClientConfigurationResult>;
clientNeedsConfigure(
client: SetupClient,
hopperPath: string | undefined,
command: readonly string[],
): Promise<boolean>;
skillNeedsInstall(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
@@ -223,20 +228,43 @@ const discoverSetupActions = async (
host.detectedClients(),
host.skillNeedsInstall(),
]);
const command = registrationCommand();
const clients = installHopper
? detectedClients
: await filterClientsNeedingConfigure(
host,
detectedClients,
hopperPath,
command,
);
const plannedActions = setupPlan(
host.platform,
installHopper,
installSkill,
detectedClients,
clients,
);
return {
installHopper,
installSkill,
detectedClients,
detectedClients: clients,
plannedActions,
};
};
const filterClientsNeedingConfigure = async (
host: SetupHost,
detectedClients: readonly SetupClient[],
hopperPath: string | undefined,
command: readonly string[],
): Promise<readonly SetupClient[]> => {
const needs = await Promise.all(
detectedClients.map((client) =>
host.clientNeedsConfigure(client, hopperPath, command),
),
);
return detectedClients.filter((_, index) => needs[index]);
};
const finalSetupRemediation = (
platform: NodeJS.Platform,
installedHopper: boolean,
@@ -293,6 +321,10 @@ const systemSetupHost = (): SetupHost => {
: client.format === "toml"
? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command),
clientNeedsConfigure: (client, hopperPath, command) =>
clientConfigurationAligned(client, hopperPath, command).then(
(aligned) => !aligned,
),
skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
@@ -492,3 +524,41 @@ const restoreConfig = async (
// The backup remains available for the remediation reported by setup.
}
};
const clientConfigurationDesired = (
hopperPath: string | undefined,
command: readonly string[],
) => ({
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
});
const clientConfigurationAligned = async (
client: SetupClient,
hopperPath: string | undefined,
command: readonly string[],
): Promise<boolean> => {
const desired = clientConfigurationDesired(hopperPath, command);
try {
const original = await readFile(client.configPath, "utf8");
if (client.format === "toml") {
const document = objectSchema.parse(parseToml(original));
const servers = parseOptionalObject(document.mcp_servers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
}
const document = parseObject(original);
const servers = parseOptionalObject(document.mcpServers);
return (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
);
} catch {
return false;
}
};
+4
View File
@@ -63,6 +63,10 @@ class FakeSetupHost implements SetupHost {
this.clientResults.get(client.name) ?? { status: "configured" },
);
};
clientNeedsConfigure = (client: SetupClient): Promise<boolean> =>
Promise.resolve(
this.clientResults.get(client.name)?.status !== "unchanged",
);
skillNeedsInstall = (): Promise<boolean> =>
Promise.resolve(this.skill !== "unchanged");
installSkill = (): Promise<"installed" | "unchanged" | "failed"> => {