@indigoai-us/hq-cli 5.110.0 → 5.111.1

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.111.1] — 2026-09-14
6
+
7
+ ### Fixed
8
+
9
+ - A new bot's first message no longer tells its signed-in owner they are not
10
+ signed in. While the bot fetched its own sign-in, a check of the owner's
11
+ sign-in in the same process could read the bot's credentials instead and find
12
+ no account. Sign-in reads now wait for that to finish.
13
+ - When a bot cannot read its owner's account right away, it tries again before
14
+ answering, and never asks a signed-in owner to sign in or run a command. The
15
+ bot's log now records why an owner check failed.
16
+
17
+ ## [5.111.0] — 2026-09-14
18
+
19
+ ### Added
20
+
21
+ - `hq skill delete <target>`: delete a company skill and all of its files for
22
+ everyone. Accepts a `skl_…` id, a SKILL.md path, or a skill slug; names the
23
+ skill and asks for confirmation (`--yes` skips it, and it refuses to run
24
+ unattended without `--yes`). Synced copies are removed on the next sync.
25
+ Requires access-admin rights on the skill.
26
+
5
27
  ## [5.110.0] — 2026-09-14
6
28
 
7
29
  ### Added
@@ -5,12 +5,13 @@
5
5
  * immutable UID, surfaces its generated runtime wrapper, and syncs it.
6
6
  * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
7
  * the same improvement thread shown in HQ Console. It never uploads a modified
8
- * SKILL.md and cannot overwrite live content. Structured suggest/list/review
9
- * commands intentionally are not registered.
8
+ * SKILL.md and cannot overwrite live content. `hq skill delete <target>` removes
9
+ * a skill and its files for the whole company (confirmation unless --yes).
10
+ * Structured suggest/list/review commands intentionally are not registered.
10
11
  */
11
12
  import { Command } from "commander";
12
13
  import { ensureCognitoToken } from "../utils/cognito-session.js";
13
- import { vaultApiFetch } from "../utils/vault-api.js";
14
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
14
15
  import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
15
16
  export declare const SKILL_UID_PATTERN: RegExp;
16
17
  export declare const SKILL_SLUG_PATTERN: RegExp;
@@ -98,9 +99,17 @@ export declare function mapSkillError(status: number, body: Record<string, unkno
98
99
  export declare function skillApiError(status: number, body: Record<string, unknown>, opts?: {
99
100
  machineIdentity?: boolean;
100
101
  }): Error;
102
+ export type SkillConfirmFn = (message: string) => Promise<boolean>;
103
+ /**
104
+ * Resolve a delete target: a `skl_…` uid, a SKILL.md path or its directory, or
105
+ * a bare skill slug under `companies/<company>/skills/<slug>/`.
106
+ */
107
+ export declare function resolveDeleteTarget(target: string, cwd: string, hqRoot: string, companySlug: string): string;
101
108
  interface SkillCommandDeps {
102
109
  ensureToken?: typeof ensureCognitoToken;
103
110
  apiFetch?: typeof vaultApiFetch;
111
+ confirm?: SkillConfirmFn;
112
+ resolveCompanyUid?: typeof getCompanyUid;
104
113
  cwd?: () => string;
105
114
  hqRoot?: string;
106
115
  syncFile?: (input: SkillSyncInput) => Promise<SkillSyncResult>;
@@ -5,8 +5,9 @@
5
5
  * immutable UID, surfaces its generated runtime wrapper, and syncs it.
6
6
  * `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
7
7
  * the same improvement thread shown in HQ Console. It never uploads a modified
8
- * SKILL.md and cannot overwrite live content. Structured suggest/list/review
9
- * commands intentionally are not registered.
8
+ * SKILL.md and cannot overwrite live content. `hq skill delete <target>` removes
9
+ * a skill and its files for the whole company (confirmation unless --yes).
10
+ * Structured suggest/list/review commands intentionally are not registered.
10
11
  */
11
12
  import * as fs from "node:fs";
12
13
  import * as path from "node:path";
@@ -14,7 +15,8 @@ import chalk from "chalk";
14
15
  import yaml from "js-yaml";
15
16
  import { share } from "@indigoai-us/hq-cloud";
16
17
  import { ensureCognitoToken, DEFAULT_HQ_ROOT, buildVaultConfig, isMachineIdentity, } from "../utils/cognito-session.js";
17
- import { vaultApiFetch } from "../utils/vault-api.js";
18
+ import * as readline from "node:readline";
19
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
18
20
  import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
19
21
  import { AuthError } from "../utils/auth-error.js";
20
22
  import { redactErrorText } from "../utils/redact-error-text.js";
@@ -322,6 +324,31 @@ export function skillApiError(status, body, opts = {}) {
322
324
  }
323
325
  return new Error(message);
324
326
  }
327
+ function realConfirm(message) {
328
+ const rl = readline.createInterface({
329
+ input: process.stdin,
330
+ output: process.stdout,
331
+ });
332
+ return new Promise((resolve) => {
333
+ rl.question(`${message} [y/N] `, (answer) => {
334
+ rl.close();
335
+ resolve(/^y(es)?$/i.test(answer.trim()));
336
+ });
337
+ });
338
+ }
339
+ /**
340
+ * Resolve a delete target: a `skl_…` uid, a SKILL.md path or its directory, or
341
+ * a bare skill slug under `companies/<company>/skills/<slug>/`.
342
+ */
343
+ export function resolveDeleteTarget(target, cwd, hqRoot, companySlug) {
344
+ if (SKILL_UID_PATTERN.test(target))
345
+ return target;
346
+ const asPath = path.resolve(cwd, target);
347
+ if (!fs.existsSync(asPath) && SKILL_SLUG_PATTERN.test(target)) {
348
+ return resolveSkillUid(canonicalCompanySkillPath(hqRoot, companySlug, target), cwd);
349
+ }
350
+ return resolveSkillUid(target, cwd);
351
+ }
325
352
  export function registerSkillCommand(program, deps = {}) {
326
353
  const ensureToken = deps.ensureToken ?? ensureCognitoToken;
327
354
  const apiFetch = deps.apiFetch ?? vaultApiFetch;
@@ -329,6 +356,8 @@ export function registerSkillCommand(program, deps = {}) {
329
356
  const hqRoot = deps.hqRoot ?? DEFAULT_HQ_ROOT;
330
357
  const syncFile = deps.syncFile ?? defaultSyncFile;
331
358
  const surfaceSkillFn = deps.surfaceSkillFn ?? surfaceCompanySkill;
359
+ const confirm = deps.confirm ?? realConfirm;
360
+ const resolveCompanyUid = deps.resolveCompanyUid ?? getCompanyUid;
332
361
  const skill = program
333
362
  .command("skill")
334
363
  .description("Create company skills and discuss improvements")
@@ -516,6 +545,53 @@ export function registerSkillCommand(program, deps = {}) {
516
545
  console.log(` Message: ${posted.body}`);
517
546
  console.log(chalk.dim(" Review it in HQ Console → Skills → Improvements"));
518
547
  });
548
+ skill
549
+ .command("delete <target>")
550
+ .description("Delete a company skill and all of its files for everyone (skl_… uid, SKILL.md path, or skill slug). Prompts for confirmation unless --yes.")
551
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
552
+ .action(async (target, opts) => {
553
+ const parentOpts = skill.opts();
554
+ const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
555
+ const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
556
+ const skillUid = resolveDeleteTarget(target, cwd(), resolvedRoot, companySlug);
557
+ const token = await ensureToken();
558
+ const companyUid = await resolveCompanyUid(token, companySlug);
559
+ const skillPath = `/v1/skills/${encodeURIComponent(companyUid)}/${encodeURIComponent(skillUid)}`;
560
+ const detail = await apiFetch({ token, path: skillPath, method: "GET" });
561
+ if (!detail.ok) {
562
+ const body = (await detail.json().catch(() => ({})));
563
+ throw skillApiError(detail.status, body, {
564
+ machineIdentity: isMachineIdentity(),
565
+ });
566
+ }
567
+ const { skill: record } = (await detail.json());
568
+ const name = typeof record?.name === "string" ? record.name : skillUid;
569
+ if (opts.yes !== true) {
570
+ if (!process.stdin.isTTY) {
571
+ throw localSkillError(`Refusing to delete '${name}' without confirmation. Re-run with --yes.`);
572
+ }
573
+ const ok = await confirm(`Delete skill '${name}' (${skillUid}) and all of its files for everyone in ${companySlug}? This can't be undone from the CLI.`);
574
+ if (!ok) {
575
+ console.log("Cancelled — nothing was deleted.");
576
+ return;
577
+ }
578
+ }
579
+ const response = await apiFetch({ token, path: skillPath, method: "DELETE" });
580
+ if (!response.ok) {
581
+ const body = (await response.json().catch(() => ({})));
582
+ throw skillApiError(response.status, body, {
583
+ machineIdentity: isMachineIdentity(),
584
+ });
585
+ }
586
+ const result = (await response.json());
587
+ console.log(chalk.green(`Skill deleted: ${name}`));
588
+ console.log(` Skill: ${skillUid}`);
589
+ console.log(` Files: ${typeof result.filesDeleted === "number" ? result.filesDeleted : 0} removed`);
590
+ if (result.tombstoneIncomplete === true) {
591
+ console.warn(chalk.yellow("⚠ Some synced computers may not be told to remove their copy. Delete it locally if it reappears."));
592
+ }
593
+ console.log(chalk.dim(" Local copies are removed on the next sync (hq sync now)."));
594
+ });
519
595
  return skill;
520
596
  }
521
597
  //# sourceMappingURL=skill.js.map
@@ -29,6 +29,10 @@ export type OwnerContext = {
29
29
  ownerUid: string;
30
30
  reason: string;
31
31
  checkedAt: string;
32
+ /** True when the owner's sign-in exists and only reading their account failed. */
33
+ signedIn?: boolean;
34
+ /** Technical cause, for logs only; never shown to the model. */
35
+ detail?: string;
32
36
  } | {
33
37
  status: "mismatch";
34
38
  ownerUid: string;
@@ -48,8 +52,12 @@ export interface OwnerLookupIo {
48
52
  status: string;
49
53
  }>>;
50
54
  now?: () => Date;
55
+ /** Test seam for the retry pause. */
56
+ sleep?: (ms: number) => Promise<void>;
51
57
  }
52
58
  export declare const defaultOwnerLookupIo: OwnerLookupIo;
59
+ /** A read right after sign-in or bot start can fail once; try again before reporting it. */
60
+ export declare const OWNER_LOOKUP_RETRY_DELAYS_MS: number[];
53
61
  export declare function lookupOwnerContext(ownerUid: string, io?: OwnerLookupIo): Promise<OwnerContext>;
54
62
  /** Successful lookups are reused for 5 minutes, failures retried after 1 minute. */
55
63
  export declare const OWNER_CONTEXT_TTL_MS: number;
@@ -33,6 +33,23 @@ export const defaultOwnerLookupIo = {
33
33
  return data.memberships;
34
34
  },
35
35
  };
36
+ /** A read right after sign-in or bot start can fail once; try again before reporting it. */
37
+ export const OWNER_LOOKUP_RETRY_DELAYS_MS = [1_000, 2_000];
38
+ async function withRetry(fn, io) {
39
+ const sleep = io.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
40
+ let lastErr;
41
+ for (const delay of [0, ...OWNER_LOOKUP_RETRY_DELAYS_MS]) {
42
+ if (delay)
43
+ await sleep(delay);
44
+ try {
45
+ return await fn();
46
+ }
47
+ catch (err) {
48
+ lastErr = err;
49
+ }
50
+ }
51
+ throw lastErr;
52
+ }
36
53
  export async function lookupOwnerContext(ownerUid, io = defaultOwnerLookupIo) {
37
54
  const checkedAt = (io.now ?? (() => new Date()))().toISOString();
38
55
  let token;
@@ -44,22 +61,23 @@ export async function lookupOwnerContext(ownerUid, io = defaultOwnerLookupIo) {
44
61
  }
45
62
  let signedInAs;
46
63
  try {
47
- signedInAs = await io.callerPersonUid(token);
64
+ signedInAs = await withRetry(() => io.callerPersonUid(token), io);
48
65
  }
49
66
  catch (err) {
50
- return { status: "unavailable", ownerUid, reason: `could not confirm who is signed in (${message(err)})`, checkedAt };
67
+ // The raw error ("…Sign in to HQ once…") would read to the model as "not signed in".
68
+ return { status: "unavailable", ownerUid, reason: "their HQ account could not be read just now", detail: message(err), checkedAt, signedIn: true };
51
69
  }
52
70
  if (signedInAs !== ownerUid)
53
71
  return { status: "mismatch", ownerUid, signedInAs, checkedAt };
54
72
  try {
55
- const rows = await io.memberships(token);
73
+ const rows = await withRetry(() => io.memberships(token), io);
56
74
  const companies = rows
57
75
  .filter((m) => m.status === "active")
58
76
  .map((m) => ({ companyUid: m.companyUid, ...(m.companySlug ? { companySlug: m.companySlug } : {}), role: m.role }));
59
77
  return { status: "ok", ownerUid, companies, checkedAt };
60
78
  }
61
79
  catch (err) {
62
- return { status: "unavailable", ownerUid, reason: `could not read your owner's companies (${message(err)})`, checkedAt };
80
+ return { status: "unavailable", ownerUid, reason: `could not read your owner's companies (${message(err)})`, checkedAt, signedIn: true };
63
81
  }
64
82
  }
65
83
  /** Successful lookups are reused for 5 minutes, failures retried after 1 minute. */
@@ -141,6 +159,12 @@ export function ownerContextBlock(ctx, agentUid) {
141
159
  `The HQ sign-in on this computer belongs to ${ctx.signedInAs}, not your owner, so your owner's companies could not be checked. ` +
142
160
  "Do not say which companies your owner belongs to; tell them you could not check and why.";
143
161
  }
162
+ else if (ctx.signedIn) {
163
+ body =
164
+ `Your owner IS signed in to HQ on this computer, but their companies could not be checked: ${ctx.reason}. ` +
165
+ "Do not guess and do not say they have no company. Do not ask them to sign in or to run any command: " +
166
+ "say you could not read their account just now and that you will check again on their next message.";
167
+ }
144
168
  else {
145
169
  body =
146
170
  `Your owner's companies could not be checked: ${ctx.reason}. ` +
@@ -311,7 +311,14 @@ export async function runBot(deps) {
311
311
  // by other people, who must not be handed the owner's companies.
312
312
  if (deps.ownerContext && sessionScope === "dm") {
313
313
  try {
314
- prompt = `${ownerContextBlock(await deps.ownerContext(), config.agentUid)}\n\n${prompt}`;
314
+ const owner = await deps.ownerContext();
315
+ if (owner.status === "unavailable") {
316
+ log("warn", `owner context unavailable: ${owner.reason}${owner.detail ? ` (${owner.detail})` : ""}`);
317
+ }
318
+ else if (owner.status === "mismatch") {
319
+ log("warn", `owner context mismatch: signed in as ${owner.signedInAs}`);
320
+ }
321
+ prompt = `${ownerContextBlock(owner, config.agentUid)}\n\n${prompt}`;
315
322
  }
316
323
  catch (err) {
317
324
  log("warn", `owner context unavailable: ${err instanceof Error ? err.message : String(err)}`);
@@ -18,6 +18,7 @@
18
18
  * HQ_COGNITO_CALLBACK_PORT — Loopback OAuth callback port
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
+ import { AsyncLocalStorage } from "node:async_hooks";
21
22
  import * as fs from "fs";
22
23
  import * as os from "os";
23
24
  import * as path from "path";
@@ -496,19 +497,39 @@ export function describeCognitoTokenSource(opts = {}) {
496
497
  * `HQ_MACHINE_TOKEN_STATE_DIR` instead. Restores the prior env afterwards.
497
498
  */
498
499
  export async function withMachineTokenStateDir(fn, opts = {}) {
499
- const home = opts.home ?? os.homedir();
500
- const env = opts.env ?? process.env;
501
- const prev = process.env.HQ_STATE_DIR;
502
- process.env.HQ_STATE_DIR = machineTokenStateDir(home, env);
503
- try {
504
- return await fn();
505
- }
506
- finally {
507
- if (prev === undefined)
508
- delete process.env.HQ_STATE_DIR;
509
- else
510
- process.env.HQ_STATE_DIR = prev;
511
- }
500
+ return withTokenStateDirSection(async () => {
501
+ const home = opts.home ?? os.homedir();
502
+ const env = opts.env ?? process.env;
503
+ const prev = process.env.HQ_STATE_DIR;
504
+ process.env.HQ_STATE_DIR = machineTokenStateDir(home, env);
505
+ try {
506
+ return await fn();
507
+ }
508
+ finally {
509
+ if (prev === undefined)
510
+ delete process.env.HQ_STATE_DIR;
511
+ else
512
+ process.env.HQ_STATE_DIR = prev;
513
+ }
514
+ });
515
+ }
516
+ /**
517
+ * The token cache location (`HQ_STATE_DIR`) is process-wide, and a machine mint
518
+ * points it at the machine cache across an await. A person-session read in the
519
+ * same process while that mint is in flight would load the MACHINE tokens: a
520
+ * local bot's resident (machine identity for its own calls) checking its
521
+ * owner's sign-in at startup got the bot's token and "No person entity found".
522
+ * Every section that swaps or reads the cache location runs one at a time;
523
+ * nested sections (a person read falling back to a machine mint) run inline.
524
+ */
525
+ const tokenStateDirSection = new AsyncLocalStorage();
526
+ let tokenStateDirTail = Promise.resolve();
527
+ function withTokenStateDirSection(fn) {
528
+ if (tokenStateDirSection.getStore())
529
+ return fn();
530
+ const run = tokenStateDirTail.then(() => tokenStateDirSection.run(true, fn));
531
+ tokenStateDirTail = run.catch(() => undefined);
532
+ return run;
512
533
  }
513
534
  async function ensureMachineTokens() {
514
535
  return withMachineTokenStateDir(() => getValidMachineTokens(DEFAULT_COGNITO));
@@ -567,6 +588,11 @@ function markPersonSessionRejected(accessToken) {
567
588
  * a browser.
568
589
  */
569
590
  export async function ensureCognitoToken(options = {}) {
591
+ if (wantsMachineCognitoTokens(options))
592
+ return ensureCognitoTokenUnserialized(options);
593
+ return withTokenStateDirSection(() => ensureCognitoTokenUnserialized(options));
594
+ }
595
+ async function ensureCognitoTokenUnserialized(options) {
570
596
  const interactive = options.interactive ?? true;
571
597
  // Machine identities (company agents) mint sessions on demand via
572
598
  // USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
@@ -631,6 +657,11 @@ export async function ensureCognitoToken(options = {}) {
631
657
  * identical to `ensureCognitoToken` there.
632
658
  */
633
659
  export async function ensureCognitoIdToken(options = {}) {
660
+ if (wantsMachineCognitoTokens(options))
661
+ return ensureCognitoIdTokenUnserialized(options);
662
+ return withTokenStateDirSection(() => ensureCognitoIdTokenUnserialized(options));
663
+ }
664
+ async function ensureCognitoIdTokenUnserialized(options) {
634
665
  const interactive = options.interactive ?? true;
635
666
  if (wantsMachineCognitoTokens(options)) {
636
667
  const machine = await ensureMachineTokensMaybeFallback(options);
@@ -704,6 +735,11 @@ export function buildVaultConfig(authToken) {
704
735
  * reason string and can decide what to do next. Never opens a browser.
705
736
  */
706
737
  export async function refreshCachedSession(options = {}) {
738
+ if (wantsMachineCognitoTokens(options))
739
+ return refreshCachedSessionUnserialized(options);
740
+ return withTokenStateDirSection(() => refreshCachedSessionUnserialized(options));
741
+ }
742
+ async function refreshCachedSessionUnserialized(options) {
707
743
  // Machine identities have no refresh token; ensure a valid cached machine
708
744
  // session without forcing a re-mint when the cache is already healthy.
709
745
  // Tokens land under the dedicated machine/daemon state dir.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.110.0",
3
+ "version": "5.111.1",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {