Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 51 additions & 20 deletions src/cli/commands/inspiredesign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import {
parseBooleanFlag,
parseNumberFlag,
parseOptionalStringFlag,
parseRepeatedStringFlag
parseRepeatedStringFlag,
readInlineFlagValue
} from "../utils/parse";
import { buildWorkflowCompletionMessage } from "../utils/workflow-message";
import { isChallengeAutomationMode, type ChallengeAutomationMode } from "../../challenges/types";
import { validateProviderUrlSiteRecipeCompatibility } from "../../guidance/recipes/site-recipe-validation";
import {
requiresProviderUrlSiteRecipeCompatibility,
validateProviderScopedUrlCanonicality,
validateProviderUrlSiteRecipeCompatibility
} from "../../guidance/recipes/site-recipe-validation";
import { resolveInspiredesignCaptureMode } from "../../inspiredesign/capture-mode";
import type { InspiredesignVisualEvidenceMode } from "../../inspiredesign/visual-evidence";
import type { WorkflowBrowserMode } from "../../providers/types";
Expand Down Expand Up @@ -101,7 +106,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--capture-mode=")) {
const value = (arg.split("=", 2)[1] ?? "").toLowerCase();
const value = (readInlineFlagValue(arg, "--capture-mode") ?? "").toLowerCase();
if (!CAPTURE_MODE_VALUES.has(value)) {
throw createUsageError(`Invalid --capture-mode: ${value}`);
}
Expand All @@ -114,7 +119,10 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--include-prototype-guidance=")) {
parsed.includePrototypeGuidance = parseBooleanFlag(arg.split("=", 2)[1] ?? "", "--include-prototype-guidance");
parsed.includePrototypeGuidance = parseBooleanFlag(
readInlineFlagValue(arg, "--include-prototype-guidance") ?? "",
"--include-prototype-guidance"
);
continue;
}

Expand All @@ -128,7 +136,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--mode=")) {
const value = (arg.split("=", 2)[1] ?? "").toLowerCase();
const value = (readInlineFlagValue(arg, "--mode") ?? "").toLowerCase();
if (!MODE_VALUES.has(value)) {
throw createUsageError(`Invalid --mode: ${value}`);
}
Expand All @@ -145,10 +153,14 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--max-references=")) {
parsed.maxReferences = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--max-references", {
min: 1,
max: MAX_REFERENCES_LIMIT
});
parsed.maxReferences = parseNumberFlag(
readInlineFlagValue(arg, "--max-references") ?? "",
"--max-references",
{
min: 1,
max: MAX_REFERENCES_LIMIT
}
);
continue;
}

Expand All @@ -162,7 +174,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--visual-evidence=")) {
const value = (arg.split("=", 2)[1] ?? "").toLowerCase();
const value = (readInlineFlagValue(arg, "--visual-evidence") ?? "").toLowerCase();
if (!VISUAL_EVIDENCE_VALUES.has(value)) {
throw createUsageError(`Invalid --visual-evidence: ${value}`);
}
Expand All @@ -176,7 +188,11 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--timeout-ms=")) {
parsed.timeoutMs = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--timeout-ms", { min: 1 });
parsed.timeoutMs = parseNumberFlag(
readInlineFlagValue(arg, "--timeout-ms") ?? "",
"--timeout-ms",
{ min: 1 }
);
continue;
}

Expand All @@ -186,7 +202,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--output-dir=")) {
parsed.outputDir = resolveWorkflowOutputDirFlag(arg.split("=", 2)[1]);
parsed.outputDir = resolveWorkflowOutputDirFlag(readInlineFlagValue(arg, "--output-dir"));
continue;
}

Expand All @@ -196,7 +212,11 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--ttl-hours=")) {
parsed.ttlHours = parseNumberFlag(arg.split("=", 2)[1] ?? "", "--ttl-hours", { min: 1, max: 168 });
parsed.ttlHours = parseNumberFlag(
readInlineFlagValue(arg, "--ttl-hours") ?? "",
"--ttl-hours",
{ min: 1, max: 168 }
);
continue;
}

Expand All @@ -210,7 +230,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--browser-mode=")) {
const value = (arg.split("=", 2)[1] ?? "").toLowerCase();
const value = (readInlineFlagValue(arg, "--browser-mode") ?? "").toLowerCase();
if (!BROWSER_MODE_VALUES.has(value)) {
throw createUsageError(`Invalid --browser-mode: ${value}`);
}
Expand All @@ -223,7 +243,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--use-cookies=")) {
parsed.useCookies = parseBooleanFlag(arg.split("=", 2)[1] ?? "", "--use-cookies");
parsed.useCookies = parseBooleanFlag(readInlineFlagValue(arg, "--use-cookies") ?? "", "--use-cookies");
continue;
}

Expand All @@ -237,7 +257,7 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--challenge-automation-mode=")) {
const value = arg.split("=", 2)[1] ?? "";
const value = readInlineFlagValue(arg, "--challenge-automation-mode") ?? "";
if (!isChallengeAutomationMode(value)) {
throw createUsageError(`Invalid --challenge-automation-mode: ${value}`);
}
Expand All @@ -255,7 +275,8 @@ const parseInspiredesignArgs = (rawArgs: string[]): InspiredesignCommandArgs =>
continue;
}
if (arg?.startsWith("--cookie-policy-override=") || arg?.startsWith("--cookie-policy=")) {
const value = (arg.split("=", 2)[1] ?? "").toLowerCase();
const flag = arg.startsWith("--cookie-policy-override=") ? "--cookie-policy-override" : "--cookie-policy";
const value = (readInlineFlagValue(arg, flag) ?? "").toLowerCase();
if (!COOKIE_POLICY_VALUES.has(value)) {
throw createUsageError(`Invalid --cookie-policy-override: ${value}`);
}
Expand All @@ -280,13 +301,23 @@ export async function runInspiredesignCommand(args: ParsedArgs) {
throw createUsageError("--query is only supported by inspiredesign harvest");
}
const isHarvest = subcommand === "harvest";
if (parsed.providers && parsed.providers.length > 0 && !parsed.query) {
const providers = parsed.providers ?? [];
const urls = parsed.urls ?? [];
const canonicality = validateProviderScopedUrlCanonicality({ providers, urls });
if (!canonicality.ok) {
throw createUsageError(canonicality.message);
}
if (requiresProviderUrlSiteRecipeCompatibility({
providers,
urls,
query: parsed.query
})) {
if (!isHarvest) {
throw createUsageError("--provider requires --query or compatible harvest --url recovery");
}
const compatibility = validateProviderUrlSiteRecipeCompatibility({
providers: parsed.providers,
urls: parsed.urls ?? []
providers,
urls
});
if (!compatibility.ok) {
throw createUsageError(compatibility.message);
Expand Down
9 changes: 7 additions & 2 deletions src/cli/utils/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ function decimalPattern(options: NumberFlagOptions): RegExp {
return signed ? SIGNED_DECIMAL_PATTERN : UNSIGNED_DECIMAL_PATTERN;
}

export const readInlineFlagValue = (arg: string, flag: string): string | undefined => {
const prefix = `${flag}=`;
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
};

export function parseNumberFlag(value: string, flag: string, options: NumberFlagOptions = {}): number {
if (value.trim() === "" || value !== value.trim() || !decimalPattern(options).test(value)) {
throw createUsageError(`Invalid ${flag}: ${value}`);
Expand Down Expand Up @@ -66,7 +71,7 @@ export function parseOptionalStringFlag(rawArgs: string[], flag: string): string
return value;
}
if (arg?.startsWith(`${flag}=`)) {
const value = arg.split("=", 2)[1];
const value = readInlineFlagValue(arg, flag);
if (!value) {
throw createUsageError(`Missing value for ${flag}`);
}
Expand Down Expand Up @@ -102,7 +107,7 @@ export function parseRepeatedStringFlag(rawArgs: string[], flag: string): string
continue;
}
if (arg?.startsWith(`${flag}=`)) {
const value = arg.split("=", 2)[1];
const value = readInlineFlagValue(arg, flag);
if (!value) {
throw createUsageError(`Missing value for ${flag}`);
}
Expand Down
20 changes: 15 additions & 5 deletions src/guidance/recipes/generic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { buildCanvasPlanSetParamsExample, buildCanvasRepairGuidance } from "../../canvas/repair-examples";
import { resolveSiteRecipeForUrl } from "./site-registry";
import { normalizePinterestReferenceUrl } from "./pinterest";
import type { CanvasGenerationPlanIssue } from "../../canvas/types";
import type {
GuidanceCommandExample,
Expand All @@ -18,6 +18,7 @@ const DEFAULT_INSPIREDESIGN_BRIEF = "Digital photography studio landing page";
const DEFAULT_INSPIREDESIGN_QUERY = "cinematic photography studio landing page inspiration";
const DEFAULT_RESEARCH_TOPIC = "browser automation provider recovery";
const DEFAULT_PROVIDER = "provider diagnostics";
const DEFAULT_PINTEREST_REFERENCE_URL = "https://www.pinterest.com/pin/27654985208435505/";

const isCanvasGenerationPlanIssue = (value: unknown): value is CanvasGenerationPlanIssue => {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
Expand Down Expand Up @@ -65,9 +66,18 @@ const inspiredesignCookieFlags = (context: GuidanceContext): string => {
const pinterestRecoveryUrls = (context: GuidanceContext): string[] => {
if (!isPinterestScopedRecovery(context)) return [];
const urls = context.referenceUrls ?? [];
return [...new Set(urls.filter((url) => resolveSiteRecipeForUrl(url)?.id === "social/pinterest"))];
const canonicalUrls = urls
.map(normalizePinterestReferenceUrl)
.filter((url): url is string => url !== null);
return [...new Set(canonicalUrls)];
};

const inspiredesignRecoveryExampleUrls = (context: GuidanceContext): string[] => (
selectedProviderIsPinterest(context)
? [DEFAULT_PINTEREST_REFERENCE_URL]
: ["https://example.com/usable-reference"]
);

const inspiredesignHarvestCommand = (context: GuidanceContext): GuidanceCommandExample => {
const brief = typeof context.details?.brief === "string" ? context.details.brief : DEFAULT_INSPIREDESIGN_BRIEF;
const query = context.query ?? DEFAULT_INSPIREDESIGN_QUERY;
Expand Down Expand Up @@ -444,10 +454,10 @@ const buildEvidenceRecoveryGuidance = (
commands: [inspiredesignHarvestCommand(context)],
paramsExamples: [{
id: "explicit-reference-url-recovery",
label: "Use explicit high-quality visual references when provider discovery is blocked",
params: {
label: "Use explicit high-quality visual references when provider discovery is blocked",
params: {
brief: typeof context.details?.brief === "string" ? context.details.brief : DEFAULT_INSPIREDESIGN_BRIEF,
urls: ["https://example.com/usable-reference"],
urls: inspiredesignRecoveryExampleUrls(context),
visualEvidence: "required",
browserMode: inspiredesignBrowserMode(context),
...(selectedProviderIsPinterest(context) ? { useCookies: true, cookiePolicy: "required" } : {})
Expand Down
7 changes: 7 additions & 0 deletions src/guidance/recipes/pinterest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ const pinterestGuidance: NextStepGuidance = {
const PINTEREST_PIN_ID_PATTERN = /^\d+$/;
const RESERVED_PINTEREST_BOARD_PATHS = new Set([
"about",
"ads",
"board",
"business",
"careers",
"contact",
"create",
"developers",
"explore",
"help",
"ideas",
"login",
"messages",
Expand All @@ -65,11 +70,13 @@ const RESERVED_PINTEREST_BOARD_PATHS = new Set([
"search",
"settings",
"shopping",
"terms",
"today"
]);
const RESERVED_PINTEREST_IDEA_PATHS = new Set(["create", "edit", "search"]);
const RESERVED_PINTEREST_PROFILE_TABS = new Set([
"activity",
"boards",
"comments",
"created",
"followers",
Expand Down
73 changes: 73 additions & 0 deletions src/guidance/recipes/site-recipe-validation.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "./site-registry";
import { normalizePinterestReferenceUrl } from "./pinterest";
import type { SiteRecipe } from "../types";

export type ProviderUrlSiteRecipeCompatibilityResult =
| { ok: true; recipeId: string }
| { ok: false; message: string };

export type ProviderScopedUrlCanonicalityResult =
| { ok: true }
| { ok: false; message: string };

type ProviderUrlSiteRecipeCompatibilityInput = {
providers: string[];
urls: string[];
};

type ProviderUrlSiteRecipeCompatibilityGateInput = ProviderUrlSiteRecipeCompatibilityInput & {
query?: string;
};

const normalizeNonBlank = (values: string[]): string[] => values
.map((value) => value.trim())
.filter((value) => value.length > 0);
Expand All @@ -20,6 +29,63 @@ const supportsBrowserNativeRecovery = (recipe: SiteRecipe | undefined): recipe i
typeof recipe?.browserNativeDiscovery?.buildSearchUrl === "function"
);

const isCanonicalRecipeReferenceUrl = (recipe: SiteRecipe, url: string): boolean => {
if (recipe.id === "social/pinterest") {
return normalizePinterestReferenceUrl(url) !== null;
}
return true;
};

const isPinterestLikeHostname = (value: string): boolean => {
const hostname = value.toLowerCase();
return [
hostname === "pinterest.com",
hostname.endsWith(".pinterest.com"),
hostname.startsWith("pinterest."),
hostname.includes(".pinterest.")
].some(Boolean);
};

export const isPinterestLikeUrl = (value: string): boolean => {
try {
return isPinterestLikeHostname(new URL(value).hostname);
} catch {
return false;
}
};

export const isNonCanonicalPinterestLikeUrl = (url: string): boolean => (
(resolveSiteRecipeForUrl(url)?.id === "social/pinterest" || isPinterestLikeUrl(url))
&& normalizePinterestReferenceUrl(url) === null
);

export const requiresProviderUrlSiteRecipeCompatibility = ({
providers,
urls,
query
}: ProviderUrlSiteRecipeCompatibilityGateInput): boolean => {
const providerIds = normalizeNonBlank(providers);
if (providerIds.length === 0) return false;
if (!query?.trim()) return true;
return false;
};

export const validateProviderScopedUrlCanonicality = ({
providers,
urls
}: ProviderUrlSiteRecipeCompatibilityInput): ProviderScopedUrlCanonicalityResult => {
const providerHasPinterest = normalizeNonBlank(providers)
.some((providerId) => resolveSiteRecipeForProvider(providerId)?.id === "social/pinterest");
if (!providerHasPinterest) return { ok: true };

const nonCanonicalPinterestUrl = normalizeNonBlank(urls)
.find((url) => normalizePinterestReferenceUrl(url) === null);
if (!nonCanonicalPinterestUrl) return { ok: true };
return incompatible(
`URL ${nonCanonicalPinterestUrl} is not a canonical social/pinterest reference URL for provider-scoped recovery.`
);
};

export const validateProviderUrlSiteRecipeCompatibility = ({
providers,
urls
Expand Down Expand Up @@ -72,5 +138,12 @@ export const validateProviderUrlSiteRecipeCompatibility = ({
if (!recipeId) {
return incompatible("Provider-scoped URL recovery could not resolve a site recipe.");
}
const recipe = providerRecipes[0]?.recipe;
const nonReferenceUrl = recipe
? urlRecipes.find((entry) => !isCanonicalRecipeReferenceUrl(recipe, entry.url))
: undefined;
if (nonReferenceUrl) {
return incompatible(`URL ${nonReferenceUrl.url} is not a canonical ${recipeId} reference URL for provider-scoped recovery.`);
}
return { ok: true, recipeId };
};
Loading
Loading