@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.
- package/CHANGELOG.md +26 -0
- package/dist/commands/agents.d.ts +23 -0
- package/dist/commands/agents.js +95 -9
- package/dist/commands/auth.d.ts +1 -1
- package/dist/commands/auth.js +2 -2
- package/dist/commands/integrations-api.d.ts +29 -0
- package/dist/commands/integrations-api.js +35 -0
- package/dist/commands/integrations-connect.js +89 -43
- package/dist/commands/integrations-core.d.ts +24 -2
- package/dist/commands/integrations-core.js +88 -12
- package/dist/commands/integrations-import.d.ts +24 -0
- package/dist/commands/integrations-import.js +364 -0
- package/dist/commands/integrations-manage.d.ts +1 -1
- package/dist/commands/integrations-manage.js +159 -4
- package/dist/commands/integrations.d.ts +3 -0
- package/dist/commands/integrations.js +143 -9
- package/dist/commands/mcp-registration.d.ts +2 -2
- package/dist/commands/mcp-registration.js +8 -6
- package/dist/utils/vault-api.d.ts +12 -0
- package/dist/utils/vault-api.js +13 -0
- package/package.json +1 -1
|
@@ -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
|
|
235
|
-
*
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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:
|