@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 CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.22] — 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - `hq auth login` now points account signup guidance at the canonical `onboarding.hq.computer` host instead of the retired Indigo onboarding domain.
10
+
11
+ ## [5.103.21] — 2026-08-24
12
+
13
+ ### Fixed
14
+
15
+ - `hq agents jobs list|pause|cancel` now fails closed on an agent box instead of
16
+ provoking a cross-agent rejection on the server. These off-box operator
17
+ commands are reachable on agent boxes, where the CLI authenticates with the
18
+ box's own agent machine identity; forwarding another agent's identifier into
19
+ `GET /v1/agents/{uid}/jobs` was refused server-side by hq-pro's self-only jobs
20
+ guard and logged as an unexpected warning, while the CLI mislabeled the 403 as
21
+ "You need owner/admin on this company." — a role problem a box can never
22
+ satisfy. The commands now resolve the agent reference through the company
23
+ roster (a slug or name becomes an `agt_` uid, or fails locally), and refuse a
24
+ cross-agent target before any request leaves the box, naming the on-box
25
+ `hq-agent-jobs` tool and the person session required to manage another agent's
26
+ jobs. A server-side `CROSS_AGENT_JOB` denial is now rendered as identity-scoped
27
+ guidance rather than the owner/admin copy. Separately, every authenticated
28
+ vault-API request now carries an `x-hq-client-name` header so its origin is
29
+ attributable in telemetry and error reports. (Sentry 7617401436)
30
+
5
31
  ## [5.103.20] — 2026-08-24
6
32
 
7
33
  ### Changed
@@ -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`). */
@@ -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 token = (await resolveVaultCredential()).token;
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
- .action(async (agentUid, jobId) => {
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 token = (await resolveVaultCredential()).token;
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
- .action(async (agentUid, jobId) => {
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 token = (await resolveVaultCredential()).token;
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) {
@@ -8,7 +8,7 @@
8
8
  * hq auth status — show whether a valid session is cached + expiry
9
9
  *
10
10
  * Sign-up is owned by the onboarding web app at
11
- * https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
11
+ * https://onboarding.hq.computer. `hq auth login` signs an existing account
12
12
  * into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
13
13
  * session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
14
14
  * by the deploy + sync skills.
@@ -8,7 +8,7 @@
8
8
  * hq auth status — show whether a valid session is cached + expiry
9
9
  *
10
10
  * Sign-up is owned by the onboarding web app at
11
- * https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
11
+ * https://onboarding.hq.computer. `hq auth login` signs an existing account
12
12
  * into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
13
13
  * session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
14
14
  * by the deploy + sync skills.
@@ -96,7 +96,7 @@ export function registerAuthCommands(program) {
96
96
  console.error(chalk.dim(callbackPortCollisionGuidance(DEFAULT_COGNITO.port ?? 8765)));
97
97
  }
98
98
  else {
99
- console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.indigo-hq.com"));
99
+ console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.hq.computer"));
100
100
  }
101
101
  process.exit(1);
102
102
  }
@@ -134,12 +134,36 @@ 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<{
140
156
  installationId: string;
141
157
  connectionId: string;
142
158
  }>;
159
+ /**
160
+ * Permanently remove a revoked connection tombstone. The admin purge endpoint
161
+ * deliberately accepts only the connection id: it derives the company and
162
+ * enforces owner authorization from the authenticated connection record.
163
+ */
164
+ export declare function purgeConnection(token: string, companyUid: string, connectionId: string): Promise<{
165
+ connectionId: string;
166
+ }>;
143
167
  export interface OAuthStartResult {
144
168
  provider: string;
145
169
  displayName: string;
@@ -181,6 +205,11 @@ export declare function updateGovernance(token: string, companyUid: string, inpu
181
205
  writePolicy?: WritePolicy;
182
206
  writeAllowlist: WriteAllowlistGrant[];
183
207
  }>;
208
+ /**
209
+ * Mark or unmark one tool as read-safe. The service owns tool classification:
210
+ * it refuses attempts to mark write/destructive tools read-safe.
211
+ */
212
+ export declare function setReadSafe(token: string, companyUid: string, connectionId: string, toolName: string, readSafe: boolean): Promise<void>;
184
213
  export interface ConnectionAccess {
185
214
  connectionId: string;
186
215
  provider: string;
@@ -74,6 +74,27 @@ export async function uninstallIntegration(token, companyUid, installationId) {
74
74
  await raiseForResponse(res, "Failed to disconnect the app");
75
75
  return (await res.json());
76
76
  }
77
+ /**
78
+ * Permanently remove a revoked connection tombstone. The admin purge endpoint
79
+ * deliberately accepts only the connection id: it derives the company and
80
+ * enforces owner authorization from the authenticated connection record.
81
+ */
82
+ export async function purgeConnection(token, companyUid, connectionId) {
83
+ // Keep the company in this client's call signature with the other
84
+ // connection mutations. It is resolved before the target connection so a
85
+ // caller cannot use a slug from a different company, but the server's purge
86
+ // contract intentionally takes only connectionId in its body.
87
+ void companyUid;
88
+ const res = await vaultApiFetch({
89
+ token,
90
+ path: "/v1/integrations/admin/purge",
91
+ method: "POST",
92
+ body: { connectionId },
93
+ });
94
+ if (!res.ok)
95
+ await raiseForResponse(res, "Failed to purge the revoked connection");
96
+ return (await res.json());
97
+ }
77
98
  export async function startOAuth(token, companyUid, input) {
78
99
  const res = await vaultApiFetch({
79
100
  token,
@@ -110,6 +131,20 @@ export async function updateGovernance(token, companyUid, input) {
110
131
  await raiseForResponse(res, "Failed to update the app's settings");
111
132
  return (await res.json());
112
133
  }
134
+ /**
135
+ * Mark or unmark one tool as read-safe. The service owns tool classification:
136
+ * it refuses attempts to mark write/destructive tools read-safe.
137
+ */
138
+ export async function setReadSafe(token, companyUid, connectionId, toolName, readSafe) {
139
+ const res = await vaultApiFetch({
140
+ token,
141
+ path: "/v1/integrations/admin/read-safe",
142
+ method: "POST",
143
+ body: { companyUid, connectionId, toolName, readSafe },
144
+ });
145
+ if (!res.ok)
146
+ await raiseForResponse(res, "Failed to update the tool's read-safe setting");
147
+ }
113
148
  /**
114
149
  * Live open-approval list, read from hq-pro's confirm-queue state (not the
115
150
  * audit feed). Unlike the audit-derived reconstruction this replaces, a call
@@ -20,7 +20,7 @@ import chalk from "chalk";
20
20
  import open from "open";
21
21
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
22
22
  import { getCompanyUid } from "../utils/vault-api.js";
23
- import { IntegrationsCliError, bareProvider, printJson, resolveConnection, } from "./integrations-core.js";
23
+ import { IntegrationsCliError, bareProvider, connectionDomain, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
24
24
  import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
25
25
  import { startLoopbackListener } from "./integrations-oauth.js";
26
26
  /** hq-pro's machine code for "this endpoint needs a browser sign-in". */
@@ -183,7 +183,7 @@ async function resolveKey(opts, appLabel) {
183
183
  * most-specific-first so an explicit flag always wins over the positional
184
184
  * argument's heuristics.
185
185
  */
186
- async function resolveTarget(token, companyUid, app, opts) {
186
+ async function resolveTarget(token, companyUid, app, opts, preserveDomain = false) {
187
187
  if (opts.docsUrl) {
188
188
  const found = await discoverDocs(token, companyUid, opts.docsUrl);
189
189
  if (!found.discovery || !found.discoveryReceiptId) {
@@ -222,6 +222,17 @@ async function resolveTarget(token, companyUid, app, opts) {
222
222
  // authClass, both of which make the connect cleaner than a raw domain
223
223
  // lookup. Missing it is fine — the domain path still works.
224
224
  const match = await findCatalogEntry(token, companyUid, app);
225
+ if (preserveDomain) {
226
+ // Reviving a revoked connection is keyed by its canonical domain in
227
+ // hq-pro. A catalog entry id is useful auth metadata, but replacing the
228
+ // domain with it can create a distinct connection instead of reviving
229
+ // the original acct_ row.
230
+ return {
231
+ ref: { domain: app },
232
+ ...(match?.authClass ? { authClass: match.authClass } : {}),
233
+ label: match?.name || app,
234
+ };
235
+ }
225
236
  return catalogEntryToTarget(match, { ref: { domain: app }, label: app });
226
237
  }
227
238
  // A bare name (`notion`, `atlassian`) is how the catalog reads to a person —
@@ -278,6 +289,14 @@ async function findCatalogEntry(token, companyUid, domain) {
278
289
  function domainLabel(domain) {
279
290
  return domain.trim().toLowerCase().split(".")[0] ?? "";
280
291
  }
292
+ /** A catalog display name's copy-pasteable command-line slug. */
293
+ function displayNameSlug(name) {
294
+ return name
295
+ .trim()
296
+ .toLowerCase()
297
+ .replace(/[^a-z0-9]+/g, "-")
298
+ .replace(/^-+|-+$/g, "");
299
+ }
281
300
  /**
282
301
  * Best-effort resolution of a bare name to a single catalog entry. Matches the
283
302
  * entry's registrable domain label (`notion` → `notion.com`) or an exact
@@ -293,7 +312,8 @@ async function findCatalogEntryByName(token, companyUid, name) {
293
312
  return null;
294
313
  const entries = await listCatalog(token, companyUid, { query: name, limit: 20 });
295
314
  const matches = entries.filter((entry) => domainLabel(entry.domain) === want ||
296
- entry.name?.trim().toLowerCase() === want);
315
+ entry.name?.trim().toLowerCase() === want ||
316
+ (entry.name !== undefined && displayNameSlug(entry.name) === displayNameSlug(want)));
297
317
  if (matches.length === 0)
298
318
  return null;
299
319
  // Collapse rows that point at the same app (same domain) before deciding
@@ -415,14 +435,15 @@ async function completeCredentialIfNeeded(token, companyUid, target, opts, resul
415
435
  });
416
436
  }
417
437
  /** Print the outcome of a successful connect. */
418
- function reportInstall(result, opts) {
438
+ function reportInstall(result, opts, expectedRevivedConnectionId) {
419
439
  if (opts.json) {
420
440
  printJson(result);
421
441
  return;
422
442
  }
423
443
  const { installation, connection } = result;
424
444
  const toolCount = result.mcp?.tools?.length;
425
- console.log(chalk.green(`Connected ${chalk.bold(installation.displayName)}`) +
445
+ const revived = connection.id === expectedRevivedConnectionId;
446
+ console.log(chalk.green(`${revived ? "Revived" : "Connected"} ${chalk.bold(installation.displayName)}`) +
426
447
  (typeof toolCount === "number" ? chalk.dim(` — ${toolCount} tools available`) : ""));
427
448
  console.log(chalk.dim(` connection: ${connection.id}`));
428
449
  if (installation.status === "needs_credentials") {
@@ -430,6 +451,49 @@ function reportInstall(result, opts) {
430
451
  }
431
452
  console.log(chalk.dim(` Try it: hq integrations tools --provider ${bareProvider(connection.provider)}`));
432
453
  }
454
+ /**
455
+ * The shared `connect <domain>` execution path. Reconnect's revoked fallback
456
+ * intentionally comes through here rather than re-installing its saved MCP
457
+ * URL: hq-pro recognizes the domain and revives the revoked row in place.
458
+ */
459
+ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
460
+ const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
461
+ const authMode = opts.auth ?? target.authClass;
462
+ if (authMode === "oauth") {
463
+ const result = await connectViaOAuth(token, companyUid, target, opts);
464
+ if (result)
465
+ reportInstall(result, opts, expectedRevivedConnectionId);
466
+ return;
467
+ }
468
+ // A key is only collected when something already says one is needed, or
469
+ // the caller supplied one — otherwise a no-auth app would pointlessly
470
+ // prompt.
471
+ const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
472
+ const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
473
+ try {
474
+ const result = await installIntegration(token, companyUid, {
475
+ ...target.ref,
476
+ ...(bearerToken
477
+ ? { authMode: "bearer", bearerToken }
478
+ : authMode === "none"
479
+ ? { authMode: "none" }
480
+ : {}),
481
+ });
482
+ reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts, expectedRevivedConnectionId);
483
+ }
484
+ catch (err) {
485
+ // Server-authoritative detection: the endpoint turned out to be
486
+ // OAuth-protected, so run the browser flow instead of making the
487
+ // caller re-issue the command with --auth oauth.
488
+ if (isOAuthRequiredError(err)) {
489
+ const result = await connectViaOAuth(token, companyUid, target, opts);
490
+ if (result)
491
+ reportInstall(result, opts, expectedRevivedConnectionId);
492
+ return;
493
+ }
494
+ throw err;
495
+ }
496
+ }
433
497
  export function registerConnectCommands(integrations) {
434
498
  integrations
435
499
  .command("catalog [query]")
@@ -552,46 +616,11 @@ export function registerConnectCommands(integrations) {
552
616
  assertAuthMode(opts.auth);
553
617
  const token = await ensureCognitoIdToken();
554
618
  const companyUid = await getCompanyUid(token, opts.company);
555
- const target = await resolveTarget(token, companyUid, app, opts);
556
- const authMode = opts.auth ?? target.authClass;
557
- if (authMode === "oauth") {
558
- const result = await connectViaOAuth(token, companyUid, target, opts);
559
- if (result)
560
- reportInstall(result, opts);
561
- return;
562
- }
563
- // A key is only collected when something already says one is needed, or
564
- // the caller supplied one — otherwise a no-auth app would pointlessly
565
- // prompt.
566
- const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
567
- const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
568
- try {
569
- const result = await installIntegration(token, companyUid, {
570
- ...target.ref,
571
- ...(bearerToken
572
- ? { authMode: "bearer", bearerToken }
573
- : authMode === "none"
574
- ? { authMode: "none" }
575
- : {}),
576
- });
577
- reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts);
578
- }
579
- catch (err) {
580
- // Server-authoritative detection: the endpoint turned out to be
581
- // OAuth-protected, so run the browser flow instead of making the
582
- // caller re-issue the command with --auth oauth.
583
- if (isOAuthRequiredError(err)) {
584
- const result = await connectViaOAuth(token, companyUid, target, opts);
585
- if (result)
586
- reportInstall(result, opts);
587
- return;
588
- }
589
- throw err;
590
- }
619
+ await connectApp(token, companyUid, app, opts);
591
620
  });
592
621
  integrations
593
622
  .command("reconnect [app]")
594
- .description("Re-authenticate a connected app whose credentials stopped working")
623
+ .description("Re-authenticate a connected app; use --connect to re-add a revoked app")
595
624
  .option("--company <slug>", "Company slug, e.g. indigo")
596
625
  .option("--provider <slug>", "Connected app (e.g. linear)")
597
626
  .option("--connection <id>", "Connection id (acct_…)")
@@ -600,6 +629,7 @@ export function registerConnectCommands(integrations) {
600
629
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
601
630
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
602
631
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
632
+ .option("--connect", "For a revoked row, run `connect <domain>` to re-add and revive it")
603
633
  .option("--json", "Machine-readable output")
604
634
  .action(async (app, opts) => {
605
635
  // Same validation as `connect`. Without it a typo like `--auth oauth2`
@@ -609,7 +639,23 @@ export function registerConnectCommands(integrations) {
609
639
  assertAuthMode(opts.auth);
610
640
  const token = await ensureCognitoIdToken();
611
641
  const companyUid = await getCompanyUid(token, opts.company);
612
- const connection = await resolveConnection(token, companyUid, app, opts);
642
+ const connection = await resolveConnection(token, companyUid, app, opts, {
643
+ allowSingleRevoked: Boolean(opts.connect),
644
+ });
645
+ if (connection.status === "revoked") {
646
+ const details = revokedConnectionDetails(connection, opts.company);
647
+ if (!opts.connect) {
648
+ if (opts.json)
649
+ printJson(details);
650
+ else {
651
+ console.log(chalk.yellow(details.reason));
652
+ console.log(chalk.yellow(`Re-add it with: ${details.fixPath}`));
653
+ }
654
+ return;
655
+ }
656
+ await connectApp(token, companyUid, connectionDomain(connection), opts, connection.id);
657
+ return;
658
+ }
613
659
  const url = connection.installation?.surface?.url;
614
660
  if (!url) {
615
661
  throw new IntegrationsCliError(`${bareProvider(connection.provider)} was not installed through the app catalog, so it cannot be reconnected from here.`, { expected: true });
@@ -74,6 +74,16 @@ export interface AdminConnection {
74
74
  };
75
75
  installation?: FactoryInstallation | null;
76
76
  }
77
+ /**
78
+ * The actionable state returned instead of attempting to use a revoked
79
+ * connection. Keep this machine-readable so commands and scripts get the same
80
+ * recovery path instead of treating a listed row as absent.
81
+ */
82
+ export interface RevokedConnectionDetails {
83
+ status: "revoked";
84
+ reason: string;
85
+ fixPath: string;
86
+ }
77
87
  export interface AdminAuditEvent {
78
88
  timestamp: string;
79
89
  memberOrAgent: string;
@@ -201,13 +211,23 @@ export declare function fetchAdminSurface(token: string, companyUid: string): Pr
201
211
  export declare function fetchConnections(token: string, companyUid: string): Promise<AdminConnection[]>;
202
212
  /**
203
213
  * Resolve one connection by `--connection acct_…` or `--provider linear`
204
- * (matches `factory:<slug>` and bare provider ids, case-insensitive). Errors
205
- * list what IS connected so the fix is one command away.
214
+ * (matches `factory:<slug>`, bare provider ids, and an installation's human
215
+ * display-name slug, case-insensitive). The legacy provider id remains a
216
+ * first-class match, so scripts that saved opaque historical slugs keep
217
+ * working. Errors list what IS connected so the fix is one command away.
206
218
  */
207
219
  export declare function selectConnection(connections: AdminConnection[], opts: {
208
220
  connection?: string;
209
221
  provider?: string;
210
222
  }): AdminConnection;
223
+ /**
224
+ * The hostname re-add needs. Prefer the server's canonical installation domain;
225
+ * an older row may only retain its MCP URL, and provider is the last-resort
226
+ * human-safe query when neither was stored.
227
+ */
228
+ export declare function connectionDomain(connection: AdminConnection): string;
229
+ /** A revoked row is still addressable, but it cannot make a live MCP call. */
230
+ export declare function revokedConnectionDetails(connection: AdminConnection, companySlug?: string): RevokedConnectionDetails;
211
231
  /**
212
232
  * Resolve a connection the caller named positionally OR through the
213
233
  * `--provider` / `--connection` flags. Every management verb takes an optional
@@ -217,6 +237,8 @@ export declare function selectConnection(connections: AdminConnection[], opts: {
217
237
  export declare function resolveConnection(token: string, companyUid: string, app: string | undefined, opts: {
218
238
  provider?: string;
219
239
  connection?: string;
240
+ }, recoveryOpts?: {
241
+ allowSingleRevoked?: boolean;
220
242
  }): Promise<AdminConnection>;
221
243
  export declare function callGateway(token: string, params: Record<string, unknown>): Promise<GatewayMessage>;
222
244
  /**