@indigoai-us/hq-cli 5.345.154 → 5.345.156

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/dist/command-catalog.generated.d.ts +59 -7
  3. package/dist/command-catalog.generated.js +75 -7
  4. package/dist/commands/agent-inbox.js +2 -2
  5. package/dist/commands/auth.d.ts +2 -0
  6. package/dist/commands/auth.js +92 -3
  7. package/dist/commands/core.js +1 -1
  8. package/dist/commands/files-browse.d.ts +16 -0
  9. package/dist/commands/files-browse.js +52 -1
  10. package/dist/commands/integrations-core.d.ts +13 -1
  11. package/dist/commands/integrations-core.js +49 -4
  12. package/dist/commands/integrations.js +4 -2
  13. package/dist/commands/library-add.d.ts +6 -2
  14. package/dist/commands/library-add.js +45 -6
  15. package/dist/commands/library-caption.d.ts +38 -1
  16. package/dist/commands/library-caption.js +304 -3
  17. package/dist/commands/library-link.d.ts +7 -3
  18. package/dist/commands/library-link.js +10 -4
  19. package/dist/commands/library-remove.d.ts +122 -0
  20. package/dist/commands/library-remove.js +681 -0
  21. package/dist/commands/library.d.ts +26 -1
  22. package/dist/commands/library.js +101 -14
  23. package/dist/commands/secrets.d.ts +16 -0
  24. package/dist/commands/secrets.js +144 -8
  25. package/dist/commands/storage-offload.d.ts +22 -4
  26. package/dist/commands/storage-offload.js +196 -2
  27. package/dist/commands/storage.js +5 -0
  28. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  29. package/dist/lib/agent-kit/inbox-state.js +5 -1
  30. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  31. package/dist/lib/daemon/machine-login.d.ts +20 -0
  32. package/dist/lib/daemon/machine-login.js +53 -8
  33. package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
  34. package/dist/lib/daemon/machine-token-identity.js +123 -0
  35. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  36. package/dist/lib/doctor/checks/sync-health.js +66 -1
  37. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  38. package/dist/lib/doctor/company-resolution-state.js +89 -0
  39. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  40. package/dist/lib/doctor/fix/apply.js +128 -12
  41. package/dist/lib/doctor/types.d.ts +1 -1
  42. package/dist/lib/doctor/types.js +1 -0
  43. package/dist/lib/hook-engine/resolve.js +6 -2
  44. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  45. package/dist/lib/index-render/company-knowledge.js +3 -2
  46. package/dist/lib/index-render/index.js +44 -3
  47. package/dist/lib/index-render/projects.d.ts +1 -1
  48. package/dist/lib/index-render/projects.js +4 -2
  49. package/dist/lib/lanes/registry-flag.d.ts +23 -2
  50. package/dist/lib/lanes/registry-flag.js +256 -36
  51. package/dist/lib/personal-secrets-audit.d.ts +31 -0
  52. package/dist/lib/personal-secrets-audit.js +45 -0
  53. package/dist/sentry-before-send.js +1 -0
  54. package/dist/utils/access-denied-hint.js +6 -0
  55. package/dist/utils/client-health.d.ts +34 -0
  56. package/dist/utils/client-health.js +96 -3
  57. package/package.json +7 -7
@@ -120,6 +120,16 @@ interface GatewayMessage {
120
120
  }
121
121
  export type GatewayErrorShape = "string_error" | "object_no_code" | "object_code" | "string_code" | "nested_code" | "empty" | "redacted_empty";
122
122
  export type GatewayFailureCode = "invalid_envelope";
123
+ /** Default client wait for a background (202) integration job. */
124
+ export declare const ASYNC_GATEWAY_DEADLINE_MS: number;
125
+ /** Upper bound for `hq integrations call --timeout`, in seconds. */
126
+ export declare const MAX_CALL_TIMEOUT_SECONDS: number;
127
+ /**
128
+ * Parse `--timeout <seconds>` for `hq integrations call` into milliseconds.
129
+ * Bounds how long the CLI waits for a background job; it never shortens the
130
+ * gateway's own limits.
131
+ */
132
+ export declare function parseCallTimeoutMs(raw: string | undefined): number;
123
133
  export declare class IntegrationsCliError extends Error {
124
134
  /**
125
135
  * True when the error is the caller's request/state/permission (a client 4xx
@@ -338,7 +348,9 @@ export declare function resolveConnection(token: string, companyUid: string, app
338
348
  }, recoveryOpts?: {
339
349
  allowSingleRevoked?: boolean;
340
350
  }): Promise<AdminConnection>;
341
- export declare function callGateway(token: string, params: Record<string, unknown>, idempotencyKey?: string, method?: "tools/call" | "tools/list"): Promise<GatewayMessage>;
351
+ export declare function callGateway(token: string, params: Record<string, unknown>, idempotencyKey?: string, method?: "tools/call" | "tools/list", opts?: {
352
+ waitMs?: number;
353
+ }): Promise<GatewayMessage>;
342
354
  /**
343
355
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
344
356
  * where `text` is the provider's JSON. Unwrap to the inner payload; fall back
@@ -19,7 +19,43 @@ import { connectionHealthNotice } from "../lib/integrations/health.js";
19
19
  import { bareProvider } from "../lib/integrations/provider-slug.js";
20
20
  export { bareProvider } from "../lib/integrations/provider-slug.js";
21
21
  const ASYNC_GATEWAY_POLL_INTERVAL_MS = 1_000;
22
- const ASYNC_GATEWAY_DEADLINE_MS = 4 * 60_000 + 30_000;
22
+ /** Default client wait for a background (202) integration job. */
23
+ export const ASYNC_GATEWAY_DEADLINE_MS = 4 * 60_000 + 30_000;
24
+ /** Upper bound for `hq integrations call --timeout`, in seconds. */
25
+ export const MAX_CALL_TIMEOUT_SECONDS = 30 * 60;
26
+ /**
27
+ * Parse `--timeout <seconds>` for `hq integrations call` into milliseconds.
28
+ * Bounds how long the CLI waits for a background job; it never shortens the
29
+ * gateway's own limits.
30
+ */
31
+ export function parseCallTimeoutMs(raw) {
32
+ if (raw === undefined)
33
+ return ASYNC_GATEWAY_DEADLINE_MS;
34
+ const trimmed = raw.trim();
35
+ if (!/^\d+$/.test(trimmed)) {
36
+ throw new IntegrationsCliError(`--timeout must be a whole number of seconds (got "${raw}").`, { expected: true });
37
+ }
38
+ const seconds = Number(trimmed);
39
+ if (seconds < 1 || seconds > MAX_CALL_TIMEOUT_SECONDS) {
40
+ throw new IntegrationsCliError(`--timeout must be between 1 and ${MAX_CALL_TIMEOUT_SECONDS} seconds (got ${seconds}).`, { expected: true });
41
+ }
42
+ return seconds * 1_000;
43
+ }
44
+ /**
45
+ * Statuses the gateway returns when an inline (non-background) tools/call ran
46
+ * into the IntegrationMcpFunction 30s wall or its provider budget.
47
+ */
48
+ function isInlineTimeoutStatus(status) {
49
+ return status === 500 || status === 502 || status === 504;
50
+ }
51
+ function inlineTimeoutHint(status) {
52
+ return (`${upstreamUnavailableMessage(status)} ` +
53
+ "If this was a long-running call (for example a large analytical read), it may have hit the " +
54
+ "gateway's 30-second limit: the gateway ran it inline, not as a background job. Long calls " +
55
+ "run as background jobs only when async integration jobs are enabled for your company; ask " +
56
+ "HQ support to enable them. Once enabled, hq integrations call waits for the result " +
57
+ "automatically (see --timeout).");
58
+ }
23
59
  export class IntegrationsCliError extends Error {
24
60
  /**
25
61
  * True when the error is the caller's request/state/permission (a client 4xx
@@ -627,9 +663,9 @@ function gatewaySubcommandFor(params, method) {
627
663
  return "tools_call";
628
664
  return "other";
629
665
  }
630
- export async function callGateway(token, params, idempotencyKey, method = "tools/call") {
666
+ export async function callGateway(token, params, idempotencyKey, method = "tools/call", opts = {}) {
631
667
  const gatewaySubcommand = gatewaySubcommandFor(params, method);
632
- const deadline = Date.now() + ASYNC_GATEWAY_DEADLINE_MS;
668
+ const deadline = Date.now() + (opts.waitMs ?? ASYNC_GATEWAY_DEADLINE_MS);
633
669
  const res = await vaultApiFetch({
634
670
  token,
635
671
  path: "/v1/integrations/mcp",
@@ -663,6 +699,15 @@ export async function callGateway(token, params, idempotencyKey, method = "tools
663
699
  }
664
700
  if (res.status !== 202 && (!res.ok || !terminalMessage)) {
665
701
  raiseIfUnauthorized(res);
702
+ if (method === "tools/call" &&
703
+ params.acceptAsync === true &&
704
+ isInlineTimeoutStatus(res.status)) {
705
+ throw new IntegrationsCliError(inlineTimeoutHint(res.status), {
706
+ expected: true,
707
+ status: res.status,
708
+ gatewaySubcommand,
709
+ });
710
+ }
666
711
  raiseIfUpstreamUnavailable(res);
667
712
  // Carry the HTTP status so a reportable transport failure (e.g. a 501 that
668
713
  // is neither 401 nor an upstream-availability status, so it is captured)
@@ -773,7 +818,7 @@ async function pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, dea
773
818
  break;
774
819
  await new Promise((resolve) => setTimeout(resolve, Math.min(ASYNC_GATEWAY_POLL_INTERVAL_MS, remainingMs)));
775
820
  }
776
- throw new IntegrationsCliError(`Timed out waiting for integration job ${jobId}; the server job may still finish. To safely retry, rerun with --idempotency-key ${idempotencyKey}.`, { expected: true });
821
+ throw new IntegrationsCliError(`Timed out waiting for integration job ${jobId}; the server job may still finish. To safely retry, rerun with --idempotency-key ${idempotencyKey} (add --timeout <seconds> to wait longer).`, { expected: true });
777
822
  }
778
823
  /**
779
824
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
@@ -56,7 +56,7 @@ import chalk from "chalk";
56
56
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
57
57
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
58
58
  import { redactSensitiveUrl, redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
59
- import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
59
+ import { IntegrationsCliError, bareProvider, callGateway, parseCallTimeoutMs, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
60
60
  import { registerConnectCommands } from "./integrations-connect.js";
61
61
  import { registerImportCommands } from "./integrations-import.js";
62
62
  import { registerManageCommands } from "./integrations-manage.js";
@@ -483,8 +483,10 @@ export function registerIntegrationsCommand(program) {
483
483
  .option("--args <json>", "Tool arguments as a JSON object (default {}); use - to read the JSON from stdin")
484
484
  .option("--args-file <path>", "Read tool arguments from a JSON file (use - for stdin); avoids shell quoting of apostrophes and parentheses")
485
485
  .option("--idempotency-key <key>", "Stable key so retries execute at most once (default: generated)")
486
+ .option("--timeout <seconds>", "How long to wait for a long call that runs as a background job (default 270, max 1800)")
486
487
  .option("--json", "Machine-readable output")
487
488
  .action(async (tool, opts) => {
489
+ const waitMs = parseCallTimeoutMs(opts.timeout);
488
490
  const parsedArgs = await resolveCallArgs(opts);
489
491
  const token = await ensureCognitoIdToken();
490
492
  const companyUid = await getCompanyUid(token, opts.company);
@@ -522,7 +524,7 @@ export function registerIntegrationsCommand(program) {
522
524
  },
523
525
  };
524
526
  }
525
- const message = await callGateway(token, gatewayParams, idempotencyKey);
527
+ const message = await callGateway(token, gatewayParams, idempotencyKey, "tools/call", { waitMs });
526
528
  const payload = unwrapGatewayResult(message.result);
527
529
  const queued = queuedOutcome(payload);
528
530
  if (queued) {
@@ -23,8 +23,12 @@ export interface PutResponse {
23
23
  export interface LibraryAddDeps {
24
24
  hqRoot: string;
25
25
  now: () => Date;
26
- /** Resolve the slug to an active company membership. */
27
- resolveCompany: (slug: string) => Promise<LibraryCompany>;
26
+ /**
27
+ * Resolve `--company` (a slug or a cmp_ uid) to the company. `folderSlug` is
28
+ * the companies/<slug>/ of an explicit `--to`, for callers that cannot map a
29
+ * uid to its folder on their own (an HQ_API_KEY).
30
+ */
31
+ resolveCompany: (ref: string, folderSlug?: string) => Promise<LibraryCompany>;
28
32
  /** POST /v1/files/presign. */
29
33
  presign: (body: Record<string, unknown>) => Promise<Response>;
30
34
  /** Open a fresh byte stream of a source. Called once to hash and once to upload. */
@@ -29,10 +29,10 @@ import https from "node:https";
29
29
  import path from "node:path";
30
30
  import { Readable } from "node:stream";
31
31
  import { formatCloudAssetBlock, formatCloudPlaceholder, isCoveredByAny, parseCloudPlaceholder, resolvePullScope, } from "@indigoai-us/hq-cloud";
32
- import { PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
32
+ import { CONTENT_SHA256_METADATA_KEY, PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
33
33
  import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
34
34
  /** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
35
- const CONTENT_SHA256_META_KEY = "hq-content-sha256";
35
+ const CONTENT_SHA256_META_KEY = CONTENT_SHA256_METADATA_KEY;
36
36
  const MEDIA_TYPES = {
37
37
  ".png": "image/png",
38
38
  ".jpg": "image/jpeg",
@@ -42,6 +42,7 @@ const MEDIA_TYPES = {
42
42
  ".svg": "image/svg+xml",
43
43
  ".avif": "image/avif",
44
44
  ".heic": "image/heic",
45
+ ".heif": "image/heif",
45
46
  ".mp4": "video/mp4",
46
47
  ".mov": "video/quicktime",
47
48
  ".webm": "video/webm",
@@ -136,6 +137,41 @@ export function resolveLibraryDir(hqRoot, options) {
136
137
  }
137
138
  return { slug, dirRel, dirAbs };
138
139
  }
140
+ /**
141
+ * The company and library folder for `add`. `--company` may be a slug or a
142
+ * cmp_ uid; the folder always uses the company's slug, so a uid never becomes
143
+ * a companies/cmp_…/ folder. A slug that disagrees with `--to` is refused
144
+ * before any lookup, as it always was.
145
+ */
146
+ async function resolveLibraryTarget(options, deps) {
147
+ const companyIsUid = options.company !== undefined && isCompanyUid(options.company);
148
+ if (!companyIsUid)
149
+ resolveLibraryDir(deps.hqRoot, options);
150
+ const folderSlug = options.to === undefined ? undefined : resolveLibraryDir(deps.hqRoot, { to: options.to }).slug;
151
+ const company = await deps.resolveCompany((options.company ?? folderSlug), folderSlug);
152
+ if (isCompanyUid(company.slug)) {
153
+ throw new Error(`No company folder is known for ${company.uid}; library folders are companies/<slug>/. ` +
154
+ "Pass --company <slug> or --to companies/<slug>/<dir>.");
155
+ }
156
+ if (folderSlug !== undefined && company.slug !== folderSlug) {
157
+ throw new Error(`--to is inside companies/${folderSlug}/, but --company ${options.company} is ${company.slug}.`);
158
+ }
159
+ return { company, ...resolveLibraryDir(deps.hqRoot, { company: company.slug, to: options.to }) };
160
+ }
161
+ function isCompanyUid(ref) {
162
+ return ref.startsWith("cmp_");
163
+ }
164
+ /**
165
+ * Capabilities an API key lacks, from hq-pro's keyed-route 403
166
+ * (`{ error, granted, missing }`). Empty for any other response.
167
+ */
168
+ function missingKeyCapabilities(status, body) {
169
+ if (status !== 403)
170
+ return [];
171
+ if (typeof body.missing === "string" && body.missing)
172
+ return [body.missing];
173
+ return Array.isArray(body.missing) ? body.missing.filter((c) => typeof c === "string") : [];
174
+ }
139
175
  async function hashStream(stream) {
140
176
  const hash = crypto.createHash("sha256");
141
177
  let size = 0;
@@ -212,8 +248,7 @@ async function uploadVerified(source, digest, grant, deps) {
212
248
  export async function runLibraryAdd(inputs, options, deps) {
213
249
  if (inputs.length === 0)
214
250
  throw new Error("Pass at least one file or URL.");
215
- const { slug, dirRel, dirAbs } = resolveLibraryDir(deps.hqRoot, options);
216
- const company = await deps.resolveCompany(slug);
251
+ const { company, slug, dirRel, dirAbs } = await resolveLibraryTarget(options, deps);
217
252
  const items = [];
218
253
  const planned = [];
219
254
  const claimed = new Set();
@@ -268,9 +303,13 @@ export async function runLibraryAdd(inputs, options, deps) {
268
303
  }
269
304
  const body = (await response.json().catch(() => ({})));
270
305
  if (!response.ok) {
271
- const msg = typeof body.error === "string" ? body.error : `HTTP ${response.status}`;
306
+ const missing = missingKeyCapabilities(response.status, body);
307
+ const msg = missing.length > 0
308
+ ? `This API key cannot upload (missing ${missing.join(", ")}). Use a key made with ` +
309
+ "`hq api-keys create --preset library-agent --allow-upload`, or unset HQ_API_KEY and run signed in."
310
+ : `Could not get an upload URL: ${typeof body.error === "string" ? body.error : `HTTP ${response.status}`}`;
272
311
  for (const p of planned)
273
- p.item.error = `Could not get an upload URL: ${msg}`;
312
+ p.item.error = msg;
274
313
  return { company: slug, items };
275
314
  }
276
315
  const rows = Array.isArray(body.results) ? body.results : [];
@@ -105,8 +105,45 @@ export declare function runLibraryDescribe(target: string, deps: LibraryDescribe
105
105
  export type RunTool = (command: string, args: string[]) => Promise<{
106
106
  stdout: string;
107
107
  }>;
108
+ /**
109
+ * HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
110
+ * bytes are not a format Claude Code reads directly. AVIF (also a HEIF
111
+ * container) is not included.
112
+ */
113
+ export declare function isHeifImage(head: Buffer, name: string): boolean;
114
+ /** Width and height from a JPEG's start-of-frame marker, or null. */
115
+ export declare function jpegDimensions(bytes: Buffer): {
116
+ width: number;
117
+ height: number;
118
+ } | null;
119
+ /** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
120
+ export declare function isUsableJpegFrame(bytes: Buffer): boolean;
121
+ /** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
122
+ export declare function jpegExifOrientation(bytes: Buffer): number;
123
+ /** The JPEG without its APP1 (EXIF and XMP) segments. */
124
+ export declare function stripJpegApp1(bytes: Buffer): Buffer;
125
+ interface TileGrid {
126
+ width: number;
127
+ height: number;
128
+ left: number;
129
+ top: number;
130
+ tiles: Array<{
131
+ stream: number;
132
+ x: number;
133
+ y: number;
134
+ }>;
135
+ }
136
+ /**
137
+ * The primary tile grid from `ffprobe -show_stream_groups -of json`: the
138
+ * "Tile Grid" group marked default, else the first one. Null when the output
139
+ * does not have the expected shape (older ffmpeg has no tile grid groups).
140
+ */
141
+ export declare function primaryTileGrid(ffprobeJson: string): TileGrid | null;
142
+ /** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
143
+ export declare function tileGridFilter(grid: TileGrid): string;
108
144
  /** Seek points at 10%, 35%, 60% and 85% of a known duration. */
109
145
  export declare function videoSeekPoints(durationSeconds: number | null): number[];
110
146
  /** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
111
- export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool): Promise<Buffer[]>;
147
+ export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool, platform?: NodeJS.Platform): Promise<Buffer[]>;
148
+ export {};
112
149
  //# sourceMappingURL=library-caption.d.ts.map
@@ -5,8 +5,11 @@
5
5
  * Captioning runs on the user's machine through the local Claude Code CLI
6
6
  * (`claude -p`, via the provider-launcher seam). No cloud caption service is
7
7
  * called. Up to 4 frames are written to a private temp dir: an image is used
8
- * as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise ffmpeg
9
- * re-encodes it to a 1024px JPEG. A video is sampled at up to 4 seek points,
8
+ * as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise it is
9
+ * re-encoded to a 1024px JPEG. A HEIC/HEIF is decoded to its primary image
10
+ * with sips (macOS), ImageMagick, or ffmpeg's tile grid; any other image, and
11
+ * a HEIC none of those can decode, goes through ffmpeg's default stream. A
12
+ * video is sampled at up to 4 seek points,
10
13
  * falling back to the first frame. Claude Code reads the frames with its Read
11
14
  * tool only and replies with JSON { description, tags }.
12
15
  *
@@ -339,6 +342,297 @@ async function jpegFrame(run, file, out, seconds) {
339
342
  return null;
340
343
  }
341
344
  }
345
+ // ── HEIC / HEIF primary image ───────────────────────────────────────────────
346
+ /** ftyp brands of HEVC-coded HEIF images (HEIC) and generic HEIF image files. */
347
+ const HEIF_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs", "mif1", "msf1", "mif2"]);
348
+ /** HEVC-specific brands: these mark a file as HEIC even when it also lists AVIF brands. */
349
+ const HEVC_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs"]);
350
+ const AVIF_BRANDS = new Set(["avif", "avis"]);
351
+ const HEIF_EXTENSIONS = new Set([".heic", ".heif", ".hif"]);
352
+ /** Bytes read to sniff the ftyp box. */
353
+ const HEIF_SNIFF_BYTES = 64;
354
+ /** A decoded frame smaller than this on either edge is not a usable picture. */
355
+ const MIN_FRAME_EDGE_PX = 16;
356
+ /** The brands in a leading ISO BMFF `ftyp` box, or null when there is none. */
357
+ function ftypBrands(head) {
358
+ if (head.length < 16 || head.subarray(4, 8).toString("latin1") !== "ftyp")
359
+ return null;
360
+ const boxSize = head.readUInt32BE(0);
361
+ const end = Math.min(head.length, boxSize >= 16 ? boxSize : head.length);
362
+ const brands = [head.subarray(8, 12).toString("latin1")];
363
+ for (let at = 16; at + 4 <= end; at += 4)
364
+ brands.push(head.subarray(at, at + 4).toString("latin1"));
365
+ return brands;
366
+ }
367
+ /**
368
+ * HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
369
+ * bytes are not a format Claude Code reads directly. AVIF (also a HEIF
370
+ * container) is not included.
371
+ */
372
+ export function isHeifImage(head, name) {
373
+ const brands = ftypBrands(head);
374
+ if (brands) {
375
+ if (brands.some((brand) => HEVC_BRANDS.has(brand)))
376
+ return true;
377
+ if (brands.some((brand) => AVIF_BRANDS.has(brand)))
378
+ return false;
379
+ if (brands.some((brand) => HEIF_BRANDS.has(brand)))
380
+ return true;
381
+ }
382
+ return HEIF_EXTENSIONS.has(path.extname(name).toLowerCase()) && !isCaptionableImage(head);
383
+ }
384
+ /** Width and height from a JPEG's start-of-frame marker, or null. */
385
+ export function jpegDimensions(bytes) {
386
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
387
+ return null;
388
+ let at = 2;
389
+ while (at + 4 <= bytes.length) {
390
+ if (bytes[at] !== 0xff)
391
+ return null;
392
+ const marker = bytes[at + 1];
393
+ if (marker === 0xff) {
394
+ at += 1;
395
+ continue;
396
+ }
397
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
398
+ at += 2;
399
+ continue;
400
+ }
401
+ if (marker === 0xd9 || marker === 0xda)
402
+ return null;
403
+ const length = bytes.readUInt16BE(at + 2);
404
+ if (length < 2)
405
+ return null;
406
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
407
+ if (isSof) {
408
+ if (at + 9 > bytes.length)
409
+ return null;
410
+ return { height: bytes.readUInt16BE(at + 5), width: bytes.readUInt16BE(at + 7) };
411
+ }
412
+ at += 2 + length;
413
+ }
414
+ return null;
415
+ }
416
+ /** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
417
+ export function isUsableJpegFrame(bytes) {
418
+ if (bytes.length === 0 || bytes.length > CAPTION_MAX_FRAME_BYTES)
419
+ return false;
420
+ if (!(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff))
421
+ return false;
422
+ const size = jpegDimensions(bytes);
423
+ if (!size)
424
+ return false;
425
+ const edges = [size.width, size.height];
426
+ return edges.every((edge) => edge >= MIN_FRAME_EDGE_PX && edge <= FRAME_EDGE_PX);
427
+ }
428
+ /** Run one decoder that writes `out`, and keep its output only when it is a usable JPEG. */
429
+ async function decodedFrame(run, command, args, out) {
430
+ try {
431
+ await fs.promises.rm(out, { force: true });
432
+ await run(command, args);
433
+ const bytes = await fs.promises.readFile(out);
434
+ return isUsableJpegFrame(bytes) ? bytes : null;
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ }
440
+ /** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
441
+ export function jpegExifOrientation(bytes) {
442
+ for (const segment of jpegSegments(bytes)) {
443
+ if (segment.marker !== 0xe1)
444
+ continue;
445
+ const body = bytes.subarray(segment.start + 4, segment.end);
446
+ if (body.length < 14 || body.subarray(0, 6).toString("latin1") !== "Exif\0\0")
447
+ continue;
448
+ const tiff = body.subarray(6);
449
+ const order = tiff.subarray(0, 2).toString("latin1");
450
+ if (order !== "II" && order !== "MM")
451
+ continue;
452
+ const u16 = (at) => (order === "II" ? tiff.readUInt16LE(at) : tiff.readUInt16BE(at));
453
+ const u32 = (at) => (order === "II" ? tiff.readUInt32LE(at) : tiff.readUInt32BE(at));
454
+ const ifd = u32(4);
455
+ if (ifd + 2 > tiff.length)
456
+ continue;
457
+ const count = u16(ifd);
458
+ for (let i = 0; i < count; i++) {
459
+ const entry = ifd + 2 + i * 12;
460
+ if (entry + 12 > tiff.length)
461
+ break;
462
+ if (u16(entry) === 0x0112) {
463
+ const value = u16(entry + 8);
464
+ return value >= 1 && value <= 8 ? value : 1;
465
+ }
466
+ }
467
+ }
468
+ return 1;
469
+ }
470
+ /** Marker segments before the scan: `start` is the 0xFF byte, `end` is past the segment. */
471
+ function jpegSegments(bytes) {
472
+ const segments = [];
473
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
474
+ return segments;
475
+ let at = 2;
476
+ while (at + 4 <= bytes.length && bytes[at] === 0xff) {
477
+ const marker = bytes[at + 1];
478
+ if (marker === 0xda || marker === 0xd9)
479
+ break;
480
+ const length = bytes.readUInt16BE(at + 2);
481
+ if (length < 2 || at + 2 + length > bytes.length)
482
+ break;
483
+ segments.push({ marker, start: at, end: at + 2 + length });
484
+ at += 2 + length;
485
+ }
486
+ return segments;
487
+ }
488
+ /** The JPEG without its APP1 (EXIF and XMP) segments. */
489
+ export function stripJpegApp1(bytes) {
490
+ const drop = jpegSegments(bytes).filter((segment) => segment.marker === 0xe1);
491
+ if (drop.length === 0)
492
+ return bytes;
493
+ const parts = [];
494
+ let at = 0;
495
+ for (const segment of drop) {
496
+ parts.push(bytes.subarray(at, segment.start));
497
+ at = segment.end;
498
+ }
499
+ parts.push(bytes.subarray(at));
500
+ return Buffer.concat(parts);
501
+ }
502
+ /** sips steps that turn stored pixels upright for each EXIF orientation (rotations are clockwise). */
503
+ const SIPS_UPRIGHT = {
504
+ 2: [["-f", "horizontal"]],
505
+ 3: [["-r", "180"]],
506
+ 4: [["-f", "vertical"]],
507
+ 5: [["-r", "90"], ["-f", "horizontal"]],
508
+ 6: [["-r", "90"]],
509
+ 7: [["-r", "90"], ["-f", "vertical"]],
510
+ 8: [["-r", "270"]],
511
+ };
512
+ /**
513
+ * sips keeps the stored pixel order and copies the EXIF orientation, so a
514
+ * HEIC shot upside down or sideways decodes rotated. Rotate the pixels
515
+ * upright with sips and drop the EXIF/XMP segments so nothing rotates them
516
+ * again. When a rotation step fails, the frame is kept with its EXIF intact.
517
+ */
518
+ async function uprightSipsFrame(run, frame, out) {
519
+ const steps = SIPS_UPRIGHT[jpegExifOrientation(frame)];
520
+ if (!steps)
521
+ return stripJpegApp1(frame);
522
+ try {
523
+ for (const step of steps)
524
+ await run("sips", [...step, out]);
525
+ const rotated = await fs.promises.readFile(out);
526
+ const upright = stripJpegApp1(rotated);
527
+ return isUsableJpegFrame(upright) ? upright : frame;
528
+ }
529
+ catch {
530
+ return frame;
531
+ }
532
+ }
533
+ function nonNegativeInt(value) {
534
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
535
+ }
536
+ /**
537
+ * The primary tile grid from `ffprobe -show_stream_groups -of json`: the
538
+ * "Tile Grid" group marked default, else the first one. Null when the output
539
+ * does not have the expected shape (older ffmpeg has no tile grid groups).
540
+ */
541
+ export function primaryTileGrid(ffprobeJson) {
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(ffprobeJson);
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ const groups = parsed.stream_groups;
550
+ if (!Array.isArray(groups))
551
+ return null;
552
+ const grids = groups.filter((g) => !!g && g.type === "Tile Grid");
553
+ const group = grids.find((g) => g.disposition?.default === 1) ?? grids[0];
554
+ const component = Array.isArray(group?.components) ? group.components[0] : undefined;
555
+ if (!component || !Array.isArray(component.subcomponents))
556
+ return null;
557
+ const width = nonNegativeInt(component.width);
558
+ const height = nonNegativeInt(component.height);
559
+ const left = nonNegativeInt(component.horizontal_offset) ?? 0;
560
+ const top = nonNegativeInt(component.vertical_offset) ?? 0;
561
+ if (!width || !height)
562
+ return null;
563
+ const tiles = [];
564
+ for (const sub of component.subcomponents) {
565
+ const stream = nonNegativeInt(sub?.stream_index);
566
+ const x = nonNegativeInt(sub?.tile_horizontal_offset);
567
+ const y = nonNegativeInt(sub?.tile_vertical_offset);
568
+ if (stream === null || x === null || y === null)
569
+ return null;
570
+ tiles.push({ stream, x, y });
571
+ }
572
+ return tiles.length > 0 ? { width, height, left, top, tiles } : null;
573
+ }
574
+ /** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
575
+ export function tileGridFilter(grid) {
576
+ const inputs = grid.tiles.map((tile) => `[0:${tile.stream}]`).join("");
577
+ const crop = `crop=${grid.width}:${grid.height}:${grid.left}:${grid.top}`;
578
+ if (grid.tiles.length === 1)
579
+ return `${inputs}${crop},${SCALE_FILTER}[frame]`;
580
+ const layout = grid.tiles.map((tile) => `${tile.x}_${tile.y}`).join("|");
581
+ return `${inputs}xstack=inputs=${grid.tiles.length}:layout=${layout}:fill=black,${crop},${SCALE_FILTER}[frame]`;
582
+ }
583
+ /**
584
+ * The primary image of a HEIC / HEIF as a 1024px JPEG. ffmpeg's default video
585
+ * stream in an iPhone HEIC is a single tile or, for Portrait photos, the
586
+ * depth/segmentation matte, so the primary image is decoded with sips
587
+ * (macOS), then ImageMagick (libheif), then by assembling ffmpeg's primary
588
+ * tile grid. Null when none of them produced a usable frame.
589
+ */
590
+ async function heifPrimaryFrame(run, file, dir, platform) {
591
+ const out = path.join(dir, "primary.jpg");
592
+ const edge = String(FRAME_EDGE_PX);
593
+ if (platform === "darwin") {
594
+ const frame = await decodedFrame(run, "sips", ["-s", "format", "jpeg", "-s", "formatOptions", "80", "-Z", edge, file, "--out", out], out);
595
+ if (frame)
596
+ return uprightSipsFrame(run, frame, out);
597
+ }
598
+ // The explicit heic: coder and [0] make ImageMagick decode only the primary image as HEIF.
599
+ const imArgs = [`heic:${file}[0]`, "-auto-orient", "-resize", `${edge}x${edge}>`, "-quality", "80", out];
600
+ const magick = await decodedFrame(run, "magick", imArgs, out);
601
+ if (magick)
602
+ return magick;
603
+ // On Windows `convert` is the system disk-conversion tool, not ImageMagick.
604
+ if (platform !== "win32") {
605
+ const convert = await decodedFrame(run, "convert", imArgs, out);
606
+ if (convert)
607
+ return convert;
608
+ }
609
+ try {
610
+ const { stdout } = await run("ffprobe", ["-v", "error", "-show_stream_groups", "-of", "json", file]);
611
+ const grid = primaryTileGrid(stdout);
612
+ if (grid) {
613
+ const args = ["-v", "error", "-y", "-i", file, "-filter_complex", tileGridFilter(grid), "-map", "[frame]",
614
+ "-frames:v", "1", "-q:v", "4", "-update", "1", out];
615
+ const frame = await decodedFrame(run, "ffmpeg", args, out);
616
+ if (frame)
617
+ return frame;
618
+ }
619
+ }
620
+ catch {
621
+ // No ffprobe, or one without stream groups: fall back to ffmpeg's default stream.
622
+ }
623
+ return null;
624
+ }
625
+ async function readHead(file) {
626
+ const handle = await fs.promises.open(file, "r");
627
+ try {
628
+ const head = Buffer.alloc(HEIF_SNIFF_BYTES);
629
+ const { bytesRead } = await handle.read(head, 0, HEIF_SNIFF_BYTES, 0);
630
+ return head.subarray(0, bytesRead);
631
+ }
632
+ finally {
633
+ await handle.close();
634
+ }
635
+ }
342
636
  /** Seek points at 10%, 35%, 60% and 85% of a known duration. */
343
637
  export function videoSeekPoints(durationSeconds) {
344
638
  if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0)
@@ -346,7 +640,8 @@ export function videoSeekPoints(durationSeconds) {
346
640
  return [0.1, 0.35, 0.6, 0.85].map((share) => durationSeconds * share);
347
641
  }
348
642
  /** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
349
- export async function extractCaptionFrames(file, mediaKind, run = runTool) {
643
+ export async function extractCaptionFrames(file, mediaKind, run = runTool, platform = process.platform) {
644
+ let heif = false;
350
645
  if (mediaKind === "image") {
351
646
  const stat = await fs.promises.stat(file);
352
647
  if (stat.size <= CAPTION_MAX_FRAME_BYTES) {
@@ -354,9 +649,15 @@ export async function extractCaptionFrames(file, mediaKind, run = runTool) {
354
649
  if (isCaptionableImage(bytes))
355
650
  return [bytes];
356
651
  }
652
+ heif = isHeifImage(await readHead(file), file);
357
653
  }
358
654
  const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-frames-"));
359
655
  try {
656
+ if (heif) {
657
+ const primary = await heifPrimaryFrame(run, file, dir, platform);
658
+ if (primary)
659
+ return [primary];
660
+ }
360
661
  if (mediaKind === "image") {
361
662
  const frame = await jpegFrame(run, file, path.join(dir, "image.jpg"));
362
663
  return frame ? [frame] : [];
@@ -10,10 +10,14 @@ export interface LibraryLinkOptions {
10
10
  export interface LibraryLinkDeps {
11
11
  /** HQ root; only called for a placeholder target. */
12
12
  hqRoot: () => string;
13
- /** True when the call runs under HQ_API_KEY (slugs cannot be resolved). */
13
+ /** True when the call runs under HQ_API_KEY (no membership lookup). */
14
14
  apiKey: boolean;
15
- /** Company ref (slug or uid, or undefined for the default) → cmp_ uid. */
16
- resolveCompany: (ref: string | undefined) => Promise<string>;
15
+ /**
16
+ * Company ref (slug or uid, or undefined for the default) → cmp_ uid. Under
17
+ * a key, `placeholderSlug` is the placeholder's companies/<slug>/, a
18
+ * fallback when neither --company nor HQ_COMPANY_UID names the company.
19
+ */
20
+ resolveCompany: (ref: string | undefined, placeholderSlug?: string) => Promise<string>;
17
21
  /** POST /v1/files/presign. */
18
22
  presign: (body: Record<string, unknown>) => Promise<Response>;
19
23
  }