@indigoai-us/hq-cli 5.103.20 → 5.103.22

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.
@@ -199,6 +199,14 @@ export function toolPrefixForProvider(provider) {
199
199
  export function bareProvider(provider) {
200
200
  return provider.replace(/^factory:/, "");
201
201
  }
202
+ /** Turn a factory display name into the human-friendly slug people type. */
203
+ function humanSlug(value) {
204
+ return value
205
+ .trim()
206
+ .toLowerCase()
207
+ .replace(/[^a-z0-9]+/g, "-")
208
+ .replace(/^-+|-+$/g, "");
209
+ }
202
210
  /**
203
211
  * Read the whole admin surface: connections with their governance state, the
204
212
  * viewer's role, and the recent audit feed. Several verbs need more than the
@@ -231,8 +239,10 @@ export async function fetchConnections(token, companyUid) {
231
239
  }
232
240
  /**
233
241
  * Resolve one connection by `--connection acct_…` or `--provider linear`
234
- * (matches `factory:<slug>` and bare provider ids, case-insensitive). Errors
235
- * list what IS connected so the fix is one command away.
242
+ * (matches `factory:<slug>`, bare provider ids, and an installation's human
243
+ * display-name slug, case-insensitive). The legacy provider id remains a
244
+ * first-class match, so scripts that saved opaque historical slugs keep
245
+ * working. Errors list what IS connected so the fix is one command away.
236
246
  */
237
247
  export function selectConnection(connections, opts) {
238
248
  const active = connections.filter((c) => c.status !== "revoked");
@@ -245,16 +255,40 @@ export function selectConnection(connections, opts) {
245
255
  }
246
256
  if (opts.provider) {
247
257
  const want = opts.provider.trim().toLowerCase();
248
- const match = active.find((c) => {
249
- const bare = bareProvider(c.provider).toLowerCase();
250
- return bare === want || c.provider.toLowerCase() === want;
251
- });
252
- if (!match) {
253
- const available = active.map((c) => bareProvider(c.provider)).join(", ");
254
- throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
255
- (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps with `hq integrations connect <app>`."), { expected: true });
258
+ const wantHumanSlug = humanSlug(opts.provider);
259
+ // A reconnect normally leaves a revoked historical row alongside its new
260
+ // active connection. Prefer the active inventory before applying the
261
+ // provider/name/alias precedence below; fall back to history only when no
262
+ // active row matches this selector at all. `--connection` above remains
263
+ // the explicit way to inspect or act on a particular historical row.
264
+ const activeMatches = connections.filter((c) => c.status !== "revoked" &&
265
+ (bareProvider(c.provider).toLowerCase() === want ||
266
+ c.provider.toLowerCase() === want ||
267
+ c.installation?.displayName?.trim().toLowerCase() === want ||
268
+ (wantHumanSlug !== "" &&
269
+ humanSlug(c.installation?.displayName ?? "") === wantHumanSlug)));
270
+ const candidates = activeMatches.length > 0 ? activeMatches : connections;
271
+ const providerMatch = candidates.find((c) => bareProvider(c.provider).toLowerCase() === want || c.provider.toLowerCase() === want);
272
+ if (providerMatch)
273
+ return providerMatch;
274
+ const displayNameMatch = candidates.find((c) => c.installation?.displayName?.trim().toLowerCase() === want);
275
+ if (displayNameMatch)
276
+ return displayNameMatch;
277
+ const aliasMatches = wantHumanSlug
278
+ ? candidates.filter((c) => {
279
+ const displayName = c.installation?.displayName;
280
+ const displayNameSlug = displayName ? humanSlug(displayName) : "";
281
+ return displayNameSlug !== "" && displayNameSlug === wantHumanSlug;
282
+ })
283
+ : [];
284
+ if (aliasMatches.length === 1)
285
+ return aliasMatches[0];
286
+ if (aliasMatches.length > 1) {
287
+ throw new IntegrationsCliError(`Display-name alias '${opts.provider}' matches multiple connected apps. Use --connection to choose one.`, { expected: true });
256
288
  }
257
- return match;
289
+ const available = connections.map((c) => bareProvider(c.provider)).join(", ");
290
+ throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
291
+ (available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps with `hq integrations connect <app>`."), { expected: true });
258
292
  }
259
293
  if (active.length === 1)
260
294
  return active[0];
@@ -264,17 +298,59 @@ export function selectConnection(connections, opts) {
264
298
  throw new IntegrationsCliError(`Multiple apps are connected — pick one with --provider:\n` +
265
299
  active.map((c) => ` --provider ${bareProvider(c.provider)}`).join("\n"), { expected: true });
266
300
  }
301
+ /**
302
+ * The hostname re-add needs. Prefer the server's canonical installation domain;
303
+ * an older row may only retain its MCP URL, and provider is the last-resort
304
+ * human-safe query when neither was stored.
305
+ */
306
+ export function connectionDomain(connection) {
307
+ const domain = connection.installation?.domain?.trim();
308
+ if (domain)
309
+ return domain;
310
+ const url = connection.installation?.surface?.url;
311
+ if (url) {
312
+ try {
313
+ const host = new URL(url).hostname;
314
+ if (host)
315
+ return host;
316
+ }
317
+ catch {
318
+ // The saved endpoint is advisory here. A malformed legacy URL must not
319
+ // prevent recovery when the provider slug can still be re-added.
320
+ }
321
+ }
322
+ return bareProvider(connection.provider);
323
+ }
324
+ /** A revoked row is still addressable, but it cannot make a live MCP call. */
325
+ export function revokedConnectionDetails(connection, companySlug) {
326
+ const domain = connectionDomain(connection);
327
+ return {
328
+ status: "revoked",
329
+ reason: "This connection was revoked and cannot be used until it is re-added.",
330
+ fixPath: `hq integrations connect ${domain}` +
331
+ (companySlug ? ` --company ${companySlug}` : ""),
332
+ };
333
+ }
267
334
  /**
268
335
  * Resolve a connection the caller named positionally OR through the
269
336
  * `--provider` / `--connection` flags. Every management verb takes an optional
270
337
  * `<app>` argument for ergonomics (`hq integrations policy linear …`), which is
271
338
  * matched exactly like `--provider` unless it looks like a connection id.
272
339
  */
273
- export async function resolveConnection(token, companyUid, app, opts) {
340
+ export async function resolveConnection(token, companyUid, app, opts, recoveryOpts = {}) {
274
341
  const connections = await fetchConnections(token, companyUid);
275
342
  if (app && !opts.provider && !opts.connection) {
276
343
  return selectConnection(connections, app.startsWith("acct_") ? { connection: app } : { provider: app });
277
344
  }
345
+ // Normal management verbs deliberately ignore revoked rows for implicit
346
+ // selection. Reconnect's explicit --connect recovery is the one exception:
347
+ // a sole revoked row is unambiguous and needs its saved domain to revive.
348
+ if (recoveryOpts.allowSingleRevoked &&
349
+ !opts.provider &&
350
+ !opts.connection &&
351
+ connections.length === 1) {
352
+ return connections[0];
353
+ }
278
354
  return selectConnection(connections, opts);
279
355
  }
280
356
  export async function callGateway(token, params) {
@@ -0,0 +1,24 @@
1
+ import { Command } from "commander";
2
+ import { type McpManifest } from "./mcp-registration.js";
3
+ export type DesktopConnector = McpManifest & {
4
+ [key: string]: unknown;
5
+ };
6
+ export type ImportStatus = "imported" | "needs-signin" | "shared-for-local" | "skipped";
7
+ export interface ImportOutcome {
8
+ name: string;
9
+ status: ImportStatus;
10
+ reason?: string;
11
+ provider?: string;
12
+ path?: string;
13
+ installCommand?: string;
14
+ authorizationUrl?: string;
15
+ redactedArgumentCredentials?: boolean;
16
+ }
17
+ /** Claude Desktop's documented connector-config location for the current OS. */
18
+ export declare function claudeDesktopConfigPath(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string;
19
+ /** Read only the supported Claude Desktop `mcpServers` object. */
20
+ export declare function readClaudeDesktopConnectors(configPath?: string): Record<string, DesktopConnector>;
21
+ /** Never persist local desktop credentials in a company-synced connector file. */
22
+ export declare function stripConnectorSecrets(entry: DesktopConnector): McpManifest;
23
+ export declare function registerImportCommands(integrations: Command): void;
24
+ //# sourceMappingURL=integrations-import.d.ts.map
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Import Claude Desktop MCP connectors into an HQ company.
3
+ *
4
+ * Remote servers are handed to integration-factory; stdio servers remain local
5
+ * and are shared as secret-stripped manifests for each teammate to install.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import chalk from "chalk";
11
+ import { ensureCognitoIdToken, ensureCognitoToken, resolveDefaultHqRoot } from "../utils/cognito-session.js";
12
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
13
+ import { bareProvider, IntegrationsCliError, printJson } from "./integrations-core.js";
14
+ import { installIntegration, startOAuth } from "./integrations-api.js";
15
+ import { loadRevealedSecrets } from "./secrets.js";
16
+ import { registerServer } from "./mcp-registration.js";
17
+ const OAUTH_REQUIRED_CODE = "INTEGRATION_FACTORY_OAUTH_REQUIRED";
18
+ /** Claude Desktop's documented connector-config location for the current OS. */
19
+ export function claudeDesktopConfigPath(platform = process.platform, home = os.homedir(), env = process.env) {
20
+ if (platform === "darwin") {
21
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
22
+ }
23
+ if (platform === "win32") {
24
+ const appData = env.APPDATA?.trim();
25
+ return path.join(appData || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
26
+ }
27
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
28
+ }
29
+ /** Read only the supported Claude Desktop `mcpServers` object. */
30
+ export function readClaudeDesktopConnectors(configPath = claudeDesktopConfigPath()) {
31
+ if (!fs.existsSync(configPath))
32
+ return {};
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
36
+ }
37
+ catch (error) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ throw new IntegrationsCliError(`Could not parse Claude Desktop connector config at ${configPath}: ${message}`, {
40
+ expected: true,
41
+ });
42
+ }
43
+ if (!parsed || typeof parsed !== "object")
44
+ return {};
45
+ const servers = parsed.mcpServers;
46
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
47
+ return {};
48
+ return Object.fromEntries(Object.entries(servers).filter((entry) => Boolean(entry[1]) && typeof entry[1] === "object" && !Array.isArray(entry[1])));
49
+ }
50
+ function selectedNames(value) {
51
+ if (value === undefined)
52
+ return undefined;
53
+ const names = value.split(",").map((name) => name.trim()).filter(Boolean);
54
+ if (names.length === 0) {
55
+ throw new IntegrationsCliError("--only needs at least one connector name.", { expected: true });
56
+ }
57
+ return new Set(names);
58
+ }
59
+ function providerSlug(name) {
60
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
61
+ return slug || "desktop-mcp";
62
+ }
63
+ function safeConnectorName(name) {
64
+ return /^[a-z0-9_-]+$/.test(name) ? name : null;
65
+ }
66
+ function secretReference(name) {
67
+ const normalized = name.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
68
+ const resolverSafe = /^[A-Z]/.test(normalized) ? normalized : `SECRET_${normalized.replace(/^_+/, "")}`;
69
+ return `\${secret:${resolverSafe || "CONNECTOR_SECRET"}}`;
70
+ }
71
+ const CREDENTIAL_ARGUMENT_FLAG = /^(?:--)?(?:api[-_]?key|token|password|secret|apikey|auth|bearer|client[-_]?secret)$/i;
72
+ function argumentSecretName(flag) {
73
+ return flag.replace(/^--?/, "").toUpperCase().replace(/[^A-Z0-9]+/g, "_") || "ARGUMENT_SECRET";
74
+ }
75
+ /** Redact explicit credential flags and standalone token-shaped argument values. */
76
+ function redactConnectorArguments(args) {
77
+ let redacted = false;
78
+ const clean = args.map((arg, index) => {
79
+ const [flag, inlineValue] = arg.split("=", 2);
80
+ if (CREDENTIAL_ARGUMENT_FLAG.test(flag)) {
81
+ redacted = true;
82
+ return inlineValue === undefined ? arg : `${flag}=${secretReference(argumentSecretName(flag))}`;
83
+ }
84
+ if (index > 0 && CREDENTIAL_ARGUMENT_FLAG.test(args[index - 1])) {
85
+ redacted = true;
86
+ return secretReference(argumentSecretName(args[index - 1]));
87
+ }
88
+ // Avoid treating package names and URLs as tokens, but err on the side of
89
+ // safety for opaque, mixed-character strings commonly used as credentials.
90
+ if (!arg.startsWith("-") && !/[/@:]/.test(arg) && arg.length >= 16 && /[A-Za-z]/.test(arg) && /\d/.test(arg)) {
91
+ redacted = true;
92
+ return secretReference("ARGUMENT_SECRET");
93
+ }
94
+ return arg;
95
+ });
96
+ return { args: clean, redacted };
97
+ }
98
+ function connectorArguments(entry) {
99
+ return Array.isArray(entry.args) && entry.args.every((arg) => typeof arg === "string") ? entry.args : [];
100
+ }
101
+ function hasRedactedArgumentCredentials(entry) {
102
+ return redactConnectorArguments(connectorArguments(entry)).redacted;
103
+ }
104
+ /** Never persist local desktop credentials in a company-synced connector file. */
105
+ export function stripConnectorSecrets(entry) {
106
+ const stringMap = (value) => {
107
+ if (!value || typeof value !== "object" || Array.isArray(value))
108
+ return undefined;
109
+ const clean = Object.entries(value).filter((pair) => typeof pair[1] === "string")
110
+ .map(([key]) => [key, secretReference(key)]);
111
+ return clean.length > 0 ? Object.fromEntries(clean) : undefined;
112
+ };
113
+ const args = redactConnectorArguments(connectorArguments(entry)).args;
114
+ return {
115
+ type: typeof entry.command === "string" ? "stdio" : entry.type,
116
+ ...(typeof entry.command === "string" ? { command: entry.command } : {}),
117
+ ...(typeof entry.url === "string" ? { url: entry.url } : {}),
118
+ ...(typeof entry.command === "string" ? { args } : {}),
119
+ ...(stringMap(entry.env) ? { env: stringMap(entry.env) } : {}),
120
+ ...(stringMap(entry.headers) ? { headers: stringMap(entry.headers) } : {}),
121
+ };
122
+ }
123
+ function isLocal(entry) {
124
+ return typeof entry.command === "string";
125
+ }
126
+ function isRemote(entry) {
127
+ return typeof entry.url === "string";
128
+ }
129
+ function staticCredential(entry) {
130
+ if (!entry.headers || typeof entry.headers !== "object" || Array.isArray(entry.headers))
131
+ return undefined;
132
+ for (const [key, raw] of Object.entries(entry.headers)) {
133
+ if (typeof raw !== "string" || raw.trim() === "")
134
+ continue;
135
+ if (!/(authorization|api[-_]?key|token|secret)/i.test(key))
136
+ continue;
137
+ if (/^authorization$/i.test(key) && /^Bearer\s+/i.test(raw)) {
138
+ return { value: raw.replace(/^Bearer\s+/i, "").trim() };
139
+ }
140
+ return { value: raw.trim(), authScheme: { placement: "header", header: key } };
141
+ }
142
+ return undefined;
143
+ }
144
+ function oauthRequired(error) {
145
+ return error instanceof IntegrationsCliError &&
146
+ (error.code === OAUTH_REQUIRED_CODE || error.oauthProtected === true);
147
+ }
148
+ function reconnectCommand(name, provider) {
149
+ return `hq integrations reconnect ${name} --provider ${bareProvider(provider)}`;
150
+ }
151
+ function connectorPath(hqRoot, company, name) {
152
+ return path.join(hqRoot, "companies", company, "settings", "connectors", `${name}.json`);
153
+ }
154
+ /**
155
+ * The API's default-company resolver returns a UID, while the synced on-disk
156
+ * layout is deliberately slug-addressed. Read the same active membership set
157
+ * to retain its canonical slug instead of inventing a folder from an ID.
158
+ */
159
+ async function companyFolderSlug(token, companyUid) {
160
+ const response = await vaultApiFetch({ token, path: "/membership/me" });
161
+ if (!response.ok) {
162
+ throw new IntegrationsCliError("Could not resolve the active company's slug for the shared connector folder.", {
163
+ expected: true,
164
+ });
165
+ }
166
+ const body = (await response.json());
167
+ const membership = body.memberships?.find((candidate) => candidate.status === "active" && candidate.companyUid === companyUid);
168
+ const folderSlug = membership?.companyFolderSlug ?? membership?.companySlug;
169
+ if (!folderSlug) {
170
+ throw new IntegrationsCliError("The active company has no slug. Re-run with --company <slug> to choose the shared connector folder.", { expected: true });
171
+ }
172
+ return folderSlug;
173
+ }
174
+ function writeLocalConnector(hqRoot, company, name, entry) {
175
+ const target = connectorPath(hqRoot, company, name);
176
+ fs.mkdirSync(path.dirname(target), { recursive: true });
177
+ fs.writeFileSync(target, `${JSON.stringify(stripConnectorSecrets(entry), null, 2)}\n`, { mode: 0o600 });
178
+ return target;
179
+ }
180
+ async function importRemote(token, companyUid, name, entry, dryRun) {
181
+ const provider = providerSlug(name);
182
+ if (dryRun)
183
+ return { name, status: "imported", reason: "would connect remote MCP endpoint", provider };
184
+ const credential = staticCredential(entry);
185
+ try {
186
+ const result = await installIntegration(token, companyUid, {
187
+ mcpUrl: entry.url,
188
+ provider,
189
+ displayName: name,
190
+ ...(credential ? { authMode: "bearer", bearerToken: credential.value, ...(credential.authScheme ? { authScheme: credential.authScheme } : {}) } : {}),
191
+ });
192
+ const resultProvider = bareProvider(result.connection.provider);
193
+ if (result.installation.status === "needs_credentials" || result.credential?.configured === false) {
194
+ return {
195
+ name,
196
+ status: "needs-signin",
197
+ provider: resultProvider,
198
+ reason: reconnectCommand(name, resultProvider),
199
+ };
200
+ }
201
+ return { name, status: "imported", provider: resultProvider };
202
+ }
203
+ catch (error) {
204
+ if (!oauthRequired(error))
205
+ throw error;
206
+ // Start the server-authoritative OAuth record but deliberately do not open a
207
+ // browser: a Desktop session cannot be transferred headlessly to HQ cloud.
208
+ const pending = await startOAuth(token, companyUid, {
209
+ mcpUrl: entry.url,
210
+ provider,
211
+ displayName: name,
212
+ });
213
+ return {
214
+ name,
215
+ status: "needs-signin",
216
+ provider: bareProvider(pending.provider),
217
+ reason: reconnectCommand(name, pending.provider),
218
+ authorizationUrl: pending.authorizationUrl,
219
+ };
220
+ }
221
+ }
222
+ function renderOutcomes(outcomes) {
223
+ console.log("Connector Outcome Details");
224
+ for (const outcome of outcomes) {
225
+ console.log(`${outcome.name.padEnd(25)} ${outcome.status.padEnd(19)} ${outcome.reason ?? outcome.path ?? ""}`);
226
+ }
227
+ for (const outcome of outcomes.filter((item) => item.status === "shared-for-local")) {
228
+ console.log(chalk.dim(` Shared manifest: ${outcome.path}`));
229
+ console.log(chalk.dim(` Teammate install: ${outcome.installCommand}`));
230
+ }
231
+ if (outcomes.some((item) => item.status === "shared-for-local")) {
232
+ console.log(chalk.yellow(" Local connector credentials were replaced with ${secret:ENV_NAME}. Set them with `hq secrets` before installing."));
233
+ }
234
+ if (outcomes.some((item) => item.redactedArgumentCredentials)) {
235
+ console.log(chalk.yellow(" Local connector argument credentials were replaced with ${secret:FLAG_NAME}. Set them with `hq secrets` before installing."));
236
+ }
237
+ for (const outcome of outcomes.filter((item) => item.status === "needs-signin")) {
238
+ console.log(chalk.yellow(` ${outcome.name} needs sign-in: ${outcome.reason}`));
239
+ if (outcome.authorizationUrl)
240
+ console.log(chalk.yellow(` Open this URL to sign in:\n ${outcome.authorizationUrl}`));
241
+ }
242
+ }
243
+ export function registerImportCommands(integrations) {
244
+ integrations
245
+ .command("import")
246
+ .description("Import Claude Desktop connectors into company Integrations")
247
+ .option("--company <slug>", "Company slug (defaults to your single active company)")
248
+ .option("--dry-run", "Detect and classify connectors without writing or connecting")
249
+ .option("--only <name[,name...]>", "Import only these Claude Desktop connector names")
250
+ .option("--json", "Machine-readable per-connector outcomes")
251
+ .option("--config <path>", "Claude Desktop config path (defaults to this OS's standard location)")
252
+ .action(async (opts) => {
253
+ const configPath = opts.config ?? claudeDesktopConfigPath();
254
+ const connectors = readClaudeDesktopConnectors(configPath);
255
+ const only = selectedNames(opts.only);
256
+ const entries = Object.entries(connectors).filter(([name]) => !only || only.has(name));
257
+ if (entries.length === 0) {
258
+ if (opts.json) {
259
+ printJson([]);
260
+ }
261
+ else {
262
+ console.log(`No Claude Desktop connectors found at ${configPath}`);
263
+ }
264
+ return;
265
+ }
266
+ const token = await ensureCognitoIdToken();
267
+ // getCompanyUid owns the single-membership fallback and multi-company
268
+ // disambiguation, exactly like every other integrations verb.
269
+ const companyUid = await getCompanyUid(token, opts.company);
270
+ const outcomes = [];
271
+ let company;
272
+ let hqRoot;
273
+ const localCompany = async () => company ??= await companyFolderSlug(token, companyUid);
274
+ const localHqRoot = () => hqRoot ??= resolveDefaultHqRoot({ onMissing: "throw" });
275
+ const normalizedNames = new Map();
276
+ for (const [name] of entries) {
277
+ if (!safeConnectorName(name))
278
+ continue;
279
+ const provider = providerSlug(name);
280
+ normalizedNames.set(provider, [...(normalizedNames.get(provider) ?? []), name]);
281
+ }
282
+ for (const [name, entry] of entries) {
283
+ const safeName = safeConnectorName(name);
284
+ if (!safeName) {
285
+ outcomes.push({ name, status: "skipped", reason: "name must use lowercase letters, digits, _ or -" });
286
+ continue;
287
+ }
288
+ const collisions = normalizedNames.get(providerSlug(name)) ?? [];
289
+ if (collisions.length > 1) {
290
+ outcomes.push({
291
+ name,
292
+ status: "skipped",
293
+ reason: `normalized provider name '${providerSlug(name)}' collides with ${collisions.filter((other) => other !== name).join(", ")}`,
294
+ });
295
+ continue;
296
+ }
297
+ if (isLocal(entry)) {
298
+ const resolvedCompany = await localCompany();
299
+ const target = connectorPath(localHqRoot(), resolvedCompany, safeName);
300
+ outcomes.push({
301
+ name,
302
+ status: "shared-for-local",
303
+ ...(opts.dryRun ? { reason: "would write secret-stripped local manifest" } : { path: writeLocalConnector(localHqRoot(), resolvedCompany, safeName, entry) }),
304
+ installCommand: `hq integrations install-local ${safeName} --company ${resolvedCompany}`,
305
+ ...(hasRedactedArgumentCredentials(entry) ? { redactedArgumentCredentials: true } : {}),
306
+ });
307
+ // Keep dry-run output useful without leaking a filesystem write target.
308
+ if (opts.dryRun)
309
+ outcomes[outcomes.length - 1].path = target;
310
+ continue;
311
+ }
312
+ if (isRemote(entry) && typeof entry.url === "string") {
313
+ outcomes.push(await importRemote(token, companyUid, name, entry, Boolean(opts.dryRun)));
314
+ continue;
315
+ }
316
+ outcomes.push({ name, status: "skipped", reason: "unsupported MCP entry (expected command for local stdio or url for remote MCP)" });
317
+ }
318
+ if (opts.json)
319
+ printJson(outcomes);
320
+ else
321
+ renderOutcomes(outcomes);
322
+ });
323
+ integrations
324
+ .command("install-local <name>")
325
+ .description("Register a company-shared local connector in this machine's Claude/Codex MCP config")
326
+ .option("--company <slug>", "Company slug (defaults to your single active company)")
327
+ .option("--json", "Machine-readable registration result")
328
+ .action(async (name, opts) => {
329
+ const safeName = safeConnectorName(name);
330
+ if (!safeName) {
331
+ throw new IntegrationsCliError("Connector name must use lowercase letters, digits, _ or -.", { expected: true });
332
+ }
333
+ const token = await ensureCognitoToken();
334
+ const companyUid = await getCompanyUid(token, opts.company);
335
+ const company = await companyFolderSlug(token, companyUid);
336
+ const hqRoot = resolveDefaultHqRoot({ onMissing: "throw" });
337
+ const manifestPath = connectorPath(hqRoot, company, safeName);
338
+ if (!fs.existsSync(manifestPath)) {
339
+ throw new IntegrationsCliError(`Shared connector not found: ${manifestPath}`, { expected: true });
340
+ }
341
+ let manifest;
342
+ try {
343
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
344
+ }
345
+ catch (error) {
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ throw new IntegrationsCliError(`Could not parse shared connector ${manifestPath}: ${message}`, { expected: true });
348
+ }
349
+ const secretNames = [...Object.values({ ...(manifest.env ?? {}), ...(manifest.headers ?? {}) }), ...(manifest.args ?? [])]
350
+ .flatMap((value) => [...value.matchAll(/\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*)\}/g)].map((match) => match[1]));
351
+ const secrets = await loadRevealedSecrets(token, companyUid, secretNames);
352
+ const result = registerServer({
353
+ name: safeName,
354
+ manifest,
355
+ pack: `company-${company}-connectors`,
356
+ resolveSecret: (secret) => secrets.get(secret) ?? null,
357
+ });
358
+ if (opts.json)
359
+ printJson(result);
360
+ else
361
+ console.log(`Registered ${safeName} in local MCP config (Claude${"skipped" in result.codex ? "; Codex not installed" : " and Codex"}).`);
362
+ });
363
+ }
364
+ //# sourceMappingURL=integrations-import.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `hq integrations show | policy | grants | grant | ungrant | access | share |
3
- * unshare | audit | pending | disconnect`.
3
+ * unshare | audit | pending | disconnect | purge`.
4
4
  *
5
5
  * The govern-and-remove half of the lifecycle. Two different permission
6
6
  * surfaces live here and are easy to confuse, so they get separate verbs: