autokap 1.4.3 → 1.5.2

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.
@@ -13,7 +13,8 @@
13
13
  import fs from 'node:fs/promises';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
- import { randomUUID } from 'node:crypto';
16
+ import { createHash, randomUUID } from 'node:crypto';
17
+ import sharp from 'sharp';
17
18
  import { Browser } from './browser.js';
18
19
  import { API_BASE_URL_ENV_VAR, requireConfig } from './cli-config.js';
19
20
  import { WebPlaywrightLocal } from './web-playwright-local.js';
@@ -22,6 +23,9 @@ import { RecoveryChainImpl } from './recovery-chain.js';
22
23
  import { parseProgram } from './execution-schema.js';
23
24
  import { buildCursorOverlayScript } from './cursor-overlay-script.js';
24
25
  import { CLI_VERSION_HEADER, } from './cli-contract.js';
26
+ import { postProcessClipRecording } from './clip-postprocess.js';
27
+ import { applyDeviceFrame } from './mockup.js';
28
+ import { localizeStatusBar } from './status-bar-l10n.js';
25
29
  import { logger } from './logger.js';
26
30
  import { callLLM } from './llm-provider.js';
27
31
  import { APP_VERSION } from './version.js';
@@ -175,6 +179,12 @@ export async function runCapture(options) {
175
179
  }
176
180
  logger.info(`[capture] Running preset "${options.presetId}" — ${program.steps.length} opcodes, ${program.variants.length} variant(s)`);
177
181
  logger.info(`[capture] Resolved API origin ${resolvedProgram.security.expectedApiOrigin}; navigation scope: ${resolvedProgram.security.allowedNavigationOrigins.join(', ')}`);
182
+ if (program.mediaMode === 'clip' || program.mediaMode === 'video') {
183
+ const label = program.mediaMode === 'video' ? 'Demo video' : 'Clip';
184
+ logger.warn(`[capture] ${label} fluidity depends on this machine's CPU. For smoother output, ` +
185
+ `re-run this preset via Cloud Recapture (dashboard → Recapture, or ` +
186
+ `\`autokap auto-recapture --project <id> --cloud\`).`);
187
+ }
178
188
  const llmConfig = resolveCliLLMConfig(resolvedProgram.security);
179
189
  // Step 3: Set up recovery chain
180
190
  const recoveryChain = new RecoveryChainImpl({
@@ -738,6 +748,9 @@ export function buildVideoClipMetadata(videoId, result, program, runId) {
738
748
  timecodeStartMs: t.timecodeStartMs,
739
749
  timecodeEndMs: t.timecodeEndMs,
740
750
  bbox: t.bbox ?? null,
751
+ ...(t.keystrokeOffsetsMs && t.keystrokeOffsetsMs.length > 0
752
+ ? { keystrokeOffsetsMs: t.keystrokeOffsetsMs }
753
+ : {}),
741
754
  }));
742
755
  clipsByKey.set(`${variantId}:${artifact.clipId}`, {
743
756
  variantId,
@@ -763,10 +776,82 @@ function inferVariantTheme(variantId) {
763
776
  }
764
777
  async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumber, job) {
765
778
  const { artifact, variant, variantSpec } = job;
766
- const formData = new FormData();
767
779
  const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
768
780
  const label = artifact.captureName ?? artifact.clipName ?? filename;
769
781
  logger.info(`[capture] Exporting capture ${uploadNumber}/${totalArtifacts}: ${label}`);
782
+ if (process.env.AUTOKAP_USE_LEGACY_MULTIPART_UPLOADS === '1') {
783
+ await uploadArtifactMultipart(config, program, runId, job, filename);
784
+ return;
785
+ }
786
+ const prepared = await prepareDirectArtifactUpload({
787
+ program,
788
+ runId,
789
+ artifact,
790
+ variant,
791
+ variantSpec,
792
+ });
793
+ const uploadsUrl = `${config.apiBaseUrl}/api/cli/artifacts/uploads`;
794
+ const initResponse = await fetch(uploadsUrl, {
795
+ method: 'POST',
796
+ headers: {
797
+ 'Authorization': `Bearer ${config.apiKey}`,
798
+ 'Content-Type': 'application/json',
799
+ [CLI_VERSION_HEADER]: APP_VERSION,
800
+ },
801
+ body: JSON.stringify({ metadata: prepared.metadata }),
802
+ });
803
+ if (!initResponse.ok) {
804
+ throw new Error(`artifact upload init failed for ${variant.variantId}: ${await formatServerError(initResponse, uploadsUrl)}`);
805
+ }
806
+ const initPayload = (await initResponse.json());
807
+ const uploadTargets = new Map(initPayload.uploads.map((target) => [target.id, target]));
808
+ const uploadedObjects = [];
809
+ for (const part of prepared.parts) {
810
+ const target = uploadTargets.get(part.id);
811
+ if (!target) {
812
+ throw new Error(`artifact upload init did not return a target for ${part.id}`);
813
+ }
814
+ const uploadResponse = await fetch(target.signedUrl, {
815
+ method: target.method,
816
+ headers: {
817
+ 'content-type': part.contentType,
818
+ 'cache-control': 'max-age=3600',
819
+ },
820
+ body: new Uint8Array(part.buffer),
821
+ });
822
+ if (!uploadResponse.ok) {
823
+ throw new Error(`artifact storage upload failed for ${part.id}: ${await formatServerError(uploadResponse, target.signedUrl)}`);
824
+ }
825
+ uploadedObjects.push({
826
+ id: part.id,
827
+ bucket: target.bucket,
828
+ path: target.path,
829
+ contentType: target.contentType,
830
+ sizeBytes: part.buffer.length,
831
+ sha256: sha256Hex(part.buffer),
832
+ });
833
+ }
834
+ const completeUrl = `${config.apiBaseUrl}/api/cli/artifacts/complete`;
835
+ const completeResponse = await fetch(completeUrl, {
836
+ method: 'POST',
837
+ headers: {
838
+ 'Authorization': `Bearer ${config.apiKey}`,
839
+ 'Content-Type': 'application/json',
840
+ [CLI_VERSION_HEADER]: APP_VERSION,
841
+ },
842
+ body: JSON.stringify({
843
+ uploadId: initPayload.uploadId,
844
+ metadata: prepared.metadata,
845
+ objects: uploadedObjects,
846
+ }),
847
+ });
848
+ if (!completeResponse.ok) {
849
+ throw new Error(`artifact completion failed for ${variant.variantId}: ${await formatServerError(completeResponse, completeUrl)}`);
850
+ }
851
+ }
852
+ async function uploadArtifactMultipart(config, program, runId, job, filename) {
853
+ const { artifact, variant, variantSpec } = job;
854
+ const formData = new FormData();
770
855
  formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
771
856
  formData.append('presetId', program.presetId);
772
857
  formData.append('programVersion', String(program.programVersion));
@@ -836,6 +921,237 @@ async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumb
836
921
  throw new Error(`artifact upload failed for ${variant.variantId}: ${await formatServerError(response, `${config.apiBaseUrl}/api/cli/artifacts`)}`);
837
922
  }
838
923
  }
924
+ async function prepareDirectArtifactUpload(params) {
925
+ const { program, runId, artifact, variant, variantSpec } = params;
926
+ const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
927
+ const isFrameCapture = artifact.mediaMode === 'clip' || artifact.mediaMode === 'video';
928
+ const deviceScaleFactor = isFrameCapture && Number.isFinite(requestedDeviceScaleFactor)
929
+ ? Math.min(Number(requestedDeviceScaleFactor), MAX_CLIP_CAPTURE_DEVICE_SCALE_FACTOR)
930
+ : requestedDeviceScaleFactor;
931
+ const tabIconSha256 = artifact.tabIconData ? sha256Hex(artifact.tabIconData) : null;
932
+ const metadata = {
933
+ presetId: program.presetId,
934
+ programVersion: program.programVersion,
935
+ compileFingerprint: program.compileFingerprint,
936
+ runId,
937
+ variantId: variant.variantId,
938
+ targetId: variantSpec?.targetId ?? variant.variantId,
939
+ targetLabel: variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId,
940
+ mediaMode: artifact.mediaMode,
941
+ mimeType: artifact.mimeType,
942
+ captureType: artifact.captureType ?? 'fullpage',
943
+ captureUrl: artifact.captureUrl ?? program.baseUrl,
944
+ lang: variantSpec?.locale ?? 'en',
945
+ theme: variantSpec?.theme ?? 'light',
946
+ deviceFrame: variantSpec?.deviceFrame ?? null,
947
+ deviceScaleFactor: Number.isFinite(deviceScaleFactor) ? Number(deviceScaleFactor) : null,
948
+ viewport: variantSpec?.viewport ?? null,
949
+ altText: artifact.altText ?? null,
950
+ elementSelector: artifact.elementSelector ?? null,
951
+ captureId: artifact.captureId ?? null,
952
+ captureName: artifact.captureName ?? null,
953
+ clipId: artifact.clipId ?? null,
954
+ clipName: artifact.clipName ?? null,
955
+ stepDescription: artifact.stepDescription ?? null,
956
+ stepIndex: typeof artifact.stepIndex === 'number' ? artifact.stepIndex : null,
957
+ durationMs: typeof artifact.durationMs === 'number' ? artifact.durationMs : null,
958
+ trimStartMs: typeof artifact.trimStartMs === 'number' ? artifact.trimStartMs : null,
959
+ artifactPlan: program.artifactPlan,
960
+ tabIconMimeType: artifact.tabIconData ? (artifact.tabIconMimeType ?? 'image/png') : null,
961
+ tabIconSha256,
962
+ };
963
+ const parts = await prepareDirectUploadParts({
964
+ metadata,
965
+ artifact,
966
+ program,
967
+ variantSpec,
968
+ });
969
+ return { metadata, parts };
970
+ }
971
+ async function prepareDirectUploadParts(params) {
972
+ const { metadata, artifact, program, variantSpec } = params;
973
+ if (artifact.mediaMode === 'screenshot') {
974
+ const finalScreenshot = await prepareScreenshotBufferForDirectUpload(artifact.buffer, metadata, program, variantSpec, artifact.tabIconData
975
+ ? {
976
+ buffer: artifact.tabIconData,
977
+ mimeType: artifact.tabIconMimeType ?? 'image/png',
978
+ }
979
+ : null);
980
+ const parts = [
981
+ { id: 'screenshotRaw', buffer: artifact.buffer, contentType: 'image/png' },
982
+ { id: 'screenshot', buffer: finalScreenshot.buffer, contentType: finalScreenshot.contentType },
983
+ ];
984
+ if (artifact.tabIconData) {
985
+ parts.push({
986
+ id: 'tabIcon',
987
+ buffer: artifact.tabIconData,
988
+ contentType: artifact.tabIconMimeType ?? 'image/png',
989
+ });
990
+ }
991
+ return parts;
992
+ }
993
+ if (artifact.mediaMode === 'clip') {
994
+ return prepareClipBuffersForDirectUpload(artifact, metadata);
995
+ }
996
+ return [
997
+ {
998
+ id: 'videoMp4',
999
+ buffer: artifact.buffer,
1000
+ contentType: 'video/mp4',
1001
+ },
1002
+ ];
1003
+ }
1004
+ async function prepareScreenshotBufferForDirectUpload(input, metadata, program, variantSpec, tabIcon) {
1005
+ let output = input;
1006
+ const artifactPlan = resolveEffectiveCliArtifactPlan(program.artifactPlan, variantSpec?.deviceFrame ?? null);
1007
+ if (artifactPlan?.applyStatusBar && !artifactPlan?.applyMockup) {
1008
+ throw new Error('applyStatusBar requires applyMockup with a device frame');
1009
+ }
1010
+ if (artifactPlan?.applyMockup) {
1011
+ if (!variantSpec?.deviceFrame) {
1012
+ throw new Error('applyMockup requires a deviceFrame on the variant');
1013
+ }
1014
+ output = await applyDeviceFrame(output, variantSpec.deviceFrame, {
1015
+ orientation: inferCliOrientation(metadata.viewport ?? null),
1016
+ colorScheme: metadata.theme,
1017
+ outputScale: normalizeCliDeviceScaleFactor(metadata.deviceScaleFactor)
1018
+ ?? normalizeCliDeviceScaleFactor(program.outputScale)
1019
+ ?? 2,
1020
+ showStatusBar: artifactPlan.applyStatusBar ?? false,
1021
+ statusBar: localizeStatusBar({}, metadata.lang),
1022
+ browserBar: buildCliBrowserBar(metadata.captureUrl, metadata.theme, tabIcon),
1023
+ });
1024
+ }
1025
+ if (artifactPlan?.format?.screenshotFormat === 'jpeg') {
1026
+ return {
1027
+ buffer: await sharp(output).jpeg({ quality: 90 }).toBuffer(),
1028
+ contentType: 'image/jpeg',
1029
+ };
1030
+ }
1031
+ return { buffer: output, contentType: 'image/png' };
1032
+ }
1033
+ async function prepareClipBuffersForDirectUpload(artifact, metadata) {
1034
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'autokap-cli-direct-clip-'));
1035
+ try {
1036
+ const sourceExtension = mimeToExtension(artifact.mimeType);
1037
+ const sourcePath = path.join(tempDir, `source.${sourceExtension}`);
1038
+ await fs.writeFile(sourcePath, artifact.buffer);
1039
+ const clipId = sanitizeArtifactToken(metadata.clipId ?? `${metadata.variantId}-${metadata.stepIndex ?? 0}`);
1040
+ const postResult = await postProcessClipRecording(sourcePath, tempDir, `${clipId}-${sanitizeArtifactToken(metadata.variantId)}`, {
1041
+ format: metadata.artifactPlan?.format && typeof metadata.artifactPlan.format === 'object'
1042
+ ? metadata.artifactPlan.format.clipFormat
1043
+ : undefined,
1044
+ maxDurationSec: typeof metadata.artifactPlan?.maxClipDurationSec === 'number'
1045
+ ? metadata.artifactPlan.maxClipDurationSec
1046
+ : undefined,
1047
+ gifMaxWidth: metadata.viewport?.width
1048
+ ? Math.min(1440, Math.round(metadata.viewport.width))
1049
+ : undefined,
1050
+ trimStartSec: Math.max(0, (metadata.trimStartMs ?? 0) / 1000),
1051
+ mp4Width: resolveClipMp4Size(metadata.viewport ?? null, metadata.deviceScaleFactor ?? null)?.width,
1052
+ mp4Height: resolveClipMp4Size(metadata.viewport ?? null, metadata.deviceScaleFactor ?? null)?.height,
1053
+ skipMp4Encode: artifact.mimeType === 'video/mp4',
1054
+ });
1055
+ metadata.durationMs = postResult.durationMs;
1056
+ const parts = [];
1057
+ if (postResult.gifPath) {
1058
+ parts.push({
1059
+ id: 'clipGif',
1060
+ buffer: await fs.readFile(postResult.gifPath),
1061
+ contentType: 'image/gif',
1062
+ });
1063
+ }
1064
+ if (postResult.mp4Path) {
1065
+ parts.push({
1066
+ id: 'clipMp4',
1067
+ buffer: await fs.readFile(postResult.mp4Path),
1068
+ contentType: 'video/mp4',
1069
+ });
1070
+ }
1071
+ if (postResult.thumbnailPath) {
1072
+ parts.push({
1073
+ id: 'clipThumbnail',
1074
+ buffer: await fs.readFile(postResult.thumbnailPath),
1075
+ contentType: 'image/png',
1076
+ });
1077
+ }
1078
+ return parts;
1079
+ }
1080
+ finally {
1081
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
1082
+ }
1083
+ }
1084
+ function resolveEffectiveCliArtifactPlan(artifactPlan, deviceFrame) {
1085
+ if (deviceFrame) {
1086
+ if (artifactPlan.applyMockup === false)
1087
+ return artifactPlan;
1088
+ return artifactPlan.applyMockup ? artifactPlan : { ...artifactPlan, applyMockup: true };
1089
+ }
1090
+ if (!artifactPlan.applyMockup && !artifactPlan.applyStatusBar)
1091
+ return artifactPlan;
1092
+ const { applyMockup: _applyMockup, applyStatusBar: _applyStatusBar, ...rest } = artifactPlan;
1093
+ return rest;
1094
+ }
1095
+ function inferCliOrientation(viewport) {
1096
+ if (!viewport)
1097
+ return undefined;
1098
+ return viewport.width >= viewport.height ? 'landscape' : 'portrait';
1099
+ }
1100
+ function normalizeCliDeviceScaleFactor(value) {
1101
+ if (!Number.isFinite(value))
1102
+ return null;
1103
+ return Math.max(0.5, Math.min(4, Number(value)));
1104
+ }
1105
+ function buildCliBrowserBar(captureUrl, colorScheme, tabIcon) {
1106
+ if (!captureUrl)
1107
+ return undefined;
1108
+ try {
1109
+ const parsed = new URL(captureUrl);
1110
+ return {
1111
+ url: captureUrl,
1112
+ pageTitle: parsed.hostname,
1113
+ colorScheme,
1114
+ tabIconUrl: tabIcon
1115
+ ? `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}`
1116
+ : `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=64`,
1117
+ };
1118
+ }
1119
+ catch {
1120
+ return {
1121
+ url: captureUrl,
1122
+ pageTitle: captureUrl,
1123
+ colorScheme,
1124
+ ...(tabIcon
1125
+ ? { tabIconUrl: `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}` }
1126
+ : {}),
1127
+ };
1128
+ }
1129
+ }
1130
+ function resolveClipMp4Size(viewport, deviceScaleFactor) {
1131
+ if (!viewport)
1132
+ return null;
1133
+ const scale = normalizeCliDeviceScaleFactor(deviceScaleFactor) ?? 1;
1134
+ return {
1135
+ width: Math.max(2, Math.round(viewport.width * scale)) & ~1,
1136
+ height: Math.max(2, Math.round(viewport.height * scale)) & ~1,
1137
+ };
1138
+ }
1139
+ function sha256Hex(buffer) {
1140
+ return createHash('sha256').update(buffer).digest('hex');
1141
+ }
1142
+ function mimeToExtension(mimeType) {
1143
+ if (mimeType.includes('jpeg'))
1144
+ return 'jpg';
1145
+ if (mimeType.includes('png'))
1146
+ return 'png';
1147
+ if (mimeType.includes('gif'))
1148
+ return 'gif';
1149
+ if (mimeType.includes('mp4'))
1150
+ return 'mp4';
1151
+ if (mimeType.includes('webm'))
1152
+ return 'webm';
1153
+ return 'bin';
1154
+ }
839
1155
  async function runWithConcurrency(items, concurrency, worker) {
840
1156
  if (items.length === 0) {
841
1157
  return;
package/dist/cli.js CHANGED
@@ -6,8 +6,9 @@ import fs from 'node:fs/promises';
6
6
  const require = createRequire(import.meta.url);
7
7
  const { version } = require('../package.json');
8
8
  import { logger } from './logger.js';
9
- import { writeConfig, readConfig, deleteConfig, requireConfig, getConfigPath, DEFAULT_API_BASE_URL, getDefaultApiBaseUrl, getDefaultWsUrl, LOCAL_API_BASE_URL, LOCAL_WS_URL, API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR, WS_URL_ENV_VAR, } from './cli-config.js';
9
+ import { writeConfig, deleteConfig, requireConfig, getConfigPath, DEFAULT_API_BASE_URL, getDefaultApiBaseUrl, getDefaultWsUrl, LOCAL_API_BASE_URL, LOCAL_WS_URL, API_KEY_ENV_VAR, API_BASE_URL_ENV_VAR, WS_URL_ENV_VAR, } from './cli-config.js';
10
10
  import { renderSkillSingleFile, writeSkillExport } from './skill-packaging.js';
11
+ import { displayNewVersionNoticeIfAvailable } from './version-check.js';
11
12
  // ── Program definition ──────────────────────────────────────────────
12
13
  export const program = new Command();
13
14
  program
@@ -549,71 +550,6 @@ program
549
550
  });
550
551
  process.exit(0);
551
552
  });
552
- // ── crm-run command ────────────────────────────────────────────────
553
- program
554
- .command('crm-run')
555
- .description('Scrape BetaList launches and feed the CRM (AUT-109 Phase B)')
556
- .option('--runId <id>', 'CRM run id (defaults to AUTOKAP_RUN_ID env)')
557
- .option('--lookback-days <n>', 'How far back to look for launches', '1')
558
- .option('--debug', 'Verbose logging', false)
559
- .action(async (opts) => {
560
- // Log immediately on boot — before any other call — so Cloud Run logs
561
- // show evidence the new command is reachable. If the next line silently
562
- // crashes (missing dep, bad import) we at least see the boot.
563
- logger.info(`[crm-run] booted — autokap CLI ${version}`);
564
- if (opts.debug) {
565
- const { setDebugEnabled } = await import('./logger.js');
566
- setDebugEnabled(true);
567
- logger.info('[crm-run] Debug mode enabled — verbose logging on');
568
- }
569
- // Self-kill after 20 min so a hung scraper exits before the backend
570
- // reconcile cron (25 min) gets to it. Ensures the Cloud Run instance
571
- // doesn't leak and the run row gets force-failed quickly.
572
- const SELF_TIMEOUT_MS = 20 * 60 * 1000;
573
- const selfTimeout = setTimeout(() => {
574
- logger.error(`[crm-run] Self-kill: scraper exceeded ${SELF_TIMEOUT_MS / 60000}min budget — exiting non-zero`);
575
- process.exit(1);
576
- }, SELF_TIMEOUT_MS);
577
- selfTimeout.unref?.();
578
- const runToken = process.env.AUTOKAP_RUN_TOKEN?.trim();
579
- const runId = opts.runId?.trim() || process.env.AUTOKAP_RUN_ID?.trim();
580
- const config = await readConfig();
581
- const apiBaseUrl = process.env.AUTOKAP_API_BASE_URL?.trim().replace(/\/+$/, '') || config?.apiBaseUrl;
582
- logger.info(`[crm-run] env check — runId=${runId ?? '<missing>'} ` +
583
- `apiBaseUrl=${apiBaseUrl ?? '<missing>'} ` +
584
- `runToken=${runToken ? `${runToken.slice(0, 12)}…` : '<missing>'}`);
585
- if (!runToken) {
586
- fatal('[crm-run] Missing AUTOKAP_RUN_TOKEN');
587
- }
588
- if (!runId) {
589
- fatal('[crm-run] Missing CRM run id. Set AUTOKAP_RUN_ID or pass --runId <id>.');
590
- }
591
- if (!apiBaseUrl) {
592
- fatal('[crm-run] Missing API base URL. Set AUTOKAP_API_BASE_URL or run autokap init.');
593
- }
594
- const parsedLookback = Number.parseInt(opts.lookbackDays, 10);
595
- const lookbackDays = Math.max(1, Math.min(7, Number.isFinite(parsedLookback) ? parsedLookback : 1));
596
- logger.info(`[crm-run] starting campaign — lookbackDays=${lookbackDays}`);
597
- try {
598
- const { runCampaign } = await import('./crm/run-campaign.js');
599
- const result = await runCampaign({
600
- runId,
601
- lookbackDays,
602
- apiBaseUrl,
603
- runToken,
604
- logger,
605
- });
606
- clearTimeout(selfTimeout);
607
- logger.success(`[crm-run] Done — scraped=${result.scraped} inserted=${result.inserted} ` +
608
- `disqualified=${result.disqualified} skipped=${result.skipped}`);
609
- process.exit(0);
610
- }
611
- catch (error) {
612
- clearTimeout(selfTimeout);
613
- logger.error(`[crm-run] Failed: ${error.message}`);
614
- process.exit(1);
615
- }
616
- });
617
553
  // ── project commands ───────────────────────────────────────────────
618
554
  const projectCmd = program
619
555
  .command('project')
@@ -1528,6 +1464,125 @@ proxyCmd
1528
1464
  }
1529
1465
  process.exit(0);
1530
1466
  });
1467
+ // ── branding command ───────────────────────────────────────────────
1468
+ const brandingCmd = program
1469
+ .command('branding')
1470
+ .description('Manage project branding and design system');
1471
+ brandingCmd
1472
+ .command('import <file-path>')
1473
+ .description('Import a design.md file as the project design system')
1474
+ .requiredOption('--project <id>', 'Project ID')
1475
+ .option('--dry', 'Validate and extract tokens without persisting', false)
1476
+ .option('--json', 'JSON output for IDE assistants', false)
1477
+ .option('--force-light-only', 'Skip dark-mode AI derivation', false)
1478
+ .action(async (filePath, opts) => {
1479
+ const config = await requireConfig();
1480
+ const resolved = path.resolve(process.cwd(), filePath);
1481
+ let content;
1482
+ try {
1483
+ content = await fs.readFile(resolved, 'utf8');
1484
+ }
1485
+ catch (e) {
1486
+ fatal(`Cannot read file at ${resolved}: ${e.message}`);
1487
+ }
1488
+ if (content.length > 200_000) {
1489
+ fatal(`File too large (${content.length} bytes, max 200000).`);
1490
+ }
1491
+ if (content.trim().length === 0) {
1492
+ fatal(`File at ${resolved} is empty.`);
1493
+ }
1494
+ const result = await requestJson(config, `/api/cli/projects/${opts.project}/design-system/import`, {
1495
+ method: 'POST',
1496
+ headers: authHeaders(config, { 'Content-Type': 'application/json' }),
1497
+ body: JSON.stringify({
1498
+ source_markdown: content,
1499
+ source_path: filePath,
1500
+ options: {
1501
+ dry_run: opts.dry,
1502
+ force_light_only: opts.forceLightOnly,
1503
+ },
1504
+ }),
1505
+ }, 'Failed to import design.md');
1506
+ if (opts.json) {
1507
+ printJson(result);
1508
+ process.exit(0);
1509
+ }
1510
+ const ds = result.design_system;
1511
+ if (ds?.meta?.name) {
1512
+ logger.info(`Design system "${ds.meta.name}" imported for project ${opts.project}.`);
1513
+ }
1514
+ else {
1515
+ logger.info(`Design system imported for project ${opts.project}.`);
1516
+ }
1517
+ const lightColors = ds?.light?.colors ?? {};
1518
+ const colorKeys = Object.keys(lightColors);
1519
+ if (colorKeys.length > 0) {
1520
+ logger.info(` Colors: ${colorKeys.length} (${colorKeys.slice(0, 5).join(', ')}${colorKeys.length > 5 ? '…' : ''})`);
1521
+ }
1522
+ const titleFont = ds?.light?.typography?.title?.fontFamily;
1523
+ if (titleFont) {
1524
+ logger.info(` Title font: ${titleFont}`);
1525
+ }
1526
+ if (ds?.dark?.colors) {
1527
+ logger.info(` Dark mode: derived (${Object.keys(ds.dark.colors).length} colors)`);
1528
+ }
1529
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
1530
+ logger.info(` Warnings:`);
1531
+ for (const w of result.warnings) {
1532
+ logger.info(` - ${w}`);
1533
+ }
1534
+ }
1535
+ if (opts.dry) {
1536
+ logger.info(` (dry-run: nothing persisted)`);
1537
+ }
1538
+ process.exit(0);
1539
+ });
1540
+ brandingCmd
1541
+ .command('export')
1542
+ .description('Export the project design system as design.md (or JSON)')
1543
+ .requiredOption('--project <id>', 'Project ID')
1544
+ .option('--out <path>', 'Write to file instead of stdout')
1545
+ .option('--format <fmt>', 'Output format: md or json', 'md')
1546
+ .action(async (opts) => {
1547
+ if (opts.format !== 'md' && opts.format !== 'json') {
1548
+ fatal('Invalid --format. Use "md" or "json".');
1549
+ }
1550
+ const config = await requireConfig();
1551
+ const searchParams = opts.format === 'json' ? new URLSearchParams({ format: 'json' }) : undefined;
1552
+ if (opts.format === 'json') {
1553
+ const payload = await requestJson(config, `/api/cli/projects/${opts.project}/design-system`, { headers: authHeaders(config) }, 'Failed to export design system', searchParams);
1554
+ const body = JSON.stringify(payload, null, 2);
1555
+ if (opts.out) {
1556
+ await fs.writeFile(path.resolve(process.cwd(), opts.out), body, 'utf8');
1557
+ logger.info(`Wrote design system JSON to ${opts.out}`);
1558
+ }
1559
+ else {
1560
+ process.stdout.write(`${body}\n`);
1561
+ }
1562
+ process.exit(0);
1563
+ }
1564
+ const result = await requestJson(config, `/api/cli/projects/${opts.project}/design-system`, { headers: authHeaders(config) }, 'Failed to export design system');
1565
+ if (opts.out) {
1566
+ await fs.writeFile(path.resolve(process.cwd(), opts.out), result.content, 'utf8');
1567
+ logger.info(`Wrote design.md to ${opts.out}`);
1568
+ }
1569
+ else {
1570
+ process.stdout.write(result.content);
1571
+ if (!result.content.endsWith('\n'))
1572
+ process.stdout.write('\n');
1573
+ }
1574
+ process.exit(0);
1575
+ });
1576
+ // ── doctor command ──────────────────────────────────────────────────
1577
+ program
1578
+ .command('doctor')
1579
+ .description('Check environment and dependencies (Node, Chromium, ffmpeg, config, skill, version)')
1580
+ .option('--fix', 'Attempt to auto-fix detected issues (Chromium install, skill reinstall)', false)
1581
+ .option('--agent <name>', 'Override agent for skill check: claude, codex, cursor, windsurf, copilot')
1582
+ .action(async (opts) => {
1583
+ const { runDoctor } = await import('./cli-doctor.js');
1584
+ await runDoctor(opts, version);
1585
+ });
1531
1586
  // ── Entry point ─────────────────────────────────────────────────────
1532
1587
  const resolvedArgv = process.argv[1] && fs.realpath(process.argv[1]).catch(() => process.argv[1]);
1533
1588
  const isDirectExecution = resolvedArgv && await resolvedArgv.then(p => {
@@ -1535,6 +1590,7 @@ const isDirectExecution = resolvedArgv && await resolvedArgv.then(p => {
1535
1590
  return base === 'cli.js' || base === 'cli.ts';
1536
1591
  });
1537
1592
  if (isDirectExecution) {
1593
+ await displayNewVersionNoticeIfAvailable(version);
1538
1594
  program.parseAsync().catch(async (err) => {
1539
1595
  logger.error(err.message);
1540
1596
  process.exit(1);
@@ -693,6 +693,13 @@ export interface OpcodeTiming {
693
693
  width: number;
694
694
  height: number;
695
695
  } | null;
696
+ /**
697
+ * For TYPE opcodes captured in clipCursor mode: timestamp (ms relative to the
698
+ * active clip start) of each individual keystroke produced by `humanType`.
699
+ * Drives keyboard SFX per-keystroke in the video compositor. Empty/undefined
700
+ * for non-TYPE opcodes and for typing paths that bypass humanType.
701
+ */
702
+ keystrokeOffsetsMs?: number[];
696
703
  }
697
704
  export interface RunResult {
698
705
  programId: string;
@@ -749,13 +756,23 @@ export interface RecordingResult {
749
756
  mimeType: string;
750
757
  trimStartMs?: number;
751
758
  }
759
+ export interface TypeOptions {
760
+ /**
761
+ * Called once per keystroke produced by `humanType`, with the absolute
762
+ * wall-clock timestamp (`Date.now()`) of the keystroke. The runner converts
763
+ * those to clip-relative offsets stored on `OpcodeTiming.keystrokeOffsetsMs`
764
+ * so the compositor can fire per-keystroke SFX. Only fires in clipCursor
765
+ * mode (the only path that produces visible per-key animation).
766
+ */
767
+ onKeystroke?: (timestampMs: number) => void;
768
+ }
752
769
  export interface RuntimeAdapter {
753
770
  navigate(url: string): Promise<void>;
754
771
  getCurrentUrl(): Promise<string>;
755
772
  getAKTree(): Promise<AKTree>;
756
773
  getPageSignals(): Promise<VideoPageSignals>;
757
774
  click(selector: string, options?: ClickOptions): Promise<void>;
758
- type(selector: string, text: string, clearFirst?: boolean): Promise<void>;
775
+ type(selector: string, text: string, clearFirst?: boolean, opts?: TypeOptions): Promise<void>;
759
776
  pressKey(key: string): Promise<void>;
760
777
  scroll(direction: 'up' | 'down' | 'left' | 'right', amount?: number): Promise<void>;
761
778
  scrollIntoView(selector: string): Promise<void>;
@@ -808,7 +825,7 @@ export interface RuntimeAdapter {
808
825
  selector?: string;
809
826
  target?: SemanticTarget;
810
827
  selectorAlternates?: string[];
811
- }, text: string, clearFirst?: boolean): Promise<void>;
828
+ }, text: string, clearFirst?: boolean, typeOpts?: TypeOptions): Promise<void>;
812
829
  /** Wait for an element by semantic target. */
813
830
  waitForTarget?(opts: {
814
831
  selector?: string;
@@ -52,8 +52,14 @@ export declare function animatedHover(page: Page, target: {
52
52
  /**
53
53
  * Type text into the currently focused element at a human-like typing speed.
54
54
  * Assumes the field is already focused (via a preceding click).
55
+ *
56
+ * `onKeystroke` fires after each character with the absolute wall-clock
57
+ * timestamp (`Date.now()`) of the keystroke. The video pipeline converts
58
+ * these to clip-relative offsets so keyboard SFX fire in lock-step with the
59
+ * visible typing.
55
60
  */
56
61
  export declare function humanType(page: Page, text: string, options?: {
57
62
  minDelayMs?: number;
58
63
  maxDelayMs?: number;
64
+ onKeystroke?: (timestampMs: number) => void;
59
65
  }): Promise<void>;
@@ -132,12 +132,20 @@ export async function animatedHover(page, target, fromCurrent, options = {}) {
132
132
  /**
133
133
  * Type text into the currently focused element at a human-like typing speed.
134
134
  * Assumes the field is already focused (via a preceding click).
135
+ *
136
+ * `onKeystroke` fires after each character with the absolute wall-clock
137
+ * timestamp (`Date.now()`) of the keystroke. The video pipeline converts
138
+ * these to clip-relative offsets so keyboard SFX fire in lock-step with the
139
+ * visible typing.
135
140
  */
136
141
  export async function humanType(page, text, options = {}) {
137
142
  const minDelay = Math.max(0, options.minDelayMs ?? 60);
138
143
  const maxDelay = Math.max(minDelay, options.maxDelayMs ?? 140);
139
144
  for (const char of text) {
140
145
  await page.keyboard.type(char);
146
+ if (options.onKeystroke) {
147
+ options.onKeystroke(Date.now());
148
+ }
141
149
  // 60–120 WPM → ~80–130ms between characters (5 chars per word)
142
150
  const delay = minDelay + Math.random() * (maxDelay - minDelay);
143
151
  if (delay > 0) {
@@ -40,5 +40,11 @@ export declare function findUnresolvedCredentialPlaceholders(text: string, crede
40
40
  export interface OpcodeActionResult {
41
41
  success: boolean;
42
42
  error?: string;
43
+ /**
44
+ * For TYPE opcodes: absolute wall-clock timestamps (`Date.now()`) of each
45
+ * keystroke produced by `humanType`. The runner converts these to
46
+ * clip-relative offsets so the video compositor can fire per-keystroke SFX.
47
+ */
48
+ keystrokeTimestampsMs?: number[];
43
49
  }
44
50
  export declare function executeOpcodeCoreAction(opcode: ExecutionOpcode, adapter: RuntimeAdapter, context?: OpcodeActionContext): Promise<OpcodeActionResult>;