@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.
- package/CHANGELOG.md +138 -0
- package/dist/command-catalog.generated.d.ts +59 -7
- package/dist/command-catalog.generated.js +75 -7
- package/dist/commands/agent-inbox.js +2 -2
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +92 -3
- package/dist/commands/core.js +1 -1
- package/dist/commands/files-browse.d.ts +16 -0
- package/dist/commands/files-browse.js +52 -1
- package/dist/commands/integrations-core.d.ts +13 -1
- package/dist/commands/integrations-core.js +49 -4
- package/dist/commands/integrations.js +4 -2
- package/dist/commands/library-add.d.ts +6 -2
- package/dist/commands/library-add.js +45 -6
- package/dist/commands/library-caption.d.ts +38 -1
- package/dist/commands/library-caption.js +304 -3
- package/dist/commands/library-link.d.ts +7 -3
- package/dist/commands/library-link.js +10 -4
- package/dist/commands/library-remove.d.ts +122 -0
- package/dist/commands/library-remove.js +681 -0
- package/dist/commands/library.d.ts +26 -1
- package/dist/commands/library.js +101 -14
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +144 -8
- package/dist/commands/storage-offload.d.ts +22 -4
- package/dist/commands/storage-offload.js +196 -2
- package/dist/commands/storage.js +5 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
- package/dist/lib/agent-kit/inbox-state.js +5 -1
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +53 -8
- package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
- package/dist/lib/daemon/machine-token-identity.js +123 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
- package/dist/lib/doctor/checks/sync-health.js +66 -1
- package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
- package/dist/lib/doctor/company-resolution-state.js +89 -0
- package/dist/lib/doctor/fix/apply.d.ts +36 -6
- package/dist/lib/doctor/fix/apply.js +128 -12
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/lib/hook-engine/resolve.js +6 -2
- package/dist/lib/index-render/company-knowledge.d.ts +1 -1
- package/dist/lib/index-render/company-knowledge.js +3 -2
- package/dist/lib/index-render/index.js +44 -3
- package/dist/lib/index-render/projects.d.ts +1 -1
- package/dist/lib/index-render/projects.js +4 -2
- package/dist/lib/lanes/registry-flag.d.ts +23 -2
- package/dist/lib/lanes/registry-flag.js +256 -36
- package/dist/lib/personal-secrets-audit.d.ts +31 -0
- package/dist/lib/personal-secrets-audit.js +45 -0
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/access-denied-hint.js +6 -0
- package/dist/utils/client-health.d.ts +34 -0
- package/dist/utils/client-health.js +96 -3
- 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"
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
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 =
|
|
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 } =
|
|
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
|
|
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 =
|
|
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
|
|
9
|
-
* re-
|
|
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 (
|
|
13
|
+
/** True when the call runs under HQ_API_KEY (no membership lookup). */
|
|
14
14
|
apiKey: boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
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
|
}
|