@indigoai-us/hq-cli 5.103.19 → 5.103.21
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 +27 -0
- package/dist/commands/agents.d.ts +23 -0
- package/dist/commands/agents.js +95 -9
- package/dist/commands/integrations-api.d.ts +16 -0
- package/dist/commands/integrations-connect.js +10 -1
- package/dist/commands/integrations-core.d.ts +4 -2
- package/dist/commands/integrations-core.js +33 -11
- package/dist/commands/integrations-import.d.ts +24 -0
- package/dist/commands/integrations-import.js +364 -0
- package/dist/commands/integrations.d.ts +1 -0
- package/dist/commands/integrations.js +63 -8
- 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 +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.21] — 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq agents jobs list|pause|cancel` now fails closed on an agent box instead of
|
|
10
|
+
provoking a cross-agent rejection on the server. These off-box operator
|
|
11
|
+
commands are reachable on agent boxes, where the CLI authenticates with the
|
|
12
|
+
box's own agent machine identity; forwarding another agent's identifier into
|
|
13
|
+
`GET /v1/agents/{uid}/jobs` was refused server-side by hq-pro's self-only jobs
|
|
14
|
+
guard and logged as an unexpected warning, while the CLI mislabeled the 403 as
|
|
15
|
+
"You need owner/admin on this company." — a role problem a box can never
|
|
16
|
+
satisfy. The commands now resolve the agent reference through the company
|
|
17
|
+
roster (a slug or name becomes an `agt_` uid, or fails locally), and refuse a
|
|
18
|
+
cross-agent target before any request leaves the box, naming the on-box
|
|
19
|
+
`hq-agent-jobs` tool and the person session required to manage another agent's
|
|
20
|
+
jobs. A server-side `CROSS_AGENT_JOB` denial is now rendered as identity-scoped
|
|
21
|
+
guidance rather than the owner/admin copy. Separately, every authenticated
|
|
22
|
+
vault-API request now carries an `x-hq-client-name` header so its origin is
|
|
23
|
+
attributable in telemetry and error reports. (Sentry 7617401436)
|
|
24
|
+
|
|
25
|
+
## [5.103.20] — 2026-08-24
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- The bundled sync engine now requires `@indigoai-us/hq-cloud` 6.15.37, the
|
|
30
|
+
current production release.
|
|
31
|
+
|
|
5
32
|
## [5.103.19] — 2026-08-22
|
|
6
33
|
|
|
7
34
|
### Fixed
|
|
@@ -267,6 +267,29 @@ export interface DmThreadMessage {
|
|
|
267
267
|
* membership).
|
|
268
268
|
*/
|
|
269
269
|
export declare function resolveAgentUid(token: string, ref: string, companySlug: string | undefined): Promise<string>;
|
|
270
|
+
/**
|
|
271
|
+
* Fail-closed local guard for the off-box `hq agents jobs` operator commands.
|
|
272
|
+
*
|
|
273
|
+
* hq-pro's jobs endpoints are self-only for an agent machine identity: an
|
|
274
|
+
* agent-authenticated list/pause/cancel against `/v1/agents/{uid}/jobs` whose
|
|
275
|
+
* `{uid}` is not the caller's OWN agt_ uid is refused server-side as 403
|
|
276
|
+
* `CROSS_AGENT_JOB` and logged loud (`caller_uid_mismatch`, Sentry hq-pro
|
|
277
|
+
* 7617401436). That happens when these commands are run ON an agent box, where
|
|
278
|
+
* {@link resolveVaultCredential} silently returns the box's own agent ID token —
|
|
279
|
+
* the operator meant to act as a person with owner/admin, not as the box.
|
|
280
|
+
*
|
|
281
|
+
* Catch it locally so the call never leaves the box: decode the (already
|
|
282
|
+
* locally trusted, signature-unverified) bearer token and, ONLY when it proves
|
|
283
|
+
* an agent identity acting on a DIFFERENT agent, return a refusal string for the
|
|
284
|
+
* command to print before any HTTP request. Every other shape fails OPEN
|
|
285
|
+
* (returns `null`) and proceeds to the server exactly as today: a person session
|
|
286
|
+
* (no `custom:entityType` claim), an opaque / non-JWT credential (an `hqk_` API
|
|
287
|
+
* key), a token with a missing or malformed uid claim, or an agent acting on its
|
|
288
|
+
* OWN jobs.
|
|
289
|
+
*
|
|
290
|
+
* Pure: no network, no logging; never prints or returns the token itself.
|
|
291
|
+
*/
|
|
292
|
+
export declare function crossAgentJobsRefusal(token: string, resolvedAgentUid: string): string | null;
|
|
270
293
|
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
271
294
|
export declare function sendAgentDm(token: string, agentUid: string, message: string): Promise<void>;
|
|
272
295
|
/** Read the two-way DM conversation with an agent (most recent `limit`). */
|
package/dist/commands/agents.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as readline from "node:readline";
|
|
|
30
30
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
31
31
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
32
32
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
33
|
+
import { peekIdToken } from "../utils/id-token.js";
|
|
33
34
|
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
34
35
|
import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
35
36
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
@@ -345,6 +346,17 @@ export async function cancelAgentJob(token, agentUid, jobId) {
|
|
|
345
346
|
export function formatJobsHttpError(err, jobId) {
|
|
346
347
|
if (err.status === 401)
|
|
347
348
|
return "Not authenticated — run `hq login`.";
|
|
349
|
+
// A 403 CROSS_AGENT_JOB is hq-pro's self-only jobs guard, not a role problem:
|
|
350
|
+
// the caller authenticated as one agent and asked about another. Naming it as
|
|
351
|
+
// "owner/admin required" (below) is misleading copy an agent box can never
|
|
352
|
+
// satisfy, so distinguish it here (defence in depth — the CLI now also fails
|
|
353
|
+
// closed locally before the request, see crossAgentJobsRefusal).
|
|
354
|
+
if (err.status === 403 && err.code === "CROSS_AGENT_JOB") {
|
|
355
|
+
return ("Jobs are scoped to the calling agent's own identity — an agent may only " +
|
|
356
|
+
"list, pause, or cancel its OWN jobs (use the on-box `hq-agent-jobs` tool). " +
|
|
357
|
+
"Managing another agent's jobs needs a person session with owner/admin on " +
|
|
358
|
+
"the company.");
|
|
359
|
+
}
|
|
348
360
|
if (err.status === 403)
|
|
349
361
|
return "You need owner/admin on this company.";
|
|
350
362
|
if (err.status === 404 && err.code === "JOB_NOT_FOUND") {
|
|
@@ -420,6 +432,77 @@ export async function resolveAgentUid(token, ref, companySlug) {
|
|
|
420
432
|
}
|
|
421
433
|
return match.uid;
|
|
422
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Fail-closed local guard for the off-box `hq agents jobs` operator commands.
|
|
437
|
+
*
|
|
438
|
+
* hq-pro's jobs endpoints are self-only for an agent machine identity: an
|
|
439
|
+
* agent-authenticated list/pause/cancel against `/v1/agents/{uid}/jobs` whose
|
|
440
|
+
* `{uid}` is not the caller's OWN agt_ uid is refused server-side as 403
|
|
441
|
+
* `CROSS_AGENT_JOB` and logged loud (`caller_uid_mismatch`, Sentry hq-pro
|
|
442
|
+
* 7617401436). That happens when these commands are run ON an agent box, where
|
|
443
|
+
* {@link resolveVaultCredential} silently returns the box's own agent ID token —
|
|
444
|
+
* the operator meant to act as a person with owner/admin, not as the box.
|
|
445
|
+
*
|
|
446
|
+
* Catch it locally so the call never leaves the box: decode the (already
|
|
447
|
+
* locally trusted, signature-unverified) bearer token and, ONLY when it proves
|
|
448
|
+
* an agent identity acting on a DIFFERENT agent, return a refusal string for the
|
|
449
|
+
* command to print before any HTTP request. Every other shape fails OPEN
|
|
450
|
+
* (returns `null`) and proceeds to the server exactly as today: a person session
|
|
451
|
+
* (no `custom:entityType` claim), an opaque / non-JWT credential (an `hqk_` API
|
|
452
|
+
* key), a token with a missing or malformed uid claim, or an agent acting on its
|
|
453
|
+
* OWN jobs.
|
|
454
|
+
*
|
|
455
|
+
* Pure: no network, no logging; never prints or returns the token itself.
|
|
456
|
+
*/
|
|
457
|
+
export function crossAgentJobsRefusal(token, resolvedAgentUid) {
|
|
458
|
+
const claims = peekIdToken(token);
|
|
459
|
+
const entityType = typeof claims["custom:entityType"] === "string"
|
|
460
|
+
? claims["custom:entityType"]
|
|
461
|
+
: undefined;
|
|
462
|
+
const entityUid = typeof claims["custom:entityUid"] === "string"
|
|
463
|
+
? claims["custom:entityUid"].trim()
|
|
464
|
+
: "";
|
|
465
|
+
// Positive proof required before refusing: an agent identity, a well-formed
|
|
466
|
+
// agt_ own-uid, and a target that differs from it. Anything else proceeds.
|
|
467
|
+
if (entityType !== "agent")
|
|
468
|
+
return null;
|
|
469
|
+
if (!AGENT_UID_PATTERN.test(entityUid))
|
|
470
|
+
return null;
|
|
471
|
+
if (entityUid === resolvedAgentUid.trim())
|
|
472
|
+
return null;
|
|
473
|
+
return (`Refusing to manage ${resolvedAgentUid}'s scheduled jobs using this agent's ` +
|
|
474
|
+
`own identity (${entityUid}). On an agent box, \`hq agents jobs\` authenticates ` +
|
|
475
|
+
`as the box itself, and hq-pro scopes an agent to its OWN jobs only — inspect ` +
|
|
476
|
+
`those with the on-box \`hq-agent-jobs\` tool. To manage another agent's jobs, ` +
|
|
477
|
+
`run this from a person session with owner/admin on the company (\`hq login\`), ` +
|
|
478
|
+
`not on the agent box.`);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Shared preamble for the three `hq agents jobs` actions: resolve the caller's
|
|
482
|
+
* credential, turn the (possibly slug/name) reference into an `agt_` uid via the
|
|
483
|
+
* roster, and fail closed BEFORE any jobs request when an agent identity targets
|
|
484
|
+
* another agent ({@link crossAgentJobsRefusal}). Returns the token + resolved
|
|
485
|
+
* uid for the caller's single jobs request. Never returns on a refusal or a
|
|
486
|
+
* resolution error — it prints and exits non-zero. The refusal exit lives
|
|
487
|
+
* OUTSIDE the resolution try so it is not re-wrapped as a generic failure.
|
|
488
|
+
*/
|
|
489
|
+
async function resolveJobsTargetOrExit(ref, company) {
|
|
490
|
+
let token;
|
|
491
|
+
let targetUid;
|
|
492
|
+
try {
|
|
493
|
+
token = (await resolveVaultCredential()).token;
|
|
494
|
+
targetUid = await resolveAgentUid(token, ref, company);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
return failJobs(err);
|
|
498
|
+
}
|
|
499
|
+
const refusal = crossAgentJobsRefusal(token, targetUid);
|
|
500
|
+
if (refusal) {
|
|
501
|
+
console.error(chalk.red(refusal));
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
return { token, targetUid };
|
|
505
|
+
}
|
|
423
506
|
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
424
507
|
export async function sendAgentDm(token, agentUid, message) {
|
|
425
508
|
await agentsRequest({
|
|
@@ -934,11 +1017,12 @@ export function registerAgentsCommand(program) {
|
|
|
934
1017
|
jobs
|
|
935
1018
|
.command("list <agentUid>")
|
|
936
1019
|
.description("List an agent's scheduled jobs")
|
|
1020
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
937
1021
|
.option("--json", "Emit raw JSON")
|
|
938
|
-
.action(async (agentUid, opts)
|
|
1022
|
+
.action(async function (agentUid, opts) {
|
|
1023
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
939
1024
|
try {
|
|
940
|
-
const
|
|
941
|
-
const roster = await listAgentJobs(token, agentUid);
|
|
1025
|
+
const roster = await listAgentJobs(token, targetUid);
|
|
942
1026
|
if (opts.json) {
|
|
943
1027
|
process.stdout.write(JSON.stringify(roster, null, 2) + "\n");
|
|
944
1028
|
return;
|
|
@@ -956,10 +1040,11 @@ export function registerAgentsCommand(program) {
|
|
|
956
1040
|
jobs
|
|
957
1041
|
.command("pause <agentUid> <jobId>")
|
|
958
1042
|
.description("Pause a job's schedule (reversible)")
|
|
959
|
-
.
|
|
1043
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
1044
|
+
.action(async function (agentUid, jobId) {
|
|
1045
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
960
1046
|
try {
|
|
961
|
-
const
|
|
962
|
-
const result = await pauseAgentJob(token, agentUid, jobId);
|
|
1047
|
+
const result = await pauseAgentJob(token, targetUid, jobId);
|
|
963
1048
|
console.log(chalk.green(formatPauseResult(result)));
|
|
964
1049
|
}
|
|
965
1050
|
catch (err) {
|
|
@@ -969,10 +1054,11 @@ export function registerAgentsCommand(program) {
|
|
|
969
1054
|
jobs
|
|
970
1055
|
.command("cancel <agentUid> <jobId>")
|
|
971
1056
|
.description("Cancel a job and delete its schedule")
|
|
972
|
-
.
|
|
1057
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
1058
|
+
.action(async function (agentUid, jobId) {
|
|
1059
|
+
const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
|
|
973
1060
|
try {
|
|
974
|
-
const
|
|
975
|
-
const result = await cancelAgentJob(token, agentUid, jobId);
|
|
1061
|
+
const result = await cancelAgentJob(token, targetUid, jobId);
|
|
976
1062
|
console.log(chalk.green(`Cancelled ${result.jobId}.`));
|
|
977
1063
|
}
|
|
978
1064
|
catch (err) {
|
|
@@ -134,6 +134,22 @@ export interface InstallInput {
|
|
|
134
134
|
docsUrl?: string;
|
|
135
135
|
authMode?: "none" | "bearer";
|
|
136
136
|
bearerToken?: string;
|
|
137
|
+
/** Preserve a non-default placement for a pasted key (for example X-API-Key). */
|
|
138
|
+
authScheme?: {
|
|
139
|
+
placement: "authorization";
|
|
140
|
+
format: "bearer";
|
|
141
|
+
} | {
|
|
142
|
+
placement: "authorization";
|
|
143
|
+
format: "prefix";
|
|
144
|
+
prefix: string;
|
|
145
|
+
} | {
|
|
146
|
+
placement: "authorization";
|
|
147
|
+
format: "basic";
|
|
148
|
+
username?: string;
|
|
149
|
+
} | {
|
|
150
|
+
placement: "header";
|
|
151
|
+
header: string;
|
|
152
|
+
};
|
|
137
153
|
}
|
|
138
154
|
export declare function installIntegration(token: string, companyUid: string, input: InstallInput): Promise<InstallResult>;
|
|
139
155
|
export declare function uninstallIntegration(token: string, companyUid: string, installationId: string): Promise<{
|
|
@@ -278,6 +278,14 @@ async function findCatalogEntry(token, companyUid, domain) {
|
|
|
278
278
|
function domainLabel(domain) {
|
|
279
279
|
return domain.trim().toLowerCase().split(".")[0] ?? "";
|
|
280
280
|
}
|
|
281
|
+
/** A catalog display name's copy-pasteable command-line slug. */
|
|
282
|
+
function displayNameSlug(name) {
|
|
283
|
+
return name
|
|
284
|
+
.trim()
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
287
|
+
.replace(/^-+|-+$/g, "");
|
|
288
|
+
}
|
|
281
289
|
/**
|
|
282
290
|
* Best-effort resolution of a bare name to a single catalog entry. Matches the
|
|
283
291
|
* entry's registrable domain label (`notion` → `notion.com`) or an exact
|
|
@@ -293,7 +301,8 @@ async function findCatalogEntryByName(token, companyUid, name) {
|
|
|
293
301
|
return null;
|
|
294
302
|
const entries = await listCatalog(token, companyUid, { query: name, limit: 20 });
|
|
295
303
|
const matches = entries.filter((entry) => domainLabel(entry.domain) === want ||
|
|
296
|
-
entry.name?.trim().toLowerCase() === want
|
|
304
|
+
entry.name?.trim().toLowerCase() === want ||
|
|
305
|
+
(entry.name !== undefined && displayNameSlug(entry.name) === displayNameSlug(want)));
|
|
297
306
|
if (matches.length === 0)
|
|
298
307
|
return null;
|
|
299
308
|
// Collapse rows that point at the same app (same domain) before deciding
|
|
@@ -201,8 +201,10 @@ export declare function fetchAdminSurface(token: string, companyUid: string): Pr
|
|
|
201
201
|
export declare function fetchConnections(token: string, companyUid: string): Promise<AdminConnection[]>;
|
|
202
202
|
/**
|
|
203
203
|
* Resolve one connection by `--connection acct_…` or `--provider linear`
|
|
204
|
-
* (matches `factory:<slug
|
|
205
|
-
*
|
|
204
|
+
* (matches `factory:<slug>`, bare provider ids, and an installation's human
|
|
205
|
+
* display-name slug, case-insensitive). The legacy provider id remains a
|
|
206
|
+
* first-class match, so scripts that saved opaque historical slugs keep
|
|
207
|
+
* working. Errors list what IS connected so the fix is one command away.
|
|
206
208
|
*/
|
|
207
209
|
export declare function selectConnection(connections: AdminConnection[], opts: {
|
|
208
210
|
connection?: string;
|
|
@@ -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,28 @@ 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
|
+
const providerMatch = active.find((c) => bareProvider(c.provider).toLowerCase() === want || c.provider.toLowerCase() === want);
|
|
260
|
+
if (providerMatch)
|
|
261
|
+
return providerMatch;
|
|
262
|
+
const displayNameMatch = active.find((c) => c.installation?.displayName?.trim().toLowerCase() === want);
|
|
263
|
+
if (displayNameMatch)
|
|
264
|
+
return displayNameMatch;
|
|
265
|
+
const aliasMatches = wantHumanSlug
|
|
266
|
+
? active.filter((c) => {
|
|
267
|
+
const displayName = c.installation?.displayName;
|
|
268
|
+
const displayNameSlug = displayName ? humanSlug(displayName) : "";
|
|
269
|
+
return displayNameSlug !== "" && displayNameSlug === wantHumanSlug;
|
|
270
|
+
})
|
|
271
|
+
: [];
|
|
272
|
+
if (aliasMatches.length === 1)
|
|
273
|
+
return aliasMatches[0];
|
|
274
|
+
if (aliasMatches.length > 1) {
|
|
275
|
+
throw new IntegrationsCliError(`Display-name alias '${opts.provider}' matches multiple connected apps. Use --connection to choose one.`, { expected: true });
|
|
256
276
|
}
|
|
257
|
-
|
|
277
|
+
const available = active.map((c) => bareProvider(c.provider)).join(", ");
|
|
278
|
+
throw new IntegrationsCliError(`No connected app matches '${opts.provider}'.` +
|
|
279
|
+
(available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps with `hq integrations connect <app>`."), { expected: true });
|
|
258
280
|
}
|
|
259
281
|
if (active.length === 1)
|
|
260
282
|
return active[0];
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* hq integrations discover <docsUrl> Find a server from its docs page.
|
|
15
15
|
* hq integrations connect <app> Connect it (no-auth, key, OAuth).
|
|
16
16
|
* hq integrations reconnect [app] Re-authenticate a broken app.
|
|
17
|
+
* hq integrations import Import Claude Desktop connectors.
|
|
17
18
|
*
|
|
18
19
|
* Use:
|
|
19
20
|
* hq integrations list --company indigo Connected apps for a company.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* hq integrations discover <docsUrl> Find a server from its docs page.
|
|
15
15
|
* hq integrations connect <app> Connect it (no-auth, key, OAuth).
|
|
16
16
|
* hq integrations reconnect [app] Re-authenticate a broken app.
|
|
17
|
+
* hq integrations import Import Claude Desktop connectors.
|
|
17
18
|
*
|
|
18
19
|
* Use:
|
|
19
20
|
* hq integrations list --company indigo Connected apps for a company.
|
|
@@ -51,8 +52,40 @@ import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
|
51
52
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
52
53
|
import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, isClientError, printJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
|
|
53
54
|
import { registerConnectCommands } from "./integrations-connect.js";
|
|
55
|
+
import { registerImportCommands } from "./integrations-import.js";
|
|
54
56
|
import { registerManageCommands } from "./integrations-manage.js";
|
|
55
57
|
export { IntegrationsCliError, callGateway, fetchConnections, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
|
|
58
|
+
/**
|
|
59
|
+
* The gateway relays MCP tool schemas verbatim. Keep the default listing
|
|
60
|
+
* compact, but make required inputs visible before a caller has to discover
|
|
61
|
+
* them through a validation error.
|
|
62
|
+
*/
|
|
63
|
+
function requiredInputHint(inputSchema) {
|
|
64
|
+
if (!inputSchema || typeof inputSchema !== "object")
|
|
65
|
+
return null;
|
|
66
|
+
const required = inputSchema.required;
|
|
67
|
+
if (!Array.isArray(required))
|
|
68
|
+
return null;
|
|
69
|
+
const names = required.filter((name) => typeof name === "string");
|
|
70
|
+
return names.length > 0 ? `requires: ${names.join(", ")}` : null;
|
|
71
|
+
}
|
|
72
|
+
/** The user-facing slug stays first; the factory's display name is additive. */
|
|
73
|
+
function connectionLabel(connection) {
|
|
74
|
+
const slug = bareProvider(connection.provider);
|
|
75
|
+
const displayName = connection.installation?.displayName?.trim();
|
|
76
|
+
return displayName && displayName.toLowerCase() !== slug.toLowerCase()
|
|
77
|
+
? `${slug} (${displayName})`
|
|
78
|
+
: slug;
|
|
79
|
+
}
|
|
80
|
+
function printToolSchema(tool) {
|
|
81
|
+
if (tool.inputSchema === undefined) {
|
|
82
|
+
console.log(chalk.dim(" Input schema was not reported by this app."));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const schema = JSON.stringify(tool.inputSchema, null, 2);
|
|
86
|
+
console.log(chalk.dim(" Input schema:"));
|
|
87
|
+
console.log(chalk.dim(schema.split("\n").map((line) => ` ${line}`).join("\n")));
|
|
88
|
+
}
|
|
56
89
|
export function registerIntegrationsCommand(program) {
|
|
57
90
|
const integrations = program
|
|
58
91
|
.command("integrations")
|
|
@@ -75,7 +108,6 @@ export function registerIntegrationsCommand(program) {
|
|
|
75
108
|
return;
|
|
76
109
|
}
|
|
77
110
|
for (const c of connections) {
|
|
78
|
-
const name = c.installation?.displayName ?? bareProvider(c.provider);
|
|
79
111
|
const flags = [
|
|
80
112
|
c.status,
|
|
81
113
|
c.writePolicy ? `writes: ${c.writePolicy}` : null,
|
|
@@ -83,16 +115,18 @@ export function registerIntegrationsCommand(program) {
|
|
|
83
115
|
]
|
|
84
116
|
.filter(Boolean)
|
|
85
117
|
.join(" · ");
|
|
86
|
-
console.log(`${chalk.bold(
|
|
118
|
+
console.log(`${chalk.bold(connectionLabel(c))} ${chalk.dim(flags)}`);
|
|
87
119
|
console.log(chalk.dim(` connection: ${c.id}`));
|
|
88
120
|
}
|
|
89
121
|
});
|
|
122
|
+
registerImportCommands(integrations);
|
|
90
123
|
integrations
|
|
91
124
|
.command("tools")
|
|
92
125
|
.description("List what a connected app can do")
|
|
93
126
|
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
94
127
|
.option("--connection <id>", "Connection id (acct_…)")
|
|
95
128
|
.option("--company <slug>", "Company slug, e.g. indigo")
|
|
129
|
+
.option("--describe <tool>", "Print the full input schema for one tool")
|
|
96
130
|
.option("--json", "Machine-readable output")
|
|
97
131
|
.action(async (opts) => {
|
|
98
132
|
const token = await ensureCognitoIdToken();
|
|
@@ -105,20 +139,41 @@ export function registerIntegrationsCommand(program) {
|
|
|
105
139
|
arguments: { companyUid, connectionId: connection.id },
|
|
106
140
|
});
|
|
107
141
|
const payload = unwrapGatewayResult(message.result);
|
|
108
|
-
if (opts.json) {
|
|
109
|
-
printJson(payload);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
142
|
const tools = payload?.tools ?? [];
|
|
113
143
|
if (tools.length === 0) {
|
|
144
|
+
if (opts.json) {
|
|
145
|
+
printJson(payload);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
114
148
|
console.log("The app reported no tools.");
|
|
115
149
|
return;
|
|
116
150
|
}
|
|
151
|
+
if (opts.describe) {
|
|
152
|
+
const tool = tools.find((candidate) => candidate.name === opts.describe);
|
|
153
|
+
if (!tool) {
|
|
154
|
+
throw new IntegrationsCliError(`No tool named '${opts.describe}' on ${connectionLabel(connection)}.`, { expected: true });
|
|
155
|
+
}
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
printJson(tool);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.log(`${chalk.bold(tool.name)} ${chalk.dim(`on ${connectionLabel(connection)}`)}`);
|
|
161
|
+
if (tool.description)
|
|
162
|
+
console.log(chalk.dim(` ${tool.description}`));
|
|
163
|
+
printToolSchema(tool);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (opts.json) {
|
|
167
|
+
printJson(payload);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.log(chalk.dim(`App: ${connectionLabel(connection)}`));
|
|
117
171
|
for (const tool of tools) {
|
|
118
172
|
const label = tool.title && tool.title !== tool.name ? ` ${chalk.dim(tool.title)}` : "";
|
|
119
|
-
|
|
173
|
+
const required = requiredInputHint(tool.inputSchema);
|
|
174
|
+
console.log(`${chalk.bold(tool.name)}${label}${required ? ` ${chalk.dim(required)}` : ""}`);
|
|
120
175
|
}
|
|
121
|
-
console.log(chalk.dim(`\n${tools.length} tools.
|
|
176
|
+
console.log(chalk.dim(`\n${tools.length} tools. Describe inputs: hq integrations tools --provider ${bareProvider(connection.provider)} --describe <tool>\nCall one with: hq integrations call <tool> --provider ${bareProvider(connection.provider)} --args '<json>'`));
|
|
122
177
|
});
|
|
123
178
|
integrations
|
|
124
179
|
.command("call <tool>")
|
|
@@ -471,7 +471,7 @@ export declare const claudeConfigFormat: ConfigFormat<Record<string, unknown>>;
|
|
|
471
471
|
/**
|
|
472
472
|
* Build the Claude server definition emitted into `mcpServers.<name>` from a
|
|
473
473
|
* manifest: pass the transport fields through, resolve `${secret:}` in every
|
|
474
|
-
* header/env value (recording the plaintexts in `secretSink` for redaction), and
|
|
474
|
+
* argument/header/env value (recording the plaintexts in `secretSink` for redaction), and
|
|
475
475
|
* stamp `_hqPack` provenance. The output object's key order is deterministic so a
|
|
476
476
|
* re-run produces a byte-identical def (idempotency depends on stable serialize).
|
|
477
477
|
*/
|
|
@@ -673,7 +673,7 @@ export declare const codexConfigFormat: ConfigFormat<CodexTomlDoc>;
|
|
|
673
673
|
export declare function appendCodexTable(originalText: string, name: string, def: TomlTable): string;
|
|
674
674
|
/**
|
|
675
675
|
* Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
|
|
676
|
-
* transport fields through, resolve `${secret:}` in every header/env value
|
|
676
|
+
* transport fields through, resolve `${secret:}` in every argument/header/env value
|
|
677
677
|
* (recording plaintexts in `secretSink` for redaction), stamp `_hqPack` provenance,
|
|
678
678
|
* and write the per-tool `approval_mode` from the manifest where present (the Codex-
|
|
679
679
|
* specific field — `[mcp_servers.<name>.tools.<t>]` with `approval_mode = "…"`).
|
|
@@ -934,7 +934,7 @@ export const claudeConfigFormat = {
|
|
|
934
934
|
/**
|
|
935
935
|
* Build the Claude server definition emitted into `mcpServers.<name>` from a
|
|
936
936
|
* manifest: pass the transport fields through, resolve `${secret:}` in every
|
|
937
|
-
* header/env value (recording the plaintexts in `secretSink` for redaction), and
|
|
937
|
+
* argument/header/env value (recording the plaintexts in `secretSink` for redaction), and
|
|
938
938
|
* stamp `_hqPack` provenance. The output object's key order is deterministic so a
|
|
939
939
|
* re-run produces a byte-identical def (idempotency depends on stable serialize).
|
|
940
940
|
*/
|
|
@@ -944,8 +944,9 @@ export function buildClaudeServerDef(manifest, pack, resolve, secretSink) {
|
|
|
944
944
|
def.url = manifest.url;
|
|
945
945
|
if (manifest.command !== undefined)
|
|
946
946
|
def.command = manifest.command;
|
|
947
|
-
if (manifest.args !== undefined)
|
|
948
|
-
def.args = manifest.args;
|
|
947
|
+
if (manifest.args !== undefined) {
|
|
948
|
+
def.args = manifest.args.map((arg) => resolveSecretRefs(arg, resolve, secretSink));
|
|
949
|
+
}
|
|
949
950
|
if (manifest.headers !== undefined) {
|
|
950
951
|
def.headers = resolveStringMap(manifest.headers, resolve, secretSink);
|
|
951
952
|
}
|
|
@@ -1371,7 +1372,7 @@ export function appendCodexTable(originalText, name, def) {
|
|
|
1371
1372
|
}
|
|
1372
1373
|
/**
|
|
1373
1374
|
* Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
|
|
1374
|
-
* transport fields through, resolve `${secret:}` in every header/env value
|
|
1375
|
+
* transport fields through, resolve `${secret:}` in every argument/header/env value
|
|
1375
1376
|
* (recording plaintexts in `secretSink` for redaction), stamp `_hqPack` provenance,
|
|
1376
1377
|
* and write the per-tool `approval_mode` from the manifest where present (the Codex-
|
|
1377
1378
|
* specific field — `[mcp_servers.<name>.tools.<t>]` with `approval_mode = "…"`).
|
|
@@ -1384,8 +1385,9 @@ export function buildCodexServerDef(manifest, pack, resolve, secretSink) {
|
|
|
1384
1385
|
def.url = manifest.url;
|
|
1385
1386
|
if (manifest.command !== undefined)
|
|
1386
1387
|
def.command = manifest.command;
|
|
1387
|
-
if (manifest.args !== undefined)
|
|
1388
|
-
def.args = manifest.args;
|
|
1388
|
+
if (manifest.args !== undefined) {
|
|
1389
|
+
def.args = manifest.args.map((arg) => resolveSecretRefs(arg, resolve, secretSink));
|
|
1390
|
+
}
|
|
1389
1391
|
if (manifest.headers !== undefined) {
|
|
1390
1392
|
def.headers = resolveStringMap(manifest.headers, resolve, secretSink);
|
|
1391
1393
|
}
|
|
@@ -34,6 +34,18 @@ export interface VaultApiOptions {
|
|
|
34
34
|
* network call; this stays the backstop for a new call site that forgets to.
|
|
35
35
|
*/
|
|
36
36
|
export declare function rewritePathForApiKey(path: string): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Client-family identifier sent on every authenticated vault-API request.
|
|
39
|
+
*
|
|
40
|
+
* hq-pro reads this exact header into its closed `RosterClientFamily` enum
|
|
41
|
+
* (`roster-client-family.ts`) and tags each agents-request Sentry event with it
|
|
42
|
+
* (`handler.ts` setSentryClientFamily). Sending it means an event this CLI
|
|
43
|
+
* raises reads `hq_client=hq_cli` and names its origin instead of `absent` — the
|
|
44
|
+
* missing attribution behind Sentry hq-pro 7617401436. It is a fixed literal,
|
|
45
|
+
* never caller-supplied text, used only for telemetry/attribution: no
|
|
46
|
+
* authorization or request signing depends on it.
|
|
47
|
+
*/
|
|
48
|
+
export declare const HQ_CLIENT_NAME = "@indigoai-us/hq-cli";
|
|
37
49
|
export declare function vaultApiFetch(opts: VaultApiOptions): Promise<Response>;
|
|
38
50
|
/**
|
|
39
51
|
* Public (NONE-auth) GET against the vault API — no bearer token. The
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -124,6 +124,18 @@ export function rewritePathForApiKey(path) {
|
|
|
124
124
|
}
|
|
125
125
|
return null;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Client-family identifier sent on every authenticated vault-API request.
|
|
129
|
+
*
|
|
130
|
+
* hq-pro reads this exact header into its closed `RosterClientFamily` enum
|
|
131
|
+
* (`roster-client-family.ts`) and tags each agents-request Sentry event with it
|
|
132
|
+
* (`handler.ts` setSentryClientFamily). Sending it means an event this CLI
|
|
133
|
+
* raises reads `hq_client=hq_cli` and names its origin instead of `absent` — the
|
|
134
|
+
* missing attribution behind Sentry hq-pro 7617401436. It is a fixed literal,
|
|
135
|
+
* never caller-supplied text, used only for telemetry/attribution: no
|
|
136
|
+
* authorization or request signing depends on it.
|
|
137
|
+
*/
|
|
138
|
+
export const HQ_CLIENT_NAME = "@indigoai-us/hq-cli";
|
|
127
139
|
export async function vaultApiFetch(opts) {
|
|
128
140
|
let path = opts.path;
|
|
129
141
|
if (opts.token.startsWith("hqk_")) {
|
|
@@ -156,6 +168,7 @@ export async function vaultApiFetch(opts) {
|
|
|
156
168
|
headers: {
|
|
157
169
|
Authorization: `Bearer ${opts.token}`,
|
|
158
170
|
'Content-Type': 'application/json',
|
|
171
|
+
'x-hq-client-name': HQ_CLIENT_NAME,
|
|
159
172
|
},
|
|
160
173
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
161
174
|
signal: opts.signal,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.103.
|
|
3
|
+
"version": "5.103.21",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
|
-
"@indigoai-us/hq-cloud": "~6.15.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.15.37",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|