autokap 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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({
@@ -763,10 +773,82 @@ function inferVariantTheme(variantId) {
763
773
  }
764
774
  async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumber, job) {
765
775
  const { artifact, variant, variantSpec } = job;
766
- const formData = new FormData();
767
776
  const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
768
777
  const label = artifact.captureName ?? artifact.clipName ?? filename;
769
778
  logger.info(`[capture] Exporting capture ${uploadNumber}/${totalArtifacts}: ${label}`);
779
+ if (process.env.AUTOKAP_USE_LEGACY_MULTIPART_UPLOADS === '1') {
780
+ await uploadArtifactMultipart(config, program, runId, job, filename);
781
+ return;
782
+ }
783
+ const prepared = await prepareDirectArtifactUpload({
784
+ program,
785
+ runId,
786
+ artifact,
787
+ variant,
788
+ variantSpec,
789
+ });
790
+ const uploadsUrl = `${config.apiBaseUrl}/api/cli/artifacts/uploads`;
791
+ const initResponse = await fetch(uploadsUrl, {
792
+ method: 'POST',
793
+ headers: {
794
+ 'Authorization': `Bearer ${config.apiKey}`,
795
+ 'Content-Type': 'application/json',
796
+ [CLI_VERSION_HEADER]: APP_VERSION,
797
+ },
798
+ body: JSON.stringify({ metadata: prepared.metadata }),
799
+ });
800
+ if (!initResponse.ok) {
801
+ throw new Error(`artifact upload init failed for ${variant.variantId}: ${await formatServerError(initResponse, uploadsUrl)}`);
802
+ }
803
+ const initPayload = (await initResponse.json());
804
+ const uploadTargets = new Map(initPayload.uploads.map((target) => [target.id, target]));
805
+ const uploadedObjects = [];
806
+ for (const part of prepared.parts) {
807
+ const target = uploadTargets.get(part.id);
808
+ if (!target) {
809
+ throw new Error(`artifact upload init did not return a target for ${part.id}`);
810
+ }
811
+ const uploadResponse = await fetch(target.signedUrl, {
812
+ method: target.method,
813
+ headers: {
814
+ 'content-type': part.contentType,
815
+ 'cache-control': 'max-age=3600',
816
+ },
817
+ body: new Uint8Array(part.buffer),
818
+ });
819
+ if (!uploadResponse.ok) {
820
+ throw new Error(`artifact storage upload failed for ${part.id}: ${await formatServerError(uploadResponse, target.signedUrl)}`);
821
+ }
822
+ uploadedObjects.push({
823
+ id: part.id,
824
+ bucket: target.bucket,
825
+ path: target.path,
826
+ contentType: target.contentType,
827
+ sizeBytes: part.buffer.length,
828
+ sha256: sha256Hex(part.buffer),
829
+ });
830
+ }
831
+ const completeUrl = `${config.apiBaseUrl}/api/cli/artifacts/complete`;
832
+ const completeResponse = await fetch(completeUrl, {
833
+ method: 'POST',
834
+ headers: {
835
+ 'Authorization': `Bearer ${config.apiKey}`,
836
+ 'Content-Type': 'application/json',
837
+ [CLI_VERSION_HEADER]: APP_VERSION,
838
+ },
839
+ body: JSON.stringify({
840
+ uploadId: initPayload.uploadId,
841
+ metadata: prepared.metadata,
842
+ objects: uploadedObjects,
843
+ }),
844
+ });
845
+ if (!completeResponse.ok) {
846
+ throw new Error(`artifact completion failed for ${variant.variantId}: ${await formatServerError(completeResponse, completeUrl)}`);
847
+ }
848
+ }
849
+ async function uploadArtifactMultipart(config, program, runId, job, filename) {
850
+ const { artifact, variant, variantSpec } = job;
851
+ const formData = new FormData();
770
852
  formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
771
853
  formData.append('presetId', program.presetId);
772
854
  formData.append('programVersion', String(program.programVersion));
@@ -836,6 +918,237 @@ async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumb
836
918
  throw new Error(`artifact upload failed for ${variant.variantId}: ${await formatServerError(response, `${config.apiBaseUrl}/api/cli/artifacts`)}`);
837
919
  }
838
920
  }
921
+ async function prepareDirectArtifactUpload(params) {
922
+ const { program, runId, artifact, variant, variantSpec } = params;
923
+ const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
924
+ const isFrameCapture = artifact.mediaMode === 'clip' || artifact.mediaMode === 'video';
925
+ const deviceScaleFactor = isFrameCapture && Number.isFinite(requestedDeviceScaleFactor)
926
+ ? Math.min(Number(requestedDeviceScaleFactor), MAX_CLIP_CAPTURE_DEVICE_SCALE_FACTOR)
927
+ : requestedDeviceScaleFactor;
928
+ const tabIconSha256 = artifact.tabIconData ? sha256Hex(artifact.tabIconData) : null;
929
+ const metadata = {
930
+ presetId: program.presetId,
931
+ programVersion: program.programVersion,
932
+ compileFingerprint: program.compileFingerprint,
933
+ runId,
934
+ variantId: variant.variantId,
935
+ targetId: variantSpec?.targetId ?? variant.variantId,
936
+ targetLabel: variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId,
937
+ mediaMode: artifact.mediaMode,
938
+ mimeType: artifact.mimeType,
939
+ captureType: artifact.captureType ?? 'fullpage',
940
+ captureUrl: artifact.captureUrl ?? program.baseUrl,
941
+ lang: variantSpec?.locale ?? 'en',
942
+ theme: variantSpec?.theme ?? 'light',
943
+ deviceFrame: variantSpec?.deviceFrame ?? null,
944
+ deviceScaleFactor: Number.isFinite(deviceScaleFactor) ? Number(deviceScaleFactor) : null,
945
+ viewport: variantSpec?.viewport ?? null,
946
+ altText: artifact.altText ?? null,
947
+ elementSelector: artifact.elementSelector ?? null,
948
+ captureId: artifact.captureId ?? null,
949
+ captureName: artifact.captureName ?? null,
950
+ clipId: artifact.clipId ?? null,
951
+ clipName: artifact.clipName ?? null,
952
+ stepDescription: artifact.stepDescription ?? null,
953
+ stepIndex: typeof artifact.stepIndex === 'number' ? artifact.stepIndex : null,
954
+ durationMs: typeof artifact.durationMs === 'number' ? artifact.durationMs : null,
955
+ trimStartMs: typeof artifact.trimStartMs === 'number' ? artifact.trimStartMs : null,
956
+ artifactPlan: program.artifactPlan,
957
+ tabIconMimeType: artifact.tabIconData ? (artifact.tabIconMimeType ?? 'image/png') : null,
958
+ tabIconSha256,
959
+ };
960
+ const parts = await prepareDirectUploadParts({
961
+ metadata,
962
+ artifact,
963
+ program,
964
+ variantSpec,
965
+ });
966
+ return { metadata, parts };
967
+ }
968
+ async function prepareDirectUploadParts(params) {
969
+ const { metadata, artifact, program, variantSpec } = params;
970
+ if (artifact.mediaMode === 'screenshot') {
971
+ const finalScreenshot = await prepareScreenshotBufferForDirectUpload(artifact.buffer, metadata, program, variantSpec, artifact.tabIconData
972
+ ? {
973
+ buffer: artifact.tabIconData,
974
+ mimeType: artifact.tabIconMimeType ?? 'image/png',
975
+ }
976
+ : null);
977
+ const parts = [
978
+ { id: 'screenshotRaw', buffer: artifact.buffer, contentType: 'image/png' },
979
+ { id: 'screenshot', buffer: finalScreenshot.buffer, contentType: finalScreenshot.contentType },
980
+ ];
981
+ if (artifact.tabIconData) {
982
+ parts.push({
983
+ id: 'tabIcon',
984
+ buffer: artifact.tabIconData,
985
+ contentType: artifact.tabIconMimeType ?? 'image/png',
986
+ });
987
+ }
988
+ return parts;
989
+ }
990
+ if (artifact.mediaMode === 'clip') {
991
+ return prepareClipBuffersForDirectUpload(artifact, metadata);
992
+ }
993
+ return [
994
+ {
995
+ id: 'videoMp4',
996
+ buffer: artifact.buffer,
997
+ contentType: 'video/mp4',
998
+ },
999
+ ];
1000
+ }
1001
+ async function prepareScreenshotBufferForDirectUpload(input, metadata, program, variantSpec, tabIcon) {
1002
+ let output = input;
1003
+ const artifactPlan = resolveEffectiveCliArtifactPlan(program.artifactPlan, variantSpec?.deviceFrame ?? null);
1004
+ if (artifactPlan?.applyStatusBar && !artifactPlan?.applyMockup) {
1005
+ throw new Error('applyStatusBar requires applyMockup with a device frame');
1006
+ }
1007
+ if (artifactPlan?.applyMockup) {
1008
+ if (!variantSpec?.deviceFrame) {
1009
+ throw new Error('applyMockup requires a deviceFrame on the variant');
1010
+ }
1011
+ output = await applyDeviceFrame(output, variantSpec.deviceFrame, {
1012
+ orientation: inferCliOrientation(metadata.viewport ?? null),
1013
+ colorScheme: metadata.theme,
1014
+ outputScale: normalizeCliDeviceScaleFactor(metadata.deviceScaleFactor)
1015
+ ?? normalizeCliDeviceScaleFactor(program.outputScale)
1016
+ ?? 2,
1017
+ showStatusBar: artifactPlan.applyStatusBar ?? false,
1018
+ statusBar: localizeStatusBar({}, metadata.lang),
1019
+ browserBar: buildCliBrowserBar(metadata.captureUrl, metadata.theme, tabIcon),
1020
+ });
1021
+ }
1022
+ if (artifactPlan?.format?.screenshotFormat === 'jpeg') {
1023
+ return {
1024
+ buffer: await sharp(output).jpeg({ quality: 90 }).toBuffer(),
1025
+ contentType: 'image/jpeg',
1026
+ };
1027
+ }
1028
+ return { buffer: output, contentType: 'image/png' };
1029
+ }
1030
+ async function prepareClipBuffersForDirectUpload(artifact, metadata) {
1031
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'autokap-cli-direct-clip-'));
1032
+ try {
1033
+ const sourceExtension = mimeToExtension(artifact.mimeType);
1034
+ const sourcePath = path.join(tempDir, `source.${sourceExtension}`);
1035
+ await fs.writeFile(sourcePath, artifact.buffer);
1036
+ const clipId = sanitizeArtifactToken(metadata.clipId ?? `${metadata.variantId}-${metadata.stepIndex ?? 0}`);
1037
+ const postResult = await postProcessClipRecording(sourcePath, tempDir, `${clipId}-${sanitizeArtifactToken(metadata.variantId)}`, {
1038
+ format: metadata.artifactPlan?.format && typeof metadata.artifactPlan.format === 'object'
1039
+ ? metadata.artifactPlan.format.clipFormat
1040
+ : undefined,
1041
+ maxDurationSec: typeof metadata.artifactPlan?.maxClipDurationSec === 'number'
1042
+ ? metadata.artifactPlan.maxClipDurationSec
1043
+ : undefined,
1044
+ gifMaxWidth: metadata.viewport?.width
1045
+ ? Math.min(1440, Math.round(metadata.viewport.width))
1046
+ : undefined,
1047
+ trimStartSec: Math.max(0, (metadata.trimStartMs ?? 0) / 1000),
1048
+ mp4Width: resolveClipMp4Size(metadata.viewport ?? null, metadata.deviceScaleFactor ?? null)?.width,
1049
+ mp4Height: resolveClipMp4Size(metadata.viewport ?? null, metadata.deviceScaleFactor ?? null)?.height,
1050
+ skipMp4Encode: artifact.mimeType === 'video/mp4',
1051
+ });
1052
+ metadata.durationMs = postResult.durationMs;
1053
+ const parts = [];
1054
+ if (postResult.gifPath) {
1055
+ parts.push({
1056
+ id: 'clipGif',
1057
+ buffer: await fs.readFile(postResult.gifPath),
1058
+ contentType: 'image/gif',
1059
+ });
1060
+ }
1061
+ if (postResult.mp4Path) {
1062
+ parts.push({
1063
+ id: 'clipMp4',
1064
+ buffer: await fs.readFile(postResult.mp4Path),
1065
+ contentType: 'video/mp4',
1066
+ });
1067
+ }
1068
+ if (postResult.thumbnailPath) {
1069
+ parts.push({
1070
+ id: 'clipThumbnail',
1071
+ buffer: await fs.readFile(postResult.thumbnailPath),
1072
+ contentType: 'image/png',
1073
+ });
1074
+ }
1075
+ return parts;
1076
+ }
1077
+ finally {
1078
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
1079
+ }
1080
+ }
1081
+ function resolveEffectiveCliArtifactPlan(artifactPlan, deviceFrame) {
1082
+ if (deviceFrame) {
1083
+ if (artifactPlan.applyMockup === false)
1084
+ return artifactPlan;
1085
+ return artifactPlan.applyMockup ? artifactPlan : { ...artifactPlan, applyMockup: true };
1086
+ }
1087
+ if (!artifactPlan.applyMockup && !artifactPlan.applyStatusBar)
1088
+ return artifactPlan;
1089
+ const { applyMockup: _applyMockup, applyStatusBar: _applyStatusBar, ...rest } = artifactPlan;
1090
+ return rest;
1091
+ }
1092
+ function inferCliOrientation(viewport) {
1093
+ if (!viewport)
1094
+ return undefined;
1095
+ return viewport.width >= viewport.height ? 'landscape' : 'portrait';
1096
+ }
1097
+ function normalizeCliDeviceScaleFactor(value) {
1098
+ if (!Number.isFinite(value))
1099
+ return null;
1100
+ return Math.max(0.5, Math.min(4, Number(value)));
1101
+ }
1102
+ function buildCliBrowserBar(captureUrl, colorScheme, tabIcon) {
1103
+ if (!captureUrl)
1104
+ return undefined;
1105
+ try {
1106
+ const parsed = new URL(captureUrl);
1107
+ return {
1108
+ url: captureUrl,
1109
+ pageTitle: parsed.hostname,
1110
+ colorScheme,
1111
+ tabIconUrl: tabIcon
1112
+ ? `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}`
1113
+ : `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=64`,
1114
+ };
1115
+ }
1116
+ catch {
1117
+ return {
1118
+ url: captureUrl,
1119
+ pageTitle: captureUrl,
1120
+ colorScheme,
1121
+ ...(tabIcon
1122
+ ? { tabIconUrl: `data:${tabIcon.mimeType};base64,${tabIcon.buffer.toString('base64')}` }
1123
+ : {}),
1124
+ };
1125
+ }
1126
+ }
1127
+ function resolveClipMp4Size(viewport, deviceScaleFactor) {
1128
+ if (!viewport)
1129
+ return null;
1130
+ const scale = normalizeCliDeviceScaleFactor(deviceScaleFactor) ?? 1;
1131
+ return {
1132
+ width: Math.max(2, Math.round(viewport.width * scale)) & ~1,
1133
+ height: Math.max(2, Math.round(viewport.height * scale)) & ~1,
1134
+ };
1135
+ }
1136
+ function sha256Hex(buffer) {
1137
+ return createHash('sha256').update(buffer).digest('hex');
1138
+ }
1139
+ function mimeToExtension(mimeType) {
1140
+ if (mimeType.includes('jpeg'))
1141
+ return 'jpg';
1142
+ if (mimeType.includes('png'))
1143
+ return 'png';
1144
+ if (mimeType.includes('gif'))
1145
+ return 'gif';
1146
+ if (mimeType.includes('mp4'))
1147
+ return 'mp4';
1148
+ if (mimeType.includes('webm'))
1149
+ return 'webm';
1150
+ return 'bin';
1151
+ }
839
1152
  async function runWithConcurrency(items, concurrency, worker) {
840
1153
  if (items.length === 0) {
841
1154
  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);
package/dist/types.d.ts CHANGED
@@ -583,7 +583,7 @@ export interface ClipOptions {
583
583
  /** Usage metadata from a single OpenRouter API call */
584
584
  export interface StepUsage {
585
585
  stepNumber: number;
586
- stepType: 'agent_iteration' | 'verification' | 'element_capture' | 'video_planning' | 'video_variant_classification' | 'video_step_verification' | 'video_step_fix' | 'assistant_chat' | 'studio_creation' | 'studio_iteration' | 'studio_capture_suggestion' | 'mock_data_generation' | 'page_identity_classification' | 'capture_verification' | 'alt_text_generation' | 'healer_invocation' | 'cron_feedback_classification' | 'tts_generation' | 'crm_landing_analysis' | 'crm_mail_generation';
586
+ stepType: 'agent_iteration' | 'verification' | 'element_capture' | 'video_planning' | 'video_variant_classification' | 'video_step_verification' | 'video_step_fix' | 'assistant_chat' | 'studio_creation' | 'studio_iteration' | 'studio_capture_suggestion' | 'mock_data_generation' | 'page_identity_classification' | 'capture_verification' | 'alt_text_generation' | 'healer_invocation' | 'cron_feedback_classification' | 'tts_generation';
587
587
  generationId: string | null;
588
588
  modelRequested: string;
589
589
  modelUsed: string | null;
@@ -0,0 +1,4 @@
1
+ export declare function fetchLatestVersionFromRegistry(): Promise<string | null>;
2
+ export declare function isNewerVersion(latest: string, current: string): boolean;
3
+ export declare function getCachedOrFetchLatest(): Promise<string | null>;
4
+ export declare function displayNewVersionNoticeIfAvailable(currentVersion: string): Promise<void>;