@koda-sl/baker-cli 0.169.2 → 0.170.0

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/dist/cli.js CHANGED
@@ -4866,6 +4866,19 @@ var ASSET_FIELD_TYPES = [
4866
4866
  "MARKETING_IMAGE",
4867
4867
  "BUSINESS_NAME"
4868
4868
  ];
4869
+ var ASSET_GROUP_ASSET_FIELD_TYPES = [
4870
+ "MARKETING_IMAGE",
4871
+ "SQUARE_MARKETING_IMAGE",
4872
+ "PORTRAIT_MARKETING_IMAGE",
4873
+ "LOGO",
4874
+ "LANDSCAPE_LOGO",
4875
+ "YOUTUBE_VIDEO",
4876
+ "HEADLINE",
4877
+ "LONG_HEADLINE",
4878
+ "DESCRIPTION",
4879
+ "BUSINESS_NAME",
4880
+ "CALL_TO_ACTION_SELECTION"
4881
+ ];
4869
4882
  var DEVICE_TYPES = ["MOBILE", "TABLET", "DESKTOP", "CONNECTED_TV", "OTHER"];
4870
4883
  var DAYS_OF_WEEK = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"];
4871
4884
  var CAMPAIGN_OBJECTIVES = [
@@ -5242,9 +5255,15 @@ var assetGroupCreateSchema = z15.object({
5242
5255
  businessName: z15.string().min(1).max(GOOGLE_ADS_LIMITS.assetGroup.businessNameMax),
5243
5256
  imageAssets: z15.array(refSchema).optional(),
5244
5257
  squareImageAssets: z15.array(refSchema).optional(),
5258
+ portraitImageAssets: z15.array(refSchema).optional(),
5245
5259
  logoAssets: z15.array(refSchema).optional(),
5246
5260
  status: z15.enum(["ENABLED", "PAUSED"]).default("PAUSED")
5247
5261
  });
5262
+ var assetGroupAssetAttachSchema = z15.object({
5263
+ assetGroup: refSchema,
5264
+ asset: refSchema,
5265
+ fieldType: z15.enum(ASSET_GROUP_ASSET_FIELD_TYPES)
5266
+ });
5248
5267
  var assetGroupUpdateSchema = z15.object({
5249
5268
  name: z15.string().min(1).max(GOOGLE_ADS_LIMITS.assetGroup.nameMax).optional(),
5250
5269
  finalUrls: z15.array(httpsUrlSchema2).min(1).optional(),
@@ -5415,6 +5434,8 @@ var GOOGLE_DRAFT_OP_KINDS = [
5415
5434
  "google.assetLink.detach",
5416
5435
  "google.assetGroup.create",
5417
5436
  "google.assetGroup.update",
5437
+ "google.assetGroupAsset.attach",
5438
+ "google.assetGroupAsset.detach",
5418
5439
  "google.audience.create",
5419
5440
  "google.audienceCriterion.attach",
5420
5441
  "google.audienceCriterion.detach",
@@ -5472,6 +5493,8 @@ var googleDraftOpInputSchema = z15.discriminatedUnion("kind", [
5472
5493
  targetOp("google.assetLink.detach"),
5473
5494
  createOp2("google.assetGroup.create", assetGroupCreateSchema),
5474
5495
  updateOp2("google.assetGroup.update", assetGroupUpdateSchema),
5496
+ createOp2("google.assetGroupAsset.attach", assetGroupAssetAttachSchema),
5497
+ targetOp("google.assetGroupAsset.detach"),
5475
5498
  createOp2("google.audience.create", audienceCreateSchema2),
5476
5499
  createOp2("google.audienceCriterion.attach", audienceCriterionAttachSchema),
5477
5500
  targetOp("google.audienceCriterion.detach"),
@@ -9442,8 +9465,15 @@ var assetGroupsCommand = defineCommand30({
9442
9465
  "long-headlines": { type: "string", description: "Comma-separated long headlines (1\u20135, \u226490 chars)" },
9443
9466
  descriptions: { type: "string", description: "Comma-separated descriptions (2\u20135, \u226490 chars)" },
9444
9467
  "business-name": { type: "string" },
9445
- "image-assets": { type: "string", description: "Comma-separated marketing image asset refs" },
9446
- "square-image-assets": { type: "string", description: "Comma-separated square marketing image asset refs" },
9468
+ "image-assets": { type: "string", description: "Comma-separated marketing image asset refs (1.91:1)" },
9469
+ "square-image-assets": {
9470
+ type: "string",
9471
+ description: "Comma-separated square marketing image asset refs (1:1)"
9472
+ },
9473
+ "portrait-image-assets": {
9474
+ type: "string",
9475
+ description: "Comma-separated portrait marketing image asset refs (4:5)"
9476
+ },
9447
9477
  "logo-assets": { type: "string", description: "Comma-separated logo image asset refs" },
9448
9478
  status: { type: "string", description: "ENABLED | PAUSED (default PAUSED)" }
9449
9479
  },
@@ -9462,6 +9492,7 @@ var assetGroupsCommand = defineCommand30({
9462
9492
  businessName: args["business-name"],
9463
9493
  imageAssets: listFlag(args["image-assets"]),
9464
9494
  squareImageAssets: listFlag(args["square-image-assets"]),
9495
+ portraitImageAssets: listFlag(args["portrait-image-assets"]),
9465
9496
  logoAssets: listFlag(args["logo-assets"]),
9466
9497
  status: args.status
9467
9498
  });
@@ -9487,7 +9518,40 @@ var assetGroupsCommand = defineCommand30({
9487
9518
  });
9488
9519
  await stageUpdate("google.assetGroup.update", customerId, target, payload);
9489
9520
  }
9490
- })
9521
+ }),
9522
+ attach: defineCommand30({
9523
+ meta: {
9524
+ name: "attach",
9525
+ description: "Add one existing asset to an existing asset group. Start here to refresh a live PMax group's creatives: attach the new image, then `detach` the old one \u2014 never detach first."
9526
+ },
9527
+ args: {
9528
+ ...customerIdArg,
9529
+ ...fileArg,
9530
+ "asset-group-ref": { type: "string", description: "Asset group ref (g_temp_*, id, or resource name)" },
9531
+ "asset-ref": { type: "string", description: "Asset ref (g_temp_*, id, or resource name)" },
9532
+ "field-type": {
9533
+ type: "string",
9534
+ description: `Slot the asset fills: ${ASSET_GROUP_ASSET_FIELD_TYPES.join(" | ")}`
9535
+ }
9536
+ },
9537
+ run: async ({ args }) => {
9538
+ const customerId = requireCustomerId(args);
9539
+ const payload = mergePayload(loadJsonFileArg(args.file), {
9540
+ assetGroup: args["asset-group-ref"],
9541
+ asset: args["asset-ref"],
9542
+ fieldType: args["field-type"]
9543
+ });
9544
+ await stageCreate("google.assetGroupAsset.attach", customerId, payload, [
9545
+ "Swapping a creative? Stage the matching `asset-groups detach` for the image this one replaces \u2014 an attach alone grows the group instead of refreshing it.",
9546
+ "The field type must match the image's real ratio (1.91:1 \u2192 MARKETING_IMAGE, 1:1 \u2192 SQUARE_MARKETING_IMAGE, 4:5 \u2192 PORTRAIT_MARKETING_IMAGE); Google rejects a mismatch at publish.",
9547
+ "Slots have ceilings as well as minimums: BUSINESS_NAME and CALL_TO_ACTION_SELECTION hold 1, LOGO 5, LONG_HEADLINE and DESCRIPTION 5, HEADLINE 15, YOUTUBE_VIDEO 15, images 20. Adding to a full slot is rejected exactly like removing below an empty one, so count what the group already holds (`SELECT asset_group_asset.field_type FROM asset_group_asset WHERE asset_group.id = \u2026`) and stage the detach alongside when it is full."
9548
+ ]);
9549
+ }
9550
+ }),
9551
+ detach: statusCommand2("google.assetGroupAsset.detach", "asset group asset", [
9552
+ "Stage order is execution order \u2014 if you are replacing this image, its `asset-groups attach` must already be staged BEFORE this detach, or Google sees the group below its per-ratio minimum and rejects the removal.",
9553
+ "Pass the full composite resource name (customers/{cid}/assetGroupAssets/{assetGroupId}~{assetId}~{FIELD_TYPE}) \u2014 read it from `asset_group_asset.resource_name` via `ads google query`."
9554
+ ])
9491
9555
  }
9492
9556
  });
9493
9557
  function fileCreateCommand(name, kind, description, hints) {
@@ -9778,1905 +9842,1904 @@ Full guide: __tooling__/docs/tools/baker/ads-google.md`
9778
9842
  // src/commands/ads/linkedin/index.ts
9779
9843
  import { defineCommand as defineCommand51 } from "citty";
9780
9844
 
9781
- // src/commands/ads/linkedin/schemas.ts
9782
- registerSchema({
9783
- command: "ads.linkedin.accounts",
9784
- description: "List LinkedIn ad accounts in the company's connected scope. Pass --include-all to list every account the OAuth token can see.",
9785
- args: {
9786
- "include-all": { type: "boolean", description: "Ignore picker scope", required: false },
9787
- "no-cache": { type: "boolean", description: "Skip CLI-side cache", required: false },
9788
- "skip-cache": { type: "boolean", description: "Bypass server-side cache", required: false },
9789
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9790
- }
9791
- });
9792
- registerSchema({
9793
- command: "ads.linkedin.account",
9794
- description: "Single LinkedIn ad account detail (currency, status, type). Resolves the urn \u2192 {id, name, currency, status, type}.",
9795
- args: {
9796
- "account-id": { type: "string", description: "Numeric ID or urn:li:sponsoredAccount:N", required: false },
9797
- "account-urn": { type: "string", description: "Alias for --account-id", required: false },
9798
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9799
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9845
+ // src/commands/ads/linkedin/write-shared.ts
9846
+ import { createHash } from "crypto";
9847
+ import { readFileSync as readFileSync4 } from "fs";
9848
+
9849
+ // src/commands/ads/linkedin/shared.ts
9850
+ var DAY_MS2 = 864e5;
9851
+ function handleLinkedinError(err) {
9852
+ if (err instanceof ApiError) {
9853
+ if (err.code === "UNAUTHORIZED") {
9854
+ handleConnectionError("linkedin_ads", err.message);
9855
+ }
9856
+ if (err.code === "NOT_FOUND") {
9857
+ handleConnectionError("linkedin_ads", err.message);
9858
+ }
9859
+ if (isNoAccountsSelectedError(err.code, err.message)) {
9860
+ handleConnectionError("linkedin_ads", err.message);
9861
+ }
9862
+ const explanation = explainLinkedinError(err);
9863
+ const envelope = {
9864
+ ok: false,
9865
+ error: {
9866
+ code: err.code,
9867
+ message: err.message,
9868
+ fix: {
9869
+ action: explanation.action,
9870
+ explanation: explanation.message,
9871
+ correctedCommand: explanation.correctedCommand
9872
+ },
9873
+ retryable: err.code === "RATE_LIMITED" || err.code === "INTERNAL_ERROR"
9874
+ }
9875
+ };
9876
+ writeAdsJson(envelope);
9877
+ process.exit(1);
9800
9878
  }
9801
- });
9802
- registerSchema({
9803
- command: "ads.linkedin.campaign-groups",
9804
- description: "List LinkedIn campaign groups in an account. Default ACTIVE-only \u2014 pass --all-statuses to widen.",
9805
- args: {
9806
- "account-id": { type: "string", description: "Account ID", required: false },
9807
- "account-urn": { type: "string", description: "Alias for --account-id", required: false },
9808
- "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
9809
- statuses: { type: "string", description: "CSV of statuses", required: false },
9810
- limit: { type: "string", description: "Max rows (default 500)", required: false },
9811
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9812
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9879
+ const message = err instanceof Error ? err.message : "Unexpected error";
9880
+ writeAdsJson({ ok: false, error: { code: "NETWORK_ERROR", message } });
9881
+ process.exit(1);
9882
+ }
9883
+ function isNoAccountsSelectedError(code, message) {
9884
+ return code === "VALIDATION_ERROR" && /no linkedin ad accounts selected/i.test(message);
9885
+ }
9886
+ function explainLinkedinError(err) {
9887
+ const lower = err.message.toLowerCase();
9888
+ if (err.code === "VALIDATION_ERROR" && lower.includes("granularity") && lower.includes("daily")) {
9889
+ return {
9890
+ action: "retry_with_flag",
9891
+ message: "LinkedIn rejects DAILY granularity when pivoting on demographic dims (job-title, company, etc.). Re-run with --granularity ALL or MONTHLY.",
9892
+ correctedCommand: void 0
9893
+ };
9813
9894
  }
9814
- });
9815
- registerSchema({
9816
- command: "ads.linkedin.campaigns",
9817
- description: "List LinkedIn campaigns. Returns audit-relevant settings (audienceExpansionEnabled, offsiteDeliveryEnabled, optimizationTargetType, costType, budgets, runSchedule, targetingCriteria).",
9818
- args: {
9819
- "account-id": { type: "string", description: "Account ID", required: false },
9820
- "account-urn": { type: "string", description: "Alias", required: false },
9821
- "campaign-group-id": { type: "string", description: "Filter by campaign group", required: false },
9822
- "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
9823
- statuses: { type: "string", description: "CSV of statuses", required: false },
9824
- limit: { type: "string", description: "Max rows", required: false },
9825
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9826
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9895
+ if (err.code === "RATE_LIMITED") {
9896
+ return {
9897
+ action: "wait_and_retry",
9898
+ message: "Hit a LinkedIn throttle. The CLI already backs off automatically \u2014 if you see this you've exhausted the retry budget. Wait ~60s and retry."
9899
+ };
9827
9900
  }
9828
- });
9829
- registerSchema({
9830
- command: "ads.linkedin.creatives",
9831
- description: "List LinkedIn creatives. Compact by default: id, urn, campaign, status, review status, format, and the resolved destination (landingUrl, or postUrn for post-based ads). The opaque 'content' block is NOT returned unless you pass --full; --fields takes dotted paths for anything narrower.",
9832
- args: {
9833
- "account-id": { type: "string", description: "Account ID", required: false },
9834
- "account-urn": { type: "string", description: "Alias", required: false },
9835
- "campaign-id": { type: "string", description: "Filter by campaign", required: false },
9836
- "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
9837
- statuses: { type: "string", description: "CSV of statuses", required: false },
9838
- fields: {
9839
- type: "string",
9840
- description: "CSV of dotted paths to return instead of the compact row, e.g. id,content.textAd.landingPage",
9841
- required: false
9842
- },
9843
- full: {
9844
- type: "boolean",
9845
- description: "Return the complete raw records, including the opaque 'content' block",
9846
- required: false
9847
- },
9848
- limit: { type: "string", description: "Max rows", required: false },
9849
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9850
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9901
+ if (err.code === "INTERNAL_ERROR" && lower.includes("deprecat")) {
9902
+ return {
9903
+ action: "reject",
9904
+ message: "LinkedIn-Version header is deprecated. The backend constant LINKEDIN_API_VERSION needs to be bumped \u2014 file an issue or update the constant."
9905
+ };
9851
9906
  }
9852
- });
9853
- registerSchema({
9854
- command: "ads.linkedin.analytics",
9855
- description: "Performance reporting (3-axis: --level \xD7 --intent \xD7 --pivot). LinkedIn's superpower: pivot=job-title|company|industry|seniority. Demographic pivots auto-force granularity=ALL and surface a delayed-data warning.",
9856
- args: {
9857
- level: {
9858
- type: "string",
9859
- description: "account|campaign-group|campaign|creative",
9860
- required: false,
9861
- default: "account"
9862
- },
9863
- "account-id": { type: "string", description: "When level=account", required: false },
9864
- "account-urn": { type: "string", description: "Alias", required: false },
9865
- "campaign-group-id": { type: "string", description: "CSV when level=campaign-group", required: false },
9866
- "campaign-id": { type: "string", description: "CSV when level=campaign", required: false },
9867
- "creative-id": { type: "string", description: "CSV when level=creative", required: false },
9868
- ids: { type: "string", description: "Generic CSV alternative", required: false },
9869
- intent: {
9870
- type: "string",
9871
- description: "Field bundle",
9872
- required: false,
9873
- default: "baseline",
9874
- enum: [
9875
- "baseline",
9876
- "revenue",
9877
- "funnel",
9878
- "engagement",
9879
- "video",
9880
- "lead-gen",
9881
- "inmail",
9882
- "document",
9883
- "ranking",
9884
- "identity"
9885
- ]
9886
- },
9887
- pivot: {
9888
- type: "string",
9889
- description: "Demographic / firmographic dim",
9890
- required: false,
9891
- default: "none",
9892
- enum: [
9893
- "none",
9894
- "campaign",
9895
- "campaign-group",
9896
- "creative",
9897
- "company",
9898
- "account",
9899
- "conversion",
9900
- "job-title",
9901
- "job-function",
9902
- "seniority",
9903
- "industry",
9904
- "company-size",
9905
- "country",
9906
- "region",
9907
- "device",
9908
- "placement",
9909
- "serving-location",
9910
- "card-index",
9911
- "objective",
9912
- "conversation-node",
9913
- "conversation-node-button"
9914
- ]
9915
- },
9916
- metrics: { type: "string", description: "CSV metric override (escape hatch)", required: false },
9917
- start: { type: "string", description: "YYYY-MM-DD", required: false },
9918
- end: { type: "string", description: "YYYY-MM-DD", required: false },
9919
- "last-days": { type: "string", description: "Window (default 7)", required: false },
9920
- granularity: {
9921
- type: "string",
9922
- description: "DAILY|MONTHLY|YEARLY|ALL",
9923
- required: false,
9924
- default: "DAILY",
9925
- enum: ["DAILY", "MONTHLY", "YEARLY", "ALL"]
9926
- },
9927
- limit: { type: "string", description: "Max rows", required: false },
9928
- "no-sort": { type: "boolean", description: "Skip default sort", required: false },
9929
- "list-intents": { type: "boolean", description: "List intents and exit", required: false },
9930
- "list-pivots": { type: "boolean", description: "List pivots and exit", required: false },
9931
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9932
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9907
+ return { action: "reject", message: err.message };
9908
+ }
9909
+ var LI_ACCOUNT_ID_RE = /^(urn:li:sponsoredAccount:)?\d+$/;
9910
+ function resolveAccountIdArg2(args) {
9911
+ const fromArgs = args["account-id"] ?? args["account-urn"];
9912
+ const id = fromArgs || getEnv().BAKER_LINKEDIN_AD_ACCOUNT_ID;
9913
+ if (!id) {
9914
+ writeAdsJson({
9915
+ ok: false,
9916
+ error: {
9917
+ code: "MISSING_ACCOUNT_ID",
9918
+ message: "Pass --account-id (numeric) or --account-urn (urn:li:sponsoredAccount:N), or set BAKER_LINKEDIN_AD_ACCOUNT_ID. Run `baker ads linkedin accounts` to find IDs."
9919
+ }
9920
+ });
9921
+ process.exit(1);
9933
9922
  }
9934
- });
9935
- registerSchema({
9936
- command: "ads.linkedin.demographics",
9937
- description: "Sweep all firmographic pivots (job-title, company, industry, seniority, function, company-size) in one call. Returns top-N rows per pivot.",
9938
- args: {
9939
- "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
9940
- pivots: { type: "string", description: "CSV of pivots (default: all firmographic)", required: false },
9941
- "top-n": { type: "string", description: "Top rows per pivot (default 10)", required: false },
9942
- intent: { type: "string", description: "Field bundle", required: false, default: "baseline" },
9943
- start: { type: "string", description: "YYYY-MM-DD", required: false },
9944
- end: { type: "string", description: "YYYY-MM-DD", required: false },
9945
- "last-days": { type: "string", description: "Window (default 30)", required: false },
9946
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9947
- output: { type: "string", description: "json", required: false, default: "json" }
9923
+ if (!LI_ACCOUNT_ID_RE.test(id)) {
9924
+ writeAdsJson({
9925
+ ok: false,
9926
+ error: {
9927
+ code: "INVALID_ACCOUNT_ID",
9928
+ message: `Invalid LinkedIn account ID "${id}". Expected numeric or urn:li:sponsoredAccount:N.`
9929
+ }
9930
+ });
9931
+ process.exit(1);
9948
9932
  }
9949
- });
9950
- registerSchema({
9951
- command: "ads.linkedin.top-companies",
9952
- description: "Top companies whose employees saw / clicked / converted. Wrapper for analytics --pivot company. The ABM feedback loop.",
9953
- args: {
9954
- "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
9955
- intent: { type: "string", description: "Field bundle (default baseline)", required: false },
9956
- "top-n": { type: "string", description: "Top rows by impressions (default 25)", required: false },
9957
- start: { type: "string", description: "YYYY-MM-DD", required: false },
9958
- end: { type: "string", description: "YYYY-MM-DD", required: false },
9959
- "last-days": { type: "string", description: "Window (default 30)", required: false },
9960
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9961
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9933
+ return id.startsWith("urn:") ? id.split(":").pop() ?? id : id;
9934
+ }
9935
+ function todayIso2() {
9936
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
9937
+ }
9938
+ function daysAgoIso2(days) {
9939
+ return new Date(Date.now() - days * DAY_MS2).toISOString().slice(0, 10);
9940
+ }
9941
+ function csvOrJson2(args) {
9942
+ return args.output ?? "json";
9943
+ }
9944
+ function resolveStatusFilter(args) {
9945
+ if (args["all-statuses"]) {
9946
+ return void 0;
9962
9947
  }
9963
- });
9964
- registerSchema({
9965
- command: "ads.linkedin.facets.list",
9966
- description: "List every targeting facet LinkedIn supports (industries, seniorities, titles, employers, growthRate, etc).",
9967
- args: {
9968
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9969
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9948
+ const explicit = args.statuses;
9949
+ if (explicit) {
9950
+ return explicit.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
9970
9951
  }
9971
- });
9972
- registerSchema({
9973
- command: "ads.linkedin.facets.values",
9974
- description: "Look up entity values for a facet \u2014 full list (q=adTargetingFacet) or typeahead search (q=typeahead, with --query).",
9975
- args: {
9976
- facet: { type: "string", description: "Facet name (industries) or URN", required: true },
9977
- query: { type: "string", description: "Typeahead query (auto-switches finder)", required: false },
9978
- finder: { type: "string", description: "adTargetingFacet|typeahead|similarEntities", required: false },
9979
- locale: { type: "string", description: "Locale (default en_US)", required: false },
9980
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9981
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9952
+ return ["ACTIVE"];
9953
+ }
9954
+
9955
+ // src/commands/ads/linkedin/write-shared.ts
9956
+ var TOTAL_BUDGET_HELP = "Lifetime budget \u2014 on something already running it must exceed spend to date, or LinkedIn rejects it";
9957
+ function bareAccountId(args) {
9958
+ const raw = resolveAccountIdArg2(args);
9959
+ return raw.replace(/^urn:li:sponsoredAccount:/, "").replace(/^sponsoredAccount:/, "");
9960
+ }
9961
+ function failWriteValidation2(message) {
9962
+ writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
9963
+ process.exit(1);
9964
+ }
9965
+ function loadJsonFileArg2(path28) {
9966
+ if (typeof path28 !== "string" || path28.length === 0) {
9967
+ return {};
9982
9968
  }
9983
- });
9984
- registerSchema({
9985
- command: "ads.linkedin.audience-size",
9986
- description: "Estimate audience size for a targetingCriteria payload. Returns total + active + playbook \xA704 sweet-spot warnings.",
9987
- args: {
9988
- "account-id": { type: "string", description: "Account ID", required: false },
9989
- "account-urn": { type: "string", description: "Alias", required: false },
9990
- targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
9991
- "targeting-file": { type: "string", description: "Path to JSON file", required: false },
9992
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
9993
- output: { type: "string", description: "json", required: false, default: "json" }
9969
+ try {
9970
+ const parsed = JSON.parse(readFileSync4(path28, "utf8"));
9971
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
9972
+ failWriteValidation2(`${path28} must contain a JSON object`);
9973
+ }
9974
+ return parsed;
9975
+ } catch (err) {
9976
+ if (err instanceof SyntaxError) {
9977
+ failWriteValidation2(`${path28} is not valid JSON: ${err.message}`);
9978
+ }
9979
+ throw err;
9994
9980
  }
9995
- });
9996
- registerSchema({
9997
- command: "ads.linkedin.bid-pricing",
9998
- description: "LinkedIn's suggested bid range + playbook \xA706 floor (2/3 of suggested). Inputs: targeting + objective + cost type.",
9999
- args: {
10000
- "account-id": { type: "string", description: "Account ID", required: false },
10001
- "account-urn": { type: "string", description: "Alias", required: false },
10002
- objective: {
10003
- type: "string",
10004
- description: "Objective type (WEBSITE_CONVERSION, LEAD_GENERATION, etc.)",
10005
- required: true
10006
- },
10007
- "cost-type": { type: "string", description: "CPC|CPM|CPV|CPS", required: true, enum: ["CPC", "CPM", "CPV", "CPS"] },
10008
- targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
10009
- "targeting-file": { type: "string", description: "Path to JSON file", required: false },
10010
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10011
- output: { type: "string", description: "json", required: false, default: "json" }
9981
+ }
9982
+ function mergePayload2(file, flags) {
9983
+ const merged = { ...file };
9984
+ for (const [key, value] of Object.entries(flags)) {
9985
+ if (value !== void 0) {
9986
+ merged[key] = value;
9987
+ }
10012
9988
  }
10013
- });
10014
- registerSchema({
10015
- command: "ads.linkedin.forecast",
10016
- description: "Forecast reach + impressions + clicks + spend for a hypothetical campaign. Useful for \xA714 greenfield planning.",
10017
- args: {
10018
- "account-id": { type: "string", description: "Account ID", required: false },
10019
- "account-urn": { type: "string", description: "Alias", required: false },
10020
- objective: { type: "string", description: "Objective type", required: true },
10021
- "cost-type": { type: "string", description: "CPC|CPM|CPV|CPS", required: true },
10022
- "daily-budget": { type: "string", description: "e.g. '200 USD'", required: false },
10023
- "total-budget": { type: "string", description: "e.g. '6000 USD'", required: false },
10024
- bid: { type: "string", description: "Bid e.g. '8 USD'", required: false },
10025
- targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
10026
- "targeting-file": { type: "string", description: "Path to JSON file", required: false },
10027
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10028
- output: { type: "string", description: "json", required: false, default: "json" }
9989
+ return merged;
9990
+ }
9991
+ function loadPatchArg2(args) {
9992
+ const file = typeof args.file === "string" && args.file.length > 0 ? args.file : void 0;
9993
+ const inline = typeof args.patch === "string" && args.patch.length > 0 ? args.patch : void 0;
9994
+ if (Boolean(file) === Boolean(inline)) {
9995
+ failWriteValidation2("pass exactly one of --file <patch.json> or --patch '<json>'");
10029
9996
  }
10030
- });
10031
- registerSchema({
10032
- command: "ads.linkedin.leads",
10033
- description: "List Lead Gen Form responses (90-day LinkedIn retention \u2014 sync to CRM via this endpoint). Filter by --form-id, --campaign-id, or --since-days.",
10034
- args: {
10035
- "account-id": { type: "string", description: "Account ID", required: false },
10036
- "account-urn": { type: "string", description: "Alias", required: false },
10037
- "form-id": { type: "string", description: "Filter by form ID", required: false },
10038
- "campaign-id": { type: "string", description: "Filter by campaign", required: false },
10039
- "since-days": { type: "string", description: "Last N days", required: false },
10040
- "since-ms": { type: "string", description: "Epoch ms (alternative to --since-days)", required: false },
10041
- limit: { type: "string", description: "Max rows", required: false },
10042
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10043
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
9997
+ if (file) {
9998
+ return loadJsonFileArg2(file);
10044
9999
  }
10045
- });
10046
- registerSchema({
10047
- command: "ads.linkedin.conversions.list",
10048
- description: "List conversion rules (Insight Tag + Conversions API).",
10049
- args: {
10050
- "account-id": { type: "string", description: "Account ID", required: false },
10051
- "account-urn": { type: "string", description: "Alias", required: false },
10052
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10053
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10000
+ try {
10001
+ const parsed = JSON.parse(inline);
10002
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
10003
+ failWriteValidation2("--patch must be a JSON object");
10004
+ }
10005
+ return parsed;
10006
+ } catch (err) {
10007
+ if (err instanceof SyntaxError) {
10008
+ failWriteValidation2(`--patch is not valid JSON: ${err.message}`);
10009
+ }
10010
+ throw err;
10054
10011
  }
10055
- });
10056
- registerSchema({
10057
- command: "ads.linkedin.conversions.health",
10058
- description: "Playbook \xA707 5-point health check: rules enabled? lead/purchase event? CAPI fired in 7d? view-through \u22647d? lead de-dup correct?",
10059
- args: {
10060
- "account-id": { type: "string", description: "Account ID", required: false },
10061
- "account-urn": { type: "string", description: "Alias", required: false },
10062
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10063
- output: { type: "string", description: "json", required: false, default: "json" }
10012
+ }
10013
+ function parseMoneyFlag(amount, currency, flag) {
10014
+ if (amount === void 0 || amount === null || amount === "") {
10015
+ return void 0;
10064
10016
  }
10065
- });
10066
- registerSchema({
10067
- command: "ads.linkedin.conversation",
10068
- description: "Per-button click rates inside Sponsored Messaging / Conversation Ads. Wraps analytics --pivot conversation-node-button.",
10069
- args: {
10070
- "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
10071
- start: { type: "string", description: "YYYY-MM-DD", required: false },
10072
- end: { type: "string", description: "YYYY-MM-DD", required: false },
10073
- "last-days": { type: "string", description: "Window (default 30)", required: false },
10074
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10075
- output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10017
+ if (currency === void 0 || currency === null || currency === "") {
10018
+ return { amount: String(amount) };
10076
10019
  }
10077
- });
10078
- registerSchema({
10079
- command: "ads.linkedin.audit",
10080
- description: "Run a 30+ check playbook audit (Settings, Tracking, Audience, Campaigns, Creative, Performance, Bidding, Compliance, Hygiene). Each finding: {id, area, check, status, severity, evidence, fix} with playbook citations. --format md renders a deliverable-ready table.",
10081
- args: {
10082
- "account-id": { type: "string", description: "Account ID", required: false },
10083
- "account-urn": { type: "string", description: "Alias", required: false },
10084
- "campaign-id": { type: "string", description: "Narrow to a single campaign", required: false },
10085
- "window-days": { type: "string", description: "Performance lookback (default 30)", required: false },
10086
- severity: { type: "string", description: "CSV severity filter (critical,high,medium,low)", required: false },
10087
- area: { type: "string", description: "CSV area filter", required: false },
10088
- format: { type: "string", description: "json | md", required: false, default: "json" },
10089
- "skip-cache": { type: "boolean", description: "Bypass cache", required: false }
10020
+ if (typeof currency !== "string" || currency.length !== 3) {
10021
+ failWriteValidation2(`${flag} got an invalid --currency (expected a 3-letter code like EUR)`);
10090
10022
  }
10091
- });
10092
- var writeAccountArgs = {
10093
- "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N", required: false },
10094
- "account-urn": { type: "string", description: "Alias for --account-id", required: false }
10095
- };
10096
- registerSchema({
10097
- command: "ads.linkedin.campaign-groups.create",
10098
- description: "Stage a new campaign group (staged until publish; review with `baker ads linkedin draft`). Returns a li_temp_* ref that later `campaigns create --group` calls can reference.",
10099
- args: {
10100
- ...writeAccountArgs,
10101
- name: { type: "string", description: "Name (\u2264100 chars)", required: true },
10102
- start: { type: "string", description: "Run schedule start (ISO date or epoch ms)", required: true },
10103
- end: { type: "string", description: "Run schedule end", required: false },
10104
- "total-budget": { type: "string", description: "Lifetime budget amount", required: false },
10105
- currency: {
10106
- type: "string",
10107
- description: "3-letter currency code (defaults to the ad account currency)",
10108
- required: false
10109
- },
10110
- status: { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10111
- file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10023
+ return { amount: String(amount), currencyCode: currency.toUpperCase() };
10024
+ }
10025
+ function parseDateFlag(value, flag) {
10026
+ if (value === void 0 || value === null || value === "") {
10027
+ return void 0;
10112
10028
  }
10113
- });
10114
- registerSchema({
10115
- command: "ads.linkedin.campaign-groups.update",
10116
- description: "Stage changes to an existing campaign group (name, schedule, budget, status). Captures a before-snapshot for the dashboard diff. Pass a li_temp_* ref to AMEND a group staged in this chat. Applies on chat publish.",
10117
- args: {
10118
- id: {
10119
- type: "positional",
10120
- description: "Campaign group id/URN, or li_temp_* ref staged in this chat",
10121
- required: true
10122
- },
10123
- ...writeAccountArgs,
10124
- name: { type: "string", description: "New name", required: false },
10125
- start: { type: "string", description: "New schedule start", required: false },
10126
- end: { type: "string", description: "New schedule end", required: false },
10127
- "total-budget": { type: "string", description: "New lifetime budget", required: false },
10128
- currency: {
10129
- type: "string",
10130
- description: "3-letter currency code (defaults to the ad account currency)",
10131
- required: false
10132
- },
10133
- status: { type: "string", description: "ACTIVE|PAUSED|ARCHIVED", required: false },
10134
- file: { type: "string", description: "JSON file with fields to change", required: false }
10029
+ const raw = String(value);
10030
+ if (/^\d{10,}$/.test(raw)) {
10031
+ return Number(raw);
10135
10032
  }
10136
- });
10137
- registerSchema({
10138
- command: "ads.linkedin.campaigns.create",
10139
- description: "Stage a new campaign. --group accepts a real id/URN or a li_temp_* ref staged earlier in this chat. Targeting goes in --targeting-file as LinkedIn criteria: {include:{and:[{or:{'urn:li:adTargetingFacet:locations':['urn:li:geo:\u2026']}}]}} \u2014 find facet values with `baker ads linkedin facets values`. Playbook defaults: manual bid (--bid), audience expansion off, LAN off. Staged until publish.",
10140
- args: {
10141
- ...writeAccountArgs,
10142
- name: { type: "string", description: "Name (\u2264255 chars)", required: true },
10143
- group: { type: "string", description: "Campaign group id/URN or li_temp_* ref", required: true },
10144
- type: { type: "string", description: "SPONSORED_UPDATES|TEXT_AD|SPONSORED_INMAILS|DYNAMIC", required: true },
10145
- objective: { type: "string", description: "LEAD_GENERATION|WEBSITE_VISIT|BRAND_AWARENESS|\u2026", required: false },
10146
- "cost-type": { type: "string", description: "CPC|CPM|CPV", required: true },
10147
- bid: { type: "string", description: "Manual bid amount (unitCost)", required: false },
10148
- "daily-budget": { type: "string", description: "Daily budget amount", required: false },
10149
- "total-budget": { type: "string", description: "Lifetime budget amount", required: false },
10150
- currency: {
10151
- type: "string",
10152
- description: "3-letter currency code (defaults to the ad account currency)",
10153
- required: false
10154
- },
10155
- start: { type: "string", description: "Run schedule start", required: false },
10156
- end: { type: "string", description: "Run schedule end", required: false },
10157
- locale: { type: "string", description: "Profile locale like en_US (default en_US)", required: false },
10158
- "targeting-file": { type: "string", description: "JSON file with targetingCriteria", required: true },
10159
- "audience-expansion": { type: "string", description: "on|off (playbook: off; default off)", required: false },
10160
- lan: {
10161
- type: "string",
10162
- description: "LinkedIn Audience Network on|off (playbook: off; default off)",
10163
- required: false
10164
- },
10165
- ctv: { type: "string", description: "Connected TV on|off (default off)", required: false },
10166
- "creative-selection": { type: "string", description: "OPTIMIZED|ROUND_ROBIN", required: false },
10167
- "associated-entity": { type: "string", description: "Organization URN", required: false },
10168
- status: { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10169
- file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10033
+ const parsed = Date.parse(raw);
10034
+ if (Number.isNaN(parsed)) {
10035
+ failWriteValidation2(`${flag} must be an ISO date (2026-07-10) or epoch milliseconds`);
10170
10036
  }
10171
- });
10172
- registerSchema({
10173
- command: "ads.linkedin.campaigns.update",
10174
- description: "Stage changes to an existing campaign \u2014 budget, bid, targeting, schedule, flags, status. Only pass what changes; a before-snapshot powers the dashboard diff. Pass a li_temp_* ref to AMEND a campaign staged in this chat (fields merge into the staged create). Applies on chat publish.",
10175
- args: {
10176
- id: { type: "positional", description: "Campaign id/URN, or li_temp_* ref staged in this chat", required: true },
10177
- ...writeAccountArgs,
10178
- name: { type: "string", description: "New name", required: false },
10179
- objective: { type: "string", description: "New objective", required: false },
10180
- "cost-type": { type: "string", description: "CPC|CPM|CPV", required: false },
10181
- bid: { type: "string", description: "New manual bid", required: false },
10182
- "daily-budget": { type: "string", description: "New daily budget", required: false },
10183
- "total-budget": { type: "string", description: "New lifetime budget", required: false },
10184
- currency: {
10185
- type: "string",
10186
- description: "3-letter currency code (defaults to the ad account currency)",
10187
- required: false
10188
- },
10189
- start: { type: "string", description: "New schedule start", required: false },
10190
- end: { type: "string", description: "New schedule end", required: false },
10191
- "targeting-file": { type: "string", description: "JSON file with replacement targetingCriteria", required: false },
10192
- "audience-expansion": { type: "string", description: "on|off", required: false },
10193
- lan: { type: "string", description: "on|off", required: false },
10194
- ctv: { type: "string", description: "on|off", required: false },
10195
- "creative-selection": { type: "string", description: "OPTIMIZED|ROUND_ROBIN", required: false },
10196
- status: { type: "string", description: "ACTIVE|PAUSED|ARCHIVED", required: false },
10197
- file: { type: "string", description: "JSON file with fields to change", required: false }
10037
+ return parsed;
10038
+ }
10039
+ function parseOnOffFlag(value, flag) {
10040
+ if (value === void 0 || value === null || value === "") {
10041
+ return void 0;
10198
10042
  }
10199
- });
10200
- registerSchema({
10201
- command: "ads.linkedin.campaigns.url-params",
10202
- description: "Start here for UTMs: stage the URL tracking parameters on an ad set. LinkedIn appends them to the link of EVERY ad in the ad set, including ads already running, so this is the level to set campaign-wide UTMs at \u2014 editing each ad's landing URL instead risks the same key landing twice. --param takes fixed values (repeatable or &-joined); --dynamic takes a value LinkedIn fills in per ad (ACCOUNT_ID, ACCOUNT_NAME, CAMPAIGN_GROUP_ID, CAMPAIGN_GROUP_NAME, CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, CREATIVE_NAME). Applies on chat publish; message and conversation ads are unaffected.",
10203
- args: {
10204
- id: { type: "positional", description: "Ad set (campaign) id or URN", required: true },
10205
- ...writeAccountArgs,
10206
- param: {
10207
- type: "string",
10208
- description: 'Fixed key=value, repeatable or &-joined \u2014 e.g. "utm_source=linkedin&utm_medium=paid-social"',
10209
- required: false
10210
- },
10211
- dynamic: {
10212
- type: "string",
10213
- description: "key=PLACEHOLDER filled in per ad, repeatable \u2014 e.g. utm_campaign=CAMPAIGN_NAME",
10214
- required: false
10215
- },
10216
- clear: { type: "boolean", description: "Remove every tracking parameter from this ad set", required: false },
10217
- file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10043
+ const raw = String(value).toLowerCase();
10044
+ if (raw === "on" || raw === "true") {
10045
+ return true;
10218
10046
  }
10219
- });
10220
- var statusSugarArgs = {
10221
- id: {
10222
- type: "positional",
10223
- description: "Entity id or URN \u2014 comma-separate several to change them all in one call",
10224
- required: false
10225
- },
10226
- "ids-file": { type: "string", description: "File of ids, one per line or comma-separated", required: false },
10227
- ...writeAccountArgs
10228
- };
10229
- var MULTI_ID_NOTE = "Takes one id or many: comma-separate the ids, or pass --ids-file. Several ids stage together as one set \u2014 all of them or none. The list form is for real ids/URNs only: a li_temp_* ref AMENDS what this chat already staged, and an amend must be passed on its own.";
10230
- registerSchema({
10231
- command: "ads.linkedin.campaigns.pause",
10232
- description: `Stage pausing a campaign (status \u2192 PAUSED on publish). ${MULTI_ID_NOTE}`,
10233
- args: statusSugarArgs
10234
- });
10235
- registerSchema({
10236
- command: "ads.linkedin.campaigns.resume",
10237
- description: `Stage resuming a paused campaign (status \u2192 ACTIVE on publish). ${MULTI_ID_NOTE}`,
10238
- args: statusSugarArgs
10239
- });
10240
- registerSchema({
10241
- command: "ads.linkedin.campaigns.archive",
10242
- description: `Stage archiving a campaign (status \u2192 ARCHIVED on publish). ${MULTI_ID_NOTE}`,
10243
- args: statusSugarArgs
10244
- });
10245
- registerSchema({
10246
- command: "ads.linkedin.campaign-groups.pause",
10247
- description: `Stage pausing a campaign group. ${MULTI_ID_NOTE}`,
10248
- args: statusSugarArgs
10249
- });
10250
- registerSchema({
10251
- command: "ads.linkedin.campaign-groups.resume",
10252
- description: `Stage resuming a campaign group. ${MULTI_ID_NOTE}`,
10253
- args: statusSugarArgs
10254
- });
10255
- registerSchema({
10256
- command: "ads.linkedin.creatives.pause",
10257
- description: `Stage pausing a creative (intendedStatus \u2192 PAUSED on publish). Playbook early-kill rule: >30 impressions with 0 clicks. ${MULTI_ID_NOTE}`,
10258
- args: statusSugarArgs
10259
- });
10260
- registerSchema({
10261
- command: "ads.linkedin.creatives.resume",
10262
- description: `Stage resuming a paused creative. ${MULTI_ID_NOTE}`,
10263
- args: statusSugarArgs
10264
- });
10265
- var duplicateArgs = {
10266
- id: { type: "positional", description: "Source id or URN", required: true },
10267
- ...writeAccountArgs,
10268
- name: { type: "string", description: "Name for the copy", required: false },
10269
- file: { type: "string", description: "JSON file with field overrides for the copy", required: false }
10270
- };
10271
- registerSchema({
10272
- command: "ads.linkedin.campaigns.duplicate",
10273
- description: "Stage a copy of an existing campaign as a new DRAFT create (playbook: duplicate, never link-to-original). Reads the source at stage time; override fields via --name/--file.",
10274
- args: duplicateArgs
10275
- });
10276
- registerSchema({
10277
- command: "ads.linkedin.campaign-groups.duplicate",
10278
- description: "Stage a copy of an existing campaign group as a new DRAFT create.",
10279
- args: duplicateArgs
10280
- });
10281
- registerSchema({
10282
- command: "ads.linkedin.creatives.duplicate",
10283
- description: "Stage a copy of an existing ad, optionally with new content. Direct-content ads (text/spotlight/follower/jobs) copy field-by-field, so a copy is how their immutable content changes (they can also update in place via creatives.update). Post-based ads re-sponsor the SAME post \u2014 the post owns the copy, media and destination, so content flags are rejected here: stage creatives.create with the new content instead, then pause the old ad. --replace stages pausing the original, gated on the new ad publishing successfully, and the copy inherits the source's status.",
10284
- args: {
10285
- id: { type: "positional", description: "Source creative id or URN", required: true },
10286
- ...writeAccountArgs,
10287
- campaign: { type: "string", description: "Stage the copy under a different ad set", required: false },
10288
- "intended-status": { type: "string", description: "Status for the copy (default DRAFT)", required: false },
10289
- replace: { type: "boolean", description: "Also pause the original once the new ad publishes", required: false },
10290
- headline: { type: "string", description: "New headline", required: false },
10291
- description: { type: "string", description: "New description (text/spotlight)", required: false },
10292
- intro: { type: "string", description: "New commentary / intro text", required: false },
10293
- "landing-url": { type: "string", description: "New https destination URL", required: false },
10294
- cta: { type: "string", description: "New CTA", required: false },
10295
- "cta-label": { type: "string", description: "Spotlight custom CTA label", required: false },
10296
- "image-id": { type: "string", description: "Baker image library id", required: false },
10297
- "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10298
- "video-id": { type: "string", description: "Baker video library id", required: false },
10299
- "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10300
- "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10301
- title: { type: "string", description: "Media title", required: false },
10302
- file: { type: "string", description: "JSON file with field overrides for the copy", required: false }
10047
+ if (raw === "off" || raw === "false") {
10048
+ return false;
10303
10049
  }
10304
- });
10305
- registerSchema({
10306
- command: "ads.linkedin.creatives.create",
10307
- description: "Stage a new creative (ad). Formats: image|video|text|spotlight|follower|document|carousel|conversation|tla|jobs|event|article. Media: --image-id/--video-id reference the Baker library (`baker images`/`baker videos`) and upload to LinkedIn at publish; --image-urn/--video-urn reference media already on LinkedIn. Limits: headline \u226470 (text ads \u226425), intro soft-truncates at 600 chars, landing URL must be https. Complex formats take --file with the full content object; conversation ads take --file with the message flow ({message: {subject, body (\u2264500), senderName?, buttons \u22645 \xD7 {label \u226425, type NESTED|LANDING_PAGE, nestedMessage?|landingPageUrl?}}}, \u226425 messages). Staged until publish.",
10308
- args: {
10309
- ...writeAccountArgs,
10310
- campaign: { type: "string", description: "Campaign id/URN or li_temp_* ref", required: true },
10311
- format: {
10312
- type: "string",
10313
- description: "image|video|text|spotlight|follower|document|carousel|conversation|tla|jobs|event|article",
10314
- required: true
10315
- },
10316
- "image-id": { type: "string", description: "Baker image library id", required: false },
10317
- "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10318
- "video-id": { type: "string", description: "Baker video library id", required: false },
10319
- "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10320
- "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10321
- intro: { type: "string", description: "Commentary / intro text", required: false },
10322
- headline: { type: "string", description: "Headline", required: false },
10323
- description: { type: "string", description: "Description (text/spotlight)", required: false },
10324
- "landing-url": { type: "string", description: "https destination URL", required: false },
10325
- cta: { type: "string", description: "LEARN_MORE|REQUEST_DEMO|SIGN_UP|REGISTER|DOWNLOAD|\u2026", required: false },
10326
- "cta-label": { type: "string", description: "Spotlight CTA label (\u226418 chars)", required: false },
10327
- "post-urn": { type: "string", description: "TLA: post to sponsor (urn:li:share|ugcPost:*)", required: false },
10328
- "event-urn": {
10329
- type: "string",
10330
- description: "Event ads: the LinkedIn event to sponsor (urn:li:event:*)",
10331
- required: false
10332
- },
10333
- "article-url": {
10334
- type: "string",
10335
- description: "Article ads: https URL of the article/newsletter to sponsor",
10336
- required: false
10337
- },
10338
- "thumbnail-image-id": {
10339
- type: "string",
10340
- description: "Article ads: Baker image id for the link thumbnail",
10341
- required: false
10342
- },
10343
- "thumbnail-urn": { type: "string", description: "Article ads: existing urn:li:image:* thumbnail", required: false },
10344
- "intended-status": { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10345
- title: { type: "string", description: "Media title (image/video) or article title", required: false },
10346
- file: { type: "string", description: "JSON file with the full content object", required: false }
10050
+ failWriteValidation2(`${flag} must be on|off`);
10051
+ }
10052
+ function parseLocaleFlag(value) {
10053
+ if (value === void 0 || value === null || value === "") {
10054
+ return void 0;
10347
10055
  }
10348
- });
10349
- registerSchema({
10350
- command: "ads.linkedin.creatives.update",
10351
- description: `Stage changes to an existing creative. Direct-content ads (text/spotlight/follower/jobs) update copy/media/URL IN PLACE \u2014 pass only the changed fields (headline, description, landing-url, image-id, \u2026); the ad's current content carries over. Post-based ads (image/video/carousel/\u2026) sponsor a post that owns the copy, media and destination \u2014 neither this command nor creatives.duplicate can change it; stage creatives.create with the new content, then pause the old ad. Passing a li_temp_* ref AMENDS the creative staged in this chat instead. ${MULTI_ID_NOTE} Applies on chat publish.`,
10352
- args: {
10353
- id: {
10354
- type: "positional",
10355
- description: "Creative id/URN \u2014 comma-separate several. A li_temp_* ref amends what this chat staged and must be passed on its own.",
10356
- required: false
10357
- },
10358
- "ids-file": {
10359
- type: "string",
10360
- description: "File of creative ids, one per line or comma-separated",
10361
- required: false
10362
- },
10363
- ...writeAccountArgs,
10364
- "intended-status": { type: "string", description: "ACTIVE|PAUSED|ARCHIVED|DRAFT", required: false },
10365
- name: { type: "string", description: "New creative name", required: false },
10366
- campaign: {
10367
- type: "string",
10368
- description: "Re-parent to a different ad set (id/URN or li_temp_*) \u2014 staged creatives only",
10369
- required: false
10370
- },
10371
- headline: { type: "string", description: "New headline", required: false },
10372
- intro: { type: "string", description: "Commentary / intro text", required: false },
10373
- description: { type: "string", description: "Description (text/spotlight)", required: false },
10374
- "landing-url": { type: "string", description: "New https destination URL", required: false },
10375
- cta: { type: "string", description: "New CTA", required: false },
10376
- "cta-label": { type: "string", description: "Spotlight custom CTA label", required: false },
10377
- "image-id": { type: "string", description: "Baker image library id", required: false },
10378
- "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10379
- "video-id": { type: "string", description: "Baker video library id", required: false },
10380
- "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10381
- "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10382
- "thumbnail-image-id": {
10383
- type: "string",
10384
- description: "Baker image id for the article thumbnail",
10385
- required: false
10386
- },
10387
- "thumbnail-urn": { type: "string", description: "Existing urn:li:image:* thumbnail", required: false },
10388
- title: { type: "string", description: "Media or article title", required: false },
10389
- file: { type: "string", description: "JSON file with fields to change; flags override file keys", required: false }
10390
- }
10391
- });
10392
- registerSchema({
10393
- command: "ads.linkedin.audiences.create",
10394
- description: "Stage a new matched audience. company-list/user-list take --list-file (CSV with header; companyName,domain for companies; email column is SHA-256 hashed locally). LinkedIn needs 300+ MATCHED members to serve \u2014 upload well above that. retargeting/engagement configs go in --file. Staged until publish.",
10395
- args: {
10396
- ...writeAccountArgs,
10397
- name: { type: "string", description: "Audience name (\u2264100 chars)", required: true },
10398
- type: { type: "string", description: "company-list|user-list|retargeting|engagement", required: true },
10399
- "list-file": { type: "string", description: "CSV file with the list rows", required: false },
10400
- file: { type: "string", description: "JSON raw source config for retargeting/engagement", required: false }
10056
+ const match = /^([a-z]{2})[_-]([A-Za-z]{2})$/.exec(String(value));
10057
+ if (!match) {
10058
+ failWriteValidation2("--locale must look like en_US (language_COUNTRY)");
10401
10059
  }
10402
- });
10403
- registerSchema({
10404
- command: "ads.linkedin.audiences.upload",
10405
- description: "Stage adding rows to an existing audience segment (or a li_temp_* segment staged in this chat). Same CSV rules as audiences.create.",
10406
- args: {
10407
- id: { type: "positional", description: "Segment URN/id or li_temp_* ref", required: true },
10408
- ...writeAccountArgs,
10409
- "list-file": { type: "string", description: "CSV file with the rows to add", required: true }
10060
+ return { language: match[1], country: match[2].toUpperCase() };
10061
+ }
10062
+ function loadTargetingFileArg(path28) {
10063
+ if (typeof path28 !== "string" || path28.length === 0) {
10064
+ return void 0;
10410
10065
  }
10411
- });
10412
- registerSchema({
10413
- command: "ads.linkedin.conversions.create",
10414
- description: "Stage a new conversion rule. Playbook defaults: --post-click-window 30 --view-window 7, counting ONE_TIME_EACH_MEMBER for leads. Staged until publish.",
10415
- args: {
10416
- ...writeAccountArgs,
10417
- name: { type: "string", description: "Rule name", required: true },
10418
- type: {
10419
- type: "string",
10420
- description: "LEAD|SIGN_UP|DOWNLOAD|PURCHASE|KEY_PAGE_VIEW|ADD_TO_CART|INSTALL|OTHER",
10421
- required: true
10422
- },
10423
- method: { type: "string", description: "INSIGHT_TAG|CONVERSIONS_API", required: true },
10424
- "post-click-window": { type: "string", description: "1|7|30|90 days", required: false },
10425
- "view-window": { type: "string", description: "1|7|30 days", required: false },
10426
- "attribution-type": {
10427
- type: "string",
10428
- description: "LAST_TOUCH_BY_CAMPAIGN|LAST_TOUCH_BY_CONVERSION",
10429
- required: false
10430
- },
10431
- "associate-all-campaigns": {
10432
- type: "boolean",
10433
- description: "Auto-associate with active campaigns",
10434
- required: false
10435
- },
10436
- file: { type: "string", description: "JSON payload file (e.g. urlMatchRuleExpression)", required: false }
10066
+ const parsed = loadJsonFileArg2(path28);
10067
+ const criteria = parsed.targetingCriteria ?? parsed;
10068
+ if (!criteria.include) {
10069
+ failWriteValidation2(
10070
+ `${path28} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
10071
+ );
10437
10072
  }
10438
- });
10439
- registerSchema({
10440
- command: "ads.linkedin.conversions.update",
10441
- description: "Stage changes to a conversion rule (windows, attribution, enabled, name). Applies on chat publish.",
10442
- args: {
10443
- id: { type: "positional", description: "Conversion rule id or URN", required: true },
10444
- ...writeAccountArgs,
10445
- name: { type: "string", description: "New name", required: false },
10446
- "post-click-window": { type: "string", description: "1|7|30|90 days", required: false },
10447
- "view-window": { type: "string", description: "1|7|30 days", required: false },
10448
- "attribution-type": { type: "string", description: "New attribution type", required: false },
10449
- enabled: { type: "string", description: "on|off", required: false },
10450
- file: { type: "string", description: "JSON file with fields to change", required: false }
10073
+ return criteria;
10074
+ }
10075
+ function sha256Lower(value) {
10076
+ return createHash("sha256").update(value.trim().toLowerCase()).digest("hex");
10077
+ }
10078
+ function parseCsvLine(line) {
10079
+ const cells = [];
10080
+ let current = "";
10081
+ let inQuotes = false;
10082
+ for (let i = 0; i < line.length; i++) {
10083
+ const char = line[i];
10084
+ if (inQuotes) {
10085
+ if (char === '"' && line[i + 1] === '"') {
10086
+ current += '"';
10087
+ i++;
10088
+ } else if (char === '"') {
10089
+ inQuotes = false;
10090
+ } else {
10091
+ current += char;
10092
+ }
10093
+ } else if (char === '"') {
10094
+ inQuotes = true;
10095
+ } else if (char === ",") {
10096
+ cells.push(current);
10097
+ current = "";
10098
+ } else {
10099
+ current += char;
10100
+ }
10451
10101
  }
10452
- });
10453
- registerSchema({
10454
- command: "ads.linkedin.lead-forms.create",
10455
- description: "Stage a new Lead Gen Form from a JSON file: {name, headline \u226460, description? \u2264160, privacyPolicyUrl, questions[] \u226412 (playbook: \u22644 for completion), thankYou?, legalDisclaimer?}. Staged until publish.",
10456
- args: {
10457
- ...writeAccountArgs,
10458
- file: { type: "string", description: "JSON form definition file", required: true },
10459
- name: { type: "string", description: "Override the form name", required: false }
10102
+ cells.push(current);
10103
+ return cells.map((cell) => cell.trim());
10104
+ }
10105
+ function parseListFileArg(path28, maxRows) {
10106
+ if (typeof path28 !== "string" || path28.length === 0) {
10107
+ return void 0;
10460
10108
  }
10461
- });
10462
- registerSchema({
10463
- command: "ads.linkedin.lead-forms.update",
10464
- description: "Stage changes to a Lead Gen Form from a JSON file. Applies on chat publish.",
10465
- args: {
10466
- id: { type: "positional", description: "Lead form id or URN", required: true },
10467
- ...writeAccountArgs,
10468
- file: { type: "string", description: "JSON file with the fields to change", required: true },
10469
- name: { type: "string", description: "New form name", required: false }
10109
+ const raw = readFileSync4(path28, "utf8");
10110
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
10111
+ if (lines.length < 2) {
10112
+ failWriteValidation2(`${path28} needs a header row and at least one data row`);
10470
10113
  }
10471
- });
10472
- registerSchema({
10473
- command: "ads.linkedin.draft.list",
10474
- description: "Show every LinkedIn write op staged in this chat: ref, summary, dependencies, warnings, mode (simulated = publish will NOT hit LinkedIn), and post-publish results (applied/simulated/failed/skipped). Ops are invisible to the read commands until publish \u2014 check here before finishing.",
10475
- args: {
10476
- json: {
10477
- type: "boolean",
10478
- description: "Print the raw JSON envelope instead of the readable tree",
10479
- required: false
10114
+ const columns = parseCsvLine(lines[0]).map((column) => column.trim());
10115
+ const rows = [];
10116
+ for (const line of lines.slice(1)) {
10117
+ const cells = parseCsvLine(line);
10118
+ const row = {};
10119
+ columns.forEach((column, index) => {
10120
+ const value = cells[index] ?? "";
10121
+ if (!value) {
10122
+ return;
10123
+ }
10124
+ row[column] = column.toLowerCase() === "email" ? sha256Lower(value) : value;
10125
+ });
10126
+ if (Object.keys(row).length > 0) {
10127
+ rows.push(row);
10480
10128
  }
10481
10129
  }
10482
- });
10483
- registerSchema({
10484
- command: "ads.linkedin.draft.remove",
10485
- description: "Drop one staged LinkedIn op from this chat's draft. Removing a create cascades to every op that depends on it (a campaign removal drops its staged creatives).",
10486
- args: {
10487
- ref: {
10488
- type: "positional",
10489
- description: "Op ref (li_temp_* for creates, target id/URN for updates)",
10490
- required: true
10491
- }
10130
+ if (rows.length > maxRows) {
10131
+ failWriteValidation2(`${path28} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
10492
10132
  }
10493
- });
10494
- registerSchema({
10495
- command: "ads.linkedin.draft.clear",
10496
- description: "Discard ALL staged LinkedIn ops in this chat's draft. Nothing applies on publish.",
10497
- args: {}
10498
- });
10499
-
10500
- // src/commands/ads/linkedin/account.ts
10501
- import { defineCommand as defineCommand32 } from "citty";
10502
-
10503
- // src/commands/ads/linkedin/shared.ts
10504
- var DAY_MS2 = 864e5;
10505
- function handleLinkedinError(err) {
10506
- if (err instanceof ApiError) {
10507
- if (err.code === "UNAUTHORIZED") {
10508
- handleConnectionError("linkedin_ads", err.message);
10509
- }
10510
- if (err.code === "NOT_FOUND") {
10511
- handleConnectionError("linkedin_ads", err.message);
10512
- }
10513
- if (isNoAccountsSelectedError(err.code, err.message)) {
10514
- handleConnectionError("linkedin_ads", err.message);
10133
+ return { columns, rows };
10134
+ }
10135
+ function failPreflight(fields) {
10136
+ writeJsonEnvelope({
10137
+ ok: false,
10138
+ error: {
10139
+ code: "VALIDATION_ERROR",
10140
+ message: fields.map((field) => field.path ? `${field.path}: ${field.message}` : field.message).join("; "),
10141
+ fields,
10142
+ fix: capabilityFix("linkedin-ads")
10515
10143
  }
10516
- const explanation = explainLinkedinError(err);
10517
- const envelope = {
10518
- ok: false,
10519
- error: {
10520
- code: err.code,
10521
- message: err.message,
10522
- fix: {
10523
- action: explanation.action,
10524
- explanation: explanation.message,
10525
- correctedCommand: explanation.correctedCommand
10526
- },
10527
- retryable: err.code === "RATE_LIMITED" || err.code === "INTERNAL_ERROR"
10528
- }
10529
- };
10530
- writeAdsJson(envelope);
10531
- process.exit(1);
10532
- }
10533
- const message = err instanceof Error ? err.message : "Unexpected error";
10534
- writeAdsJson({ ok: false, error: { code: "NETWORK_ERROR", message } });
10144
+ });
10535
10145
  process.exit(1);
10536
10146
  }
10537
- function isNoAccountsSelectedError(code, message) {
10538
- return code === "VALIDATION_ERROR" && /no linkedin ad accounts selected/i.test(message);
10147
+ var BATCH_NUDGE = "This command takes several ids at once \u2014 pass them comma-separated or with --ids-file instead of one call per id.";
10148
+ function batchNudge(data) {
10149
+ return data.sameKindStaged === void 0 ? [] : [BATCH_NUDGE];
10539
10150
  }
10540
- function explainLinkedinError(err) {
10541
- const lower = err.message.toLowerCase();
10542
- if (err.code === "VALIDATION_ERROR" && lower.includes("granularity") && lower.includes("daily")) {
10543
- return {
10544
- action: "retry_with_flag",
10545
- message: "LinkedIn rejects DAILY granularity when pivoting on demographic dims (job-title, company, etc.). Re-run with --granularity ALL or MONTHLY.",
10546
- correctedCommand: void 0
10547
- };
10548
- }
10549
- if (err.code === "RATE_LIMITED") {
10550
- return {
10551
- action: "wait_and_retry",
10552
- message: "Hit a LinkedIn throttle. The CLI already backs off automatically \u2014 if you see this you've exhausted the retry budget. Wait ~60s and retry."
10553
- };
10151
+ async function stageOp(raw, hints) {
10152
+ const preflight = linkedinDraftOpInputSchema.safeParse(raw);
10153
+ if (!preflight.success) {
10154
+ failPreflight(preflight.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })));
10554
10155
  }
10555
- if (err.code === "INTERNAL_ERROR" && lower.includes("deprecat")) {
10556
- return {
10557
- action: "reject",
10558
- message: "LinkedIn-Version header is deprecated. The backend constant LINKEDIN_API_VERSION needs to be bumped \u2014 file an issue or update the constant."
10559
- };
10156
+ try {
10157
+ const chatId = requireChatId();
10158
+ const response = await apiPost("/api/ads/linkedin/draft/stage", {
10159
+ chatId,
10160
+ op: preflight.data
10161
+ });
10162
+ const allHints = [...batchNudge(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
10163
+ writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10164
+ } catch (err) {
10165
+ handleLinkedinError(err);
10560
10166
  }
10561
- return { action: "reject", message: err.message };
10562
10167
  }
10563
- var LI_ACCOUNT_ID_RE = /^(urn:li:sponsoredAccount:)?\d+$/;
10564
- function resolveAccountIdArg2(args) {
10565
- const fromArgs = args["account-id"] ?? args["account-urn"];
10566
- const id = fromArgs || getEnv().BAKER_LINKEDIN_AD_ACCOUNT_ID;
10567
- if (!id) {
10568
- writeAdsJson({
10569
- ok: false,
10570
- error: {
10571
- code: "MISSING_ACCOUNT_ID",
10572
- message: "Pass --account-id (numeric) or --account-urn (urn:li:sponsoredAccount:N), or set BAKER_LINKEDIN_AD_ACCOUNT_ID. Run `baker ads linkedin accounts` to find IDs."
10573
- }
10574
- });
10575
- process.exit(1);
10168
+ async function stageLinkedinOps(rawOps, hints) {
10169
+ if (rawOps.length === 1 && rawOps[0]) {
10170
+ await stageOp(rawOps[0], hints);
10171
+ return;
10576
10172
  }
10577
- if (!LI_ACCOUNT_ID_RE.test(id)) {
10578
- writeAdsJson({
10579
- ok: false,
10580
- error: {
10581
- code: "INVALID_ACCOUNT_ID",
10582
- message: `Invalid LinkedIn account ID "${id}". Expected numeric or urn:li:sponsoredAccount:N.`
10583
- }
10584
- });
10585
- process.exit(1);
10173
+ const ops = [];
10174
+ for (const [index, raw] of rawOps.entries()) {
10175
+ const preflight = linkedinDraftOpInputSchema.safeParse(raw);
10176
+ if (!preflight.success) {
10177
+ failPreflight(
10178
+ preflight.error.issues.map((issue) => ({
10179
+ path: `ops[${index}]${issue.path.length > 0 ? `.${issue.path.join(".")}` : ""}`,
10180
+ message: issue.message
10181
+ }))
10182
+ );
10183
+ }
10184
+ ops.push(preflight.data);
10185
+ }
10186
+ try {
10187
+ const chatId = requireChatId();
10188
+ const response = await apiPost("/api/ads/linkedin/draft/stage-batch", { chatId, ops });
10189
+ const allHints = [...hints ?? [], ...adCopyHints(ops)];
10190
+ writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10191
+ } catch (err) {
10192
+ handleLinkedinError(err);
10586
10193
  }
10587
- return id.startsWith("urn:") ? id.split(":").pop() ?? id : id;
10588
10194
  }
10589
- function todayIso2() {
10590
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
10195
+ async function stageStatusChange(input) {
10196
+ const payload = input.kind === "creative.update" ? { intendedStatus: input.status } : { status: input.status };
10197
+ await stageLinkedinOps(
10198
+ input.targets.map((target) => ({ kind: input.kind, accountId: input.accountId, target, payload }))
10199
+ );
10591
10200
  }
10592
- function daysAgoIso2(days) {
10593
- return new Date(Date.now() - days * DAY_MS2).toISOString().slice(0, 10);
10201
+ function requireTarget2(args, entity) {
10202
+ const inverted = booleanWordPositionalError(args, entity);
10203
+ if (inverted !== void 0) {
10204
+ failWriteValidation2(inverted);
10205
+ }
10206
+ const positionals = readPositionals(args);
10207
+ const target = args.id ?? args.target ?? args.ref ?? positionals[0];
10208
+ if (typeof target !== "string" || target.length === 0) {
10209
+ failWriteValidation2(`pass the ${entity} id or URN as the positional argument`);
10210
+ }
10211
+ if (positionals.length > 1 || splitIdList(target).length > 1) {
10212
+ failWriteValidation2(
10213
+ `this command takes one ${entity} id \u2014 run it once per id. Only pause/resume/archive and 'creatives update' accept several ids in one call.`
10214
+ );
10215
+ }
10216
+ return target;
10594
10217
  }
10595
- function csvOrJson2(args) {
10596
- return args.output ?? "json";
10218
+ function readPositionals(args) {
10219
+ if (!Array.isArray(args._)) {
10220
+ return [];
10221
+ }
10222
+ return args._.filter((value) => typeof value === "string" && value.length > 0);
10597
10223
  }
10598
- function resolveStatusFilter(args) {
10599
- if (args["all-statuses"]) {
10600
- return void 0;
10224
+ function splitIdList(raw) {
10225
+ return raw.split(",").map((id) => id.trim()).filter(Boolean);
10226
+ }
10227
+ function idsFileEntries(path28) {
10228
+ if (typeof path28 !== "string" || path28.length === 0) {
10229
+ return [];
10601
10230
  }
10602
- const explicit = args.statuses;
10603
- if (explicit) {
10604
- return explicit.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
10231
+ return readFileSync4(path28, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).flatMap(splitIdList);
10232
+ }
10233
+ function requireTargets(args, entity) {
10234
+ const positionals = readPositionals(args);
10235
+ const named = [args.id, args.target, args.ref].filter(
10236
+ (value) => typeof value === "string" && value.length > 0
10237
+ );
10238
+ const fromArgs = positionals.length > 0 ? positionals : named;
10239
+ const targets = [.../* @__PURE__ */ new Set([...fromArgs.flatMap(splitIdList), ...idsFileEntries(args["ids-file"])])];
10240
+ if (targets.length === 0) {
10241
+ failWriteValidation2(
10242
+ `pass the ${entity} id or URN as the positional argument (comma-separated for several), or --ids-file <path>`
10243
+ );
10605
10244
  }
10606
- return ["ACTIVE"];
10245
+ if (targets.length > LINKEDIN_DRAFT_BATCH_MAX) {
10246
+ failWriteValidation2(
10247
+ `${targets.length} ids exceeds the batch limit of ${LINKEDIN_DRAFT_BATCH_MAX} \u2014 split the list`
10248
+ );
10249
+ }
10250
+ return targets;
10607
10251
  }
10608
10252
 
10609
- // src/commands/ads/linkedin/account.ts
10610
- var accountCommand = defineCommand32({
10611
- meta: {
10612
- name: "account",
10613
- description: `Single LinkedIn ad account detail (currency, status, type).
10614
-
10615
- Examples:
10616
- baker ads linkedin account --account-id 503001492
10617
- baker ads linkedin account --account-urn urn:li:sponsoredAccount:503001492 --output md`
10618
- },
10253
+ // src/commands/ads/linkedin/schemas.ts
10254
+ registerSchema({
10255
+ command: "ads.linkedin.accounts",
10256
+ description: "List LinkedIn ad accounts in the company's connected scope. Pass --include-all to list every account the OAuth token can see.",
10619
10257
  args: {
10620
- "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
10621
- "account-urn": { type: "string", description: "Alias for --account-id" },
10622
- "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
10623
- output: { type: "string", description: "json|csv|jsonl|md", default: "json" }
10624
- },
10625
- run: async ({ args }) => {
10626
- const accountId = resolveAccountIdArg2(args);
10627
- try {
10628
- const params = { "account-id": accountId };
10629
- if (args["skip-cache"]) params["skip-cache"] = "true";
10630
- const data = await apiGet("/api/ads/linkedin/account", params);
10631
- const fmt = csvOrJson2(args);
10632
- if (fmt !== "json") {
10633
- writeAdsOutput([data], fmt);
10634
- return;
10635
- }
10636
- writeAdsJson({ ok: true, data });
10637
- } catch (err) {
10638
- handleLinkedinError(err);
10639
- }
10258
+ "include-all": { type: "boolean", description: "Ignore picker scope", required: false },
10259
+ "no-cache": { type: "boolean", description: "Skip CLI-side cache", required: false },
10260
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache", required: false },
10261
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10640
10262
  }
10641
10263
  });
10642
-
10643
- // src/commands/ads/linkedin/accounts.ts
10644
- import { defineCommand as defineCommand33 } from "citty";
10645
- var ACCOUNTS_TTL_MS = 60 * 60 * 1e3;
10646
- function accountHints2(accounts, includeAll) {
10647
- if (includeAll) {
10648
- return [];
10264
+ registerSchema({
10265
+ command: "ads.linkedin.account",
10266
+ description: "Single LinkedIn ad account detail (currency, status, type). Resolves the urn \u2192 {id, name, currency, status, type}.",
10267
+ args: {
10268
+ "account-id": { type: "string", description: "Numeric ID or urn:li:sponsoredAccount:N", required: false },
10269
+ "account-urn": { type: "string", description: "Alias for --account-id", required: false },
10270
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10271
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10649
10272
  }
10650
- return connectedResourceHints({
10651
- plural: "LinkedIn ad accounts",
10652
- singular: "LinkedIn ad account",
10653
- flag: "--account-id",
10654
- resources: accounts.map((account) => ({ id: account.externalId, label: account.name }))
10655
- });
10656
- }
10657
- var accountsCommand2 = defineCommand33({
10658
- meta: {
10659
- name: "accounts",
10660
- description: `List LinkedIn ad accounts in this company's connected scope.
10661
-
10662
- Examples:
10663
- baker ads linkedin accounts
10664
- baker ads linkedin accounts --include-all # ignore picker scope, list every account the token can see
10665
- baker ads linkedin accounts --output csv`
10666
- },
10273
+ });
10274
+ registerSchema({
10275
+ command: "ads.linkedin.campaign-groups",
10276
+ description: "List LinkedIn campaign groups in an account. Default ACTIVE-only \u2014 pass --all-statuses to widen.",
10667
10277
  args: {
10668
- "include-all": { type: "boolean", description: "List all accessible accounts, ignoring picker scope" },
10669
- "no-cache": { type: "boolean", description: "Skip CLI-side cache" },
10670
- "skip-cache": { type: "boolean", description: "Bypass server-side cache (force re-fetch)" },
10671
- output: { type: "string", description: "Output format: json|csv|jsonl|md", default: "json" }
10672
- },
10673
- run: async ({ args }) => {
10674
- const includeAll = args["include-all"] === true;
10675
- const useCache = !args["no-cache"];
10676
- const cacheKey = `accounts:${includeAll ? "all" : "scoped"}`;
10677
- if (useCache) {
10678
- const cached = cacheGet("linkedin-accounts", cacheKey);
10679
- if (cached) {
10680
- writeJsonEnvelope({
10681
- ok: true,
10682
- data: cached.data,
10683
- cached: true,
10684
- hints: accountHints2(cached.data, includeAll)
10685
- });
10686
- return;
10687
- }
10688
- }
10689
- try {
10690
- const params = {};
10691
- if (includeAll) params["include-all"] = "true";
10692
- if (args["skip-cache"]) params["skip-cache"] = "true";
10693
- const data = await apiGet("/api/ads/linkedin/accounts", params);
10694
- if (useCache) {
10695
- cacheSet("linkedin-accounts", cacheKey, data, ACCOUNTS_TTL_MS);
10696
- }
10697
- const fmt = csvOrJson2(args);
10698
- if (fmt !== "json") {
10699
- writeAdsOutput(data, fmt);
10700
- return;
10701
- }
10702
- writeJsonEnvelope({ ok: true, data, hints: accountHints2(data, includeAll) });
10703
- } catch (err) {
10704
- handleLinkedinError(err);
10705
- }
10278
+ "account-id": { type: "string", description: "Account ID", required: false },
10279
+ "account-urn": { type: "string", description: "Alias for --account-id", required: false },
10280
+ "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
10281
+ statuses: { type: "string", description: "CSV of statuses", required: false },
10282
+ limit: { type: "string", description: "Max rows (default 500)", required: false },
10283
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10284
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10706
10285
  }
10707
10286
  });
10708
-
10709
- // src/commands/ads/linkedin/analytics.ts
10710
- import { defineCommand as defineCommand34 } from "citty";
10711
-
10712
- // src/commands/ads/linkedin/presets.ts
10713
- var INTENTS = {
10714
- baseline: {
10715
- name: "baseline",
10716
- description: "Spend, impressions, clicks. The default 'how is delivery?' question. Derives CTR, CPC, CPM.",
10717
- metrics: ["impressions", "clicks", "costInUsd", "costInLocalCurrency", "approximateUniqueImpressions"],
10718
- derived: ["ctr", "cpc", "cpm", "frequency"]
10719
- },
10720
- revenue: {
10721
- name: "revenue",
10722
- description: "Spend, conversions, conversion value. 'How much money are we making?'",
10723
- metrics: [
10724
- "costInUsd",
10725
- "externalWebsiteConversions",
10726
- "externalWebsitePostClickConversions",
10727
- "externalWebsitePostViewConversions",
10728
- "conversionValueInLocalCurrency",
10729
- "oneClickLeads"
10730
- ]
10731
- },
10732
- funnel: {
10733
- name: "funnel",
10734
- description: "Impression \u2192 click \u2192 landing \u2192 form open \u2192 lead \u2192 conversion. 'Where do users drop off?'",
10735
- metrics: [
10736
- "impressions",
10737
- "clicks",
10738
- "landingPageClicks",
10739
- "oneClickLeadFormOpens",
10740
- "oneClickLeads",
10741
- "externalWebsiteConversions",
10742
- "costInUsd"
10743
- ],
10744
- derived: ["ctr", "leadCompletionRate"]
10745
- },
10746
- engagement: {
10747
- name: "engagement",
10748
- description: "Likes, comments, shares, follows + viral spillover. TLA-aware \u2014 measures organic reach effect.",
10749
- metrics: [
10750
- "impressions",
10751
- "likes",
10752
- "comments",
10753
- "shares",
10754
- "reactions",
10755
- "follows",
10756
- "totalEngagements",
10757
- "viralImpressions",
10758
- "viralLikes",
10759
- "viralShares",
10760
- "viralOneClickLeads",
10761
- "costInUsd"
10762
- ]
10763
- },
10764
- video: {
10765
- name: "video",
10766
- description: "Video watch quartiles + cost. 'How are video creatives holding attention?'",
10767
- metrics: [
10768
- "videoStarts",
10769
- "videoFirstQuartileCompletions",
10770
- "videoMidpointCompletions",
10771
- "videoThirdQuartileCompletions",
10772
- "videoCompletions",
10773
- "videoViews",
10774
- "fullScreenPlays",
10775
- "costInUsd",
10776
- "impressions"
10777
- ]
10778
- },
10779
- "lead-gen": {
10780
- name: "lead-gen",
10781
- description: "Lead Gen Form opens + completions. CLI derives form-completion-rate.",
10782
- metrics: [
10783
- "oneClickLeadFormOpens",
10784
- "oneClickLeads",
10785
- "viralOneClickLeads",
10786
- "viralOneClickLeadFormOpens",
10787
- "costInUsd"
10788
- ],
10789
- derived: ["leadCompletionRate"]
10790
- },
10791
- inmail: {
10792
- name: "inmail",
10793
- description: "Sponsored Message / Conversation Ads \u2014 sends, opens, clicks, lead-gen taps.",
10794
- metrics: [
10795
- "sends",
10796
- "opens",
10797
- "clicks",
10798
- "costInUsd",
10799
- "leadGenerationMailContactInfoShares",
10800
- "leadGenerationMailInterestedClicks"
10801
- ]
10802
- },
10803
- document: {
10804
- name: "document",
10805
- description: "Document Ads \u2014 quartile read-through, completion, downloads.",
10806
- metrics: [
10807
- "documentFirstQuartileCompletions",
10808
- "documentMidpointCompletions",
10809
- "documentThirdQuartileCompletions",
10810
- "documentCompletions",
10811
- "downloadClicks",
10812
- "costInUsd",
10813
- "impressions"
10814
- ]
10815
- },
10816
- ranking: {
10817
- name: "ranking",
10818
- description: "Frequency + reach metrics for fatigue detection. CLI derives frequency = impressions/uniqueImpressions.",
10819
- metrics: ["impressions", "clicks", "costInUsd", "approximateUniqueImpressions"],
10820
- derived: ["frequency", "ctr"]
10821
- },
10822
- identity: {
10823
- name: "identity",
10824
- description: "Just spend + impressions. Cheap roll-up.",
10825
- metrics: ["costInUsd", "impressions"]
10287
+ registerSchema({
10288
+ command: "ads.linkedin.campaigns",
10289
+ description: "List LinkedIn campaigns. Returns audit-relevant settings (audienceExpansionEnabled, offsiteDeliveryEnabled, optimizationTargetType, costType, budgets, runSchedule, targetingCriteria).",
10290
+ args: {
10291
+ "account-id": { type: "string", description: "Account ID", required: false },
10292
+ "account-urn": { type: "string", description: "Alias", required: false },
10293
+ "campaign-group-id": { type: "string", description: "Filter by campaign group", required: false },
10294
+ "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
10295
+ statuses: { type: "string", description: "CSV of statuses", required: false },
10296
+ limit: { type: "string", description: "Max rows", required: false },
10297
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10298
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10826
10299
  }
10827
- };
10828
- var MEMBER_PIVOTS = /* @__PURE__ */ new Set([
10829
- "company",
10830
- "job-title",
10831
- "job-function",
10832
- "seniority",
10833
- "industry",
10834
- "company-size",
10835
- "country",
10836
- "region"
10837
- ]);
10838
- var ALL_PIVOTS = [
10839
- "none",
10840
- "campaign",
10841
- "campaign-group",
10842
- "creative",
10843
- "company",
10844
- "account",
10845
- "conversion",
10846
- "job-title",
10847
- "job-function",
10848
- "seniority",
10849
- "industry",
10850
- "company-size",
10851
- "country",
10852
- "region",
10853
- "device",
10854
- "placement",
10855
- "serving-location",
10856
- "card-index",
10857
- "objective",
10858
- "conversation-node",
10859
- "conversation-node-button"
10860
- ];
10861
- var ALL_LEVELS = ["account", "campaign-group", "campaign", "creative"];
10862
- function listIntents() {
10863
- return Object.values(INTENTS);
10864
- }
10865
- function isPivot(slug) {
10866
- return ALL_PIVOTS.includes(slug);
10867
- }
10868
- function isLevel(slug) {
10869
- return ALL_LEVELS.includes(slug);
10870
- }
10871
- function composeFields(intent, _level, metricsOverride) {
10872
- const base = metricsOverride && metricsOverride.length > 0 ? metricsOverride : INTENTS[intent].metrics;
10873
- const seen = /* @__PURE__ */ new Set();
10874
- const out = [];
10875
- for (const f of ["pivotValues", "dateRange", ...base]) {
10876
- if (!seen.has(f)) {
10877
- seen.add(f);
10878
- out.push(f);
10879
- }
10300
+ });
10301
+ registerSchema({
10302
+ command: "ads.linkedin.creatives",
10303
+ description: "List LinkedIn creatives. Compact by default: id, urn, campaign, status, review status, format, and the resolved destination (landingUrl, or postUrn for post-based ads). The opaque 'content' block is NOT returned unless you pass --full; --fields takes dotted paths for anything narrower.",
10304
+ args: {
10305
+ "account-id": { type: "string", description: "Account ID", required: false },
10306
+ "account-urn": { type: "string", description: "Alias", required: false },
10307
+ "campaign-id": { type: "string", description: "Filter by campaign", required: false },
10308
+ "all-statuses": { type: "boolean", description: "Drop the ACTIVE filter", required: false },
10309
+ statuses: { type: "string", description: "CSV of statuses", required: false },
10310
+ fields: {
10311
+ type: "string",
10312
+ description: "CSV of dotted paths to return instead of the compact row, e.g. id,content.textAd.landingPage",
10313
+ required: false
10314
+ },
10315
+ full: {
10316
+ type: "boolean",
10317
+ description: "Return the complete raw records, including the opaque 'content' block",
10318
+ required: false
10319
+ },
10320
+ limit: { type: "string", description: "Max rows", required: false },
10321
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10322
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10880
10323
  }
10881
- return out;
10882
- }
10883
-
10884
- // src/commands/ads/linkedin/analytics.ts
10885
- function commaSplit(input) {
10886
- if (!input) return void 0;
10887
- return input.split(",").map((s) => s.trim()).filter(Boolean);
10888
- }
10889
- function parseGranularity(raw) {
10890
- const v = (raw ?? "DAILY").toUpperCase();
10891
- if (v === "DAILY" || v === "MONTHLY" || v === "YEARLY" || v === "ALL") {
10892
- return v;
10324
+ });
10325
+ registerSchema({
10326
+ command: "ads.linkedin.analytics",
10327
+ description: "Performance reporting (3-axis: --level \xD7 --intent \xD7 --pivot). LinkedIn's superpower: pivot=job-title|company|industry|seniority. Demographic pivots auto-force granularity=ALL and surface a delayed-data warning.",
10328
+ args: {
10329
+ level: {
10330
+ type: "string",
10331
+ description: "account|campaign-group|campaign|creative",
10332
+ required: false,
10333
+ default: "account"
10334
+ },
10335
+ "account-id": { type: "string", description: "When level=account", required: false },
10336
+ "account-urn": { type: "string", description: "Alias", required: false },
10337
+ "campaign-group-id": { type: "string", description: "CSV when level=campaign-group", required: false },
10338
+ "campaign-id": { type: "string", description: "CSV when level=campaign", required: false },
10339
+ "creative-id": { type: "string", description: "CSV when level=creative", required: false },
10340
+ ids: { type: "string", description: "Generic CSV alternative", required: false },
10341
+ intent: {
10342
+ type: "string",
10343
+ description: "Field bundle",
10344
+ required: false,
10345
+ default: "baseline",
10346
+ enum: [
10347
+ "baseline",
10348
+ "revenue",
10349
+ "funnel",
10350
+ "engagement",
10351
+ "video",
10352
+ "lead-gen",
10353
+ "inmail",
10354
+ "document",
10355
+ "ranking",
10356
+ "identity"
10357
+ ]
10358
+ },
10359
+ pivot: {
10360
+ type: "string",
10361
+ description: "Demographic / firmographic dim",
10362
+ required: false,
10363
+ default: "none",
10364
+ enum: [
10365
+ "none",
10366
+ "campaign",
10367
+ "campaign-group",
10368
+ "creative",
10369
+ "company",
10370
+ "account",
10371
+ "conversion",
10372
+ "job-title",
10373
+ "job-function",
10374
+ "seniority",
10375
+ "industry",
10376
+ "company-size",
10377
+ "country",
10378
+ "region",
10379
+ "device",
10380
+ "placement",
10381
+ "serving-location",
10382
+ "card-index",
10383
+ "objective",
10384
+ "conversation-node",
10385
+ "conversation-node-button"
10386
+ ]
10387
+ },
10388
+ metrics: { type: "string", description: "CSV metric override (escape hatch)", required: false },
10389
+ start: { type: "string", description: "YYYY-MM-DD", required: false },
10390
+ end: { type: "string", description: "YYYY-MM-DD", required: false },
10391
+ "last-days": { type: "string", description: "Window (default 7)", required: false },
10392
+ granularity: {
10393
+ type: "string",
10394
+ description: "DAILY|MONTHLY|YEARLY|ALL",
10395
+ required: false,
10396
+ default: "DAILY",
10397
+ enum: ["DAILY", "MONTHLY", "YEARLY", "ALL"]
10398
+ },
10399
+ limit: { type: "string", description: "Max rows", required: false },
10400
+ "no-sort": { type: "boolean", description: "Skip default sort", required: false },
10401
+ "list-intents": { type: "boolean", description: "List intents and exit", required: false },
10402
+ "list-pivots": { type: "boolean", description: "List pivots and exit", required: false },
10403
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10404
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10893
10405
  }
10894
- handleLinkedinError(new Error(`Invalid --granularity "${raw}". Use DAILY | MONTHLY | YEARLY | ALL.`));
10895
- }
10896
- function parseLevel(raw) {
10897
- const v = raw ?? "account";
10898
- if (!isLevel(v)) {
10899
- handleLinkedinError(new Error(`Invalid --level "${raw}". Use one of: ${ALL_LEVELS.join(", ")}.`));
10406
+ });
10407
+ registerSchema({
10408
+ command: "ads.linkedin.demographics",
10409
+ description: "Sweep all firmographic pivots (job-title, company, industry, seniority, function, company-size) in one call. Returns top-N rows per pivot.",
10410
+ args: {
10411
+ "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
10412
+ pivots: { type: "string", description: "CSV of pivots (default: all firmographic)", required: false },
10413
+ "top-n": { type: "string", description: "Top rows per pivot (default 10)", required: false },
10414
+ intent: { type: "string", description: "Field bundle", required: false, default: "baseline" },
10415
+ start: { type: "string", description: "YYYY-MM-DD", required: false },
10416
+ end: { type: "string", description: "YYYY-MM-DD", required: false },
10417
+ "last-days": { type: "string", description: "Window (default 30)", required: false },
10418
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10419
+ output: { type: "string", description: "json", required: false, default: "json" }
10420
+ }
10421
+ });
10422
+ registerSchema({
10423
+ command: "ads.linkedin.top-companies",
10424
+ description: "Top companies whose employees saw / clicked / converted. Wrapper for analytics --pivot company. The ABM feedback loop.",
10425
+ args: {
10426
+ "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
10427
+ intent: { type: "string", description: "Field bundle (default baseline)", required: false },
10428
+ "top-n": { type: "string", description: "Top rows by impressions (default 25)", required: false },
10429
+ start: { type: "string", description: "YYYY-MM-DD", required: false },
10430
+ end: { type: "string", description: "YYYY-MM-DD", required: false },
10431
+ "last-days": { type: "string", description: "Window (default 30)", required: false },
10432
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10433
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10434
+ }
10435
+ });
10436
+ registerSchema({
10437
+ command: "ads.linkedin.facets.list",
10438
+ description: "List every targeting facet LinkedIn supports (industries, seniorities, titles, employers, growthRate, etc).",
10439
+ args: {
10440
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10441
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10442
+ }
10443
+ });
10444
+ registerSchema({
10445
+ command: "ads.linkedin.facets.values",
10446
+ description: "Look up entity values for a facet \u2014 full list (q=adTargetingFacet) or typeahead search (q=typeahead, with --query).",
10447
+ args: {
10448
+ facet: { type: "string", description: "Facet name (industries) or URN", required: true },
10449
+ query: { type: "string", description: "Typeahead query (auto-switches finder)", required: false },
10450
+ finder: { type: "string", description: "adTargetingFacet|typeahead|similarEntities", required: false },
10451
+ locale: { type: "string", description: "Locale (default en_US)", required: false },
10452
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10453
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10454
+ }
10455
+ });
10456
+ registerSchema({
10457
+ command: "ads.linkedin.audience-size",
10458
+ description: "Estimate audience size for a targetingCriteria payload. Returns total + active + playbook \xA704 sweet-spot warnings.",
10459
+ args: {
10460
+ "account-id": { type: "string", description: "Account ID", required: false },
10461
+ "account-urn": { type: "string", description: "Alias", required: false },
10462
+ targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
10463
+ "targeting-file": { type: "string", description: "Path to JSON file", required: false },
10464
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10465
+ output: { type: "string", description: "json", required: false, default: "json" }
10466
+ }
10467
+ });
10468
+ registerSchema({
10469
+ command: "ads.linkedin.bid-pricing",
10470
+ description: "LinkedIn's suggested bid range + playbook \xA706 floor (2/3 of suggested). Inputs: targeting + objective + cost type.",
10471
+ args: {
10472
+ "account-id": { type: "string", description: "Account ID", required: false },
10473
+ "account-urn": { type: "string", description: "Alias", required: false },
10474
+ objective: {
10475
+ type: "string",
10476
+ description: "Objective type (WEBSITE_CONVERSION, LEAD_GENERATION, etc.)",
10477
+ required: true
10478
+ },
10479
+ "cost-type": { type: "string", description: "CPC|CPM|CPV|CPS", required: true, enum: ["CPC", "CPM", "CPV", "CPS"] },
10480
+ targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
10481
+ "targeting-file": { type: "string", description: "Path to JSON file", required: false },
10482
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10483
+ output: { type: "string", description: "json", required: false, default: "json" }
10484
+ }
10485
+ });
10486
+ registerSchema({
10487
+ command: "ads.linkedin.forecast",
10488
+ description: "Forecast reach + impressions + clicks + spend for a hypothetical campaign. Useful for \xA714 greenfield planning.",
10489
+ args: {
10490
+ "account-id": { type: "string", description: "Account ID", required: false },
10491
+ "account-urn": { type: "string", description: "Alias", required: false },
10492
+ objective: { type: "string", description: "Objective type", required: true },
10493
+ "cost-type": { type: "string", description: "CPC|CPM|CPV|CPS", required: true },
10494
+ "daily-budget": { type: "string", description: "e.g. '200 USD'", required: false },
10495
+ "total-budget": { type: "string", description: "e.g. '6000 USD'", required: false },
10496
+ bid: { type: "string", description: "Bid e.g. '8 USD'", required: false },
10497
+ targeting: { type: "string", description: "Inline JSON targetingCriteria", required: false },
10498
+ "targeting-file": { type: "string", description: "Path to JSON file", required: false },
10499
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10500
+ output: { type: "string", description: "json", required: false, default: "json" }
10501
+ }
10502
+ });
10503
+ registerSchema({
10504
+ command: "ads.linkedin.leads",
10505
+ description: "List Lead Gen Form responses (90-day LinkedIn retention \u2014 sync to CRM via this endpoint). Filter by --form-id, --campaign-id, or --since-days.",
10506
+ args: {
10507
+ "account-id": { type: "string", description: "Account ID", required: false },
10508
+ "account-urn": { type: "string", description: "Alias", required: false },
10509
+ "form-id": { type: "string", description: "Filter by form ID", required: false },
10510
+ "campaign-id": { type: "string", description: "Filter by campaign", required: false },
10511
+ "since-days": { type: "string", description: "Last N days", required: false },
10512
+ "since-ms": { type: "string", description: "Epoch ms (alternative to --since-days)", required: false },
10513
+ limit: { type: "string", description: "Max rows", required: false },
10514
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10515
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10516
+ }
10517
+ });
10518
+ registerSchema({
10519
+ command: "ads.linkedin.conversions.list",
10520
+ description: "List conversion rules (Insight Tag + Conversions API).",
10521
+ args: {
10522
+ "account-id": { type: "string", description: "Account ID", required: false },
10523
+ "account-urn": { type: "string", description: "Alias", required: false },
10524
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10525
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10900
10526
  }
10901
- return v;
10902
- }
10903
- function parsePivot(raw) {
10904
- const v = raw ?? "none";
10905
- if (!isPivot(v)) {
10906
- handleLinkedinError(new Error(`Invalid --pivot "${raw}". Use one of: ${ALL_PIVOTS.join(", ")}.`));
10527
+ });
10528
+ registerSchema({
10529
+ command: "ads.linkedin.conversions.health",
10530
+ description: "Playbook \xA707 5-point health check: rules enabled? lead/purchase event? CAPI fired in 7d? view-through \u22647d? lead de-dup correct?",
10531
+ args: {
10532
+ "account-id": { type: "string", description: "Account ID", required: false },
10533
+ "account-urn": { type: "string", description: "Alias", required: false },
10534
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10535
+ output: { type: "string", description: "json", required: false, default: "json" }
10907
10536
  }
10908
- return v;
10909
- }
10910
- function parseIntent(raw) {
10911
- const v = raw ?? "baseline";
10912
- const known = [
10913
- "baseline",
10914
- "revenue",
10915
- "funnel",
10916
- "engagement",
10917
- "video",
10918
- "lead-gen",
10919
- "inmail",
10920
- "document",
10921
- "ranking",
10922
- "identity"
10923
- ];
10924
- if (!known.includes(v)) {
10925
- handleLinkedinError(new Error(`Invalid --intent "${raw}". Run --list-intents to see options.`));
10537
+ });
10538
+ registerSchema({
10539
+ command: "ads.linkedin.conversation",
10540
+ description: "Per-button click rates inside Sponsored Messaging / Conversation Ads. Wraps analytics --pivot conversation-node-button.",
10541
+ args: {
10542
+ "campaign-id": { type: "string", description: "CSV campaign IDs", required: true },
10543
+ start: { type: "string", description: "YYYY-MM-DD", required: false },
10544
+ end: { type: "string", description: "YYYY-MM-DD", required: false },
10545
+ "last-days": { type: "string", description: "Window (default 30)", required: false },
10546
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false },
10547
+ output: { type: "string", description: "json|csv|jsonl|md", required: false, default: "json" }
10926
10548
  }
10927
- return v;
10928
- }
10929
- function resolveDateRange(args) {
10930
- const start = args.start;
10931
- const end = args.end;
10932
- if (start) {
10933
- return { start, end };
10549
+ });
10550
+ registerSchema({
10551
+ command: "ads.linkedin.audit",
10552
+ description: "Run a 30+ check playbook audit (Settings, Tracking, Audience, Campaigns, Creative, Performance, Bidding, Compliance, Hygiene). Each finding: {id, area, check, status, severity, evidence, fix} with playbook citations. --format md renders a deliverable-ready table.",
10553
+ args: {
10554
+ "account-id": { type: "string", description: "Account ID", required: false },
10555
+ "account-urn": { type: "string", description: "Alias", required: false },
10556
+ "campaign-id": { type: "string", description: "Narrow to a single campaign", required: false },
10557
+ "window-days": { type: "string", description: "Performance lookback (default 30)", required: false },
10558
+ severity: { type: "string", description: "CSV severity filter (critical,high,medium,low)", required: false },
10559
+ area: { type: "string", description: "CSV area filter", required: false },
10560
+ format: { type: "string", description: "json | md", required: false, default: "json" },
10561
+ "skip-cache": { type: "boolean", description: "Bypass cache", required: false }
10934
10562
  }
10935
- const lastDaysRaw = args["last-days"];
10936
- const days = lastDaysRaw ? Number(lastDaysRaw) : 7;
10937
- if (!Number.isFinite(days) || days < 1 || days > 730) {
10938
- handleLinkedinError(new Error(`Invalid --last-days "${lastDaysRaw}". Use a positive number \u2264 730.`));
10563
+ });
10564
+ var writeAccountArgs = {
10565
+ "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N", required: false },
10566
+ "account-urn": { type: "string", description: "Alias for --account-id", required: false }
10567
+ };
10568
+ registerSchema({
10569
+ command: "ads.linkedin.campaign-groups.create",
10570
+ description: "Stage a new campaign group (staged until publish; review with `baker ads linkedin draft`). Returns a li_temp_* ref that later `campaigns create --group` calls can reference.",
10571
+ args: {
10572
+ ...writeAccountArgs,
10573
+ name: { type: "string", description: "Name (\u2264100 chars)", required: true },
10574
+ start: { type: "string", description: "Run schedule start (ISO date or epoch ms)", required: true },
10575
+ end: { type: "string", description: "Run schedule end", required: false },
10576
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP, required: false },
10577
+ currency: {
10578
+ type: "string",
10579
+ description: "3-letter currency code (defaults to the ad account currency)",
10580
+ required: false
10581
+ },
10582
+ status: { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10583
+ file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10939
10584
  }
10940
- return { start: daysAgoIso2(days), end: todayIso2() };
10941
- }
10942
- function resolveScopeIds(args, level) {
10943
- if (level === "account") {
10944
- const id = resolveAccountIdArg2(args);
10945
- return [id];
10585
+ });
10586
+ registerSchema({
10587
+ command: "ads.linkedin.campaign-groups.update",
10588
+ description: "Stage changes to an existing campaign group (name, schedule, budget, status). Captures a before-snapshot for the dashboard diff. Pass a li_temp_* ref to AMEND a group staged in this chat. Applies on chat publish.",
10589
+ args: {
10590
+ id: {
10591
+ type: "positional",
10592
+ description: "Campaign group id/URN, or li_temp_* ref staged in this chat",
10593
+ required: true
10594
+ },
10595
+ ...writeAccountArgs,
10596
+ name: { type: "string", description: "New name", required: false },
10597
+ start: { type: "string", description: "New schedule start", required: false },
10598
+ end: { type: "string", description: "New schedule end", required: false },
10599
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP, required: false },
10600
+ currency: {
10601
+ type: "string",
10602
+ description: "3-letter currency code (defaults to the ad account currency)",
10603
+ required: false
10604
+ },
10605
+ status: { type: "string", description: "ACTIVE|PAUSED|ARCHIVED", required: false },
10606
+ file: { type: "string", description: "JSON file with fields to change", required: false }
10946
10607
  }
10947
- if (level === "campaign-group") {
10948
- const raw2 = args["campaign-group-id"] ?? args.ids;
10949
- if (!raw2) {
10950
- handleLinkedinError(new Error("Pass --campaign-group-id (CSV for multi) when --level campaign-group."));
10951
- }
10952
- return commaSplit(raw2) ?? [];
10608
+ });
10609
+ registerSchema({
10610
+ command: "ads.linkedin.campaigns.create",
10611
+ description: "Stage a new campaign. --group accepts a real id/URN or a li_temp_* ref staged earlier in this chat. Targeting goes in --targeting-file as LinkedIn criteria: {include:{and:[{or:{'urn:li:adTargetingFacet:locations':['urn:li:geo:\u2026']}}]}} \u2014 find facet values with `baker ads linkedin facets values`. Playbook defaults: manual bid (--bid), audience expansion off, LAN off. Staged until publish.",
10612
+ args: {
10613
+ ...writeAccountArgs,
10614
+ name: { type: "string", description: "Name (\u2264255 chars)", required: true },
10615
+ group: { type: "string", description: "Campaign group id/URN or li_temp_* ref", required: true },
10616
+ type: { type: "string", description: "SPONSORED_UPDATES|TEXT_AD|SPONSORED_INMAILS|DYNAMIC", required: true },
10617
+ objective: { type: "string", description: "LEAD_GENERATION|WEBSITE_VISIT|BRAND_AWARENESS|\u2026", required: false },
10618
+ "cost-type": { type: "string", description: "CPC|CPM|CPV", required: true },
10619
+ bid: { type: "string", description: "Manual bid amount (unitCost)", required: false },
10620
+ "daily-budget": { type: "string", description: "Daily budget amount", required: false },
10621
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP, required: false },
10622
+ currency: {
10623
+ type: "string",
10624
+ description: "3-letter currency code (defaults to the ad account currency)",
10625
+ required: false
10626
+ },
10627
+ start: { type: "string", description: "Run schedule start", required: false },
10628
+ end: { type: "string", description: "Run schedule end", required: false },
10629
+ locale: { type: "string", description: "Profile locale like en_US (default en_US)", required: false },
10630
+ "targeting-file": { type: "string", description: "JSON file with targetingCriteria", required: true },
10631
+ "audience-expansion": { type: "string", description: "on|off (playbook: off; default off)", required: false },
10632
+ lan: {
10633
+ type: "string",
10634
+ description: "LinkedIn Audience Network on|off (playbook: off; default off)",
10635
+ required: false
10636
+ },
10637
+ ctv: { type: "string", description: "Connected TV on|off (default off)", required: false },
10638
+ "creative-selection": { type: "string", description: "OPTIMIZED|ROUND_ROBIN", required: false },
10639
+ "associated-entity": { type: "string", description: "Organization URN", required: false },
10640
+ status: { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10641
+ file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10953
10642
  }
10954
- if (level === "campaign") {
10955
- const raw2 = args["campaign-id"] ?? args.ids;
10956
- if (!raw2) {
10957
- handleLinkedinError(new Error("Pass --campaign-id (CSV for multi) when --level campaign."));
10958
- }
10959
- return commaSplit(raw2) ?? [];
10643
+ });
10644
+ registerSchema({
10645
+ command: "ads.linkedin.campaigns.update",
10646
+ description: "Stage changes to an existing campaign \u2014 budget, bid, targeting, schedule, flags, status. Only pass what changes; a before-snapshot powers the dashboard diff. Pass a li_temp_* ref to AMEND a campaign staged in this chat (fields merge into the staged create). Applies on chat publish.",
10647
+ args: {
10648
+ id: { type: "positional", description: "Campaign id/URN, or li_temp_* ref staged in this chat", required: true },
10649
+ ...writeAccountArgs,
10650
+ name: { type: "string", description: "New name", required: false },
10651
+ objective: { type: "string", description: "New objective", required: false },
10652
+ "cost-type": { type: "string", description: "CPC|CPM|CPV", required: false },
10653
+ bid: { type: "string", description: "New manual bid", required: false },
10654
+ "daily-budget": { type: "string", description: "New daily budget", required: false },
10655
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP, required: false },
10656
+ currency: {
10657
+ type: "string",
10658
+ description: "3-letter currency code (defaults to the ad account currency)",
10659
+ required: false
10660
+ },
10661
+ start: { type: "string", description: "New schedule start", required: false },
10662
+ end: { type: "string", description: "New schedule end", required: false },
10663
+ "targeting-file": { type: "string", description: "JSON file with replacement targetingCriteria", required: false },
10664
+ "audience-expansion": { type: "string", description: "on|off", required: false },
10665
+ lan: { type: "string", description: "on|off", required: false },
10666
+ ctv: { type: "string", description: "on|off", required: false },
10667
+ "creative-selection": { type: "string", description: "OPTIMIZED|ROUND_ROBIN", required: false },
10668
+ status: { type: "string", description: "ACTIVE|PAUSED|ARCHIVED", required: false },
10669
+ file: { type: "string", description: "JSON file with fields to change", required: false }
10960
10670
  }
10961
- const raw = args["creative-id"] ?? args.ids;
10962
- if (!raw) {
10963
- handleLinkedinError(new Error("Pass --creative-id (CSV for multi) when --level creative."));
10671
+ });
10672
+ registerSchema({
10673
+ command: "ads.linkedin.campaigns.url-params",
10674
+ description: "Start here for UTMs: stage the URL tracking parameters on an ad set. LinkedIn appends them to the link of EVERY ad in the ad set, including ads already running, so this is the level to set campaign-wide UTMs at \u2014 editing each ad's landing URL instead risks the same key landing twice. --param takes fixed values (repeatable or &-joined); --dynamic takes a value LinkedIn fills in per ad (ACCOUNT_ID, ACCOUNT_NAME, CAMPAIGN_GROUP_ID, CAMPAIGN_GROUP_NAME, CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, CREATIVE_NAME). Applies on chat publish; message and conversation ads are unaffected.",
10675
+ args: {
10676
+ id: { type: "positional", description: "Ad set (campaign) id or URN", required: true },
10677
+ ...writeAccountArgs,
10678
+ param: {
10679
+ type: "string",
10680
+ description: 'Fixed key=value, repeatable or &-joined \u2014 e.g. "utm_source=linkedin&utm_medium=paid-social"',
10681
+ required: false
10682
+ },
10683
+ dynamic: {
10684
+ type: "string",
10685
+ description: "key=PLACEHOLDER filled in per ad, repeatable \u2014 e.g. utm_campaign=CAMPAIGN_NAME",
10686
+ required: false
10687
+ },
10688
+ clear: { type: "boolean", description: "Remove every tracking parameter from this ad set", required: false },
10689
+ file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
10690
+ }
10691
+ });
10692
+ var statusSugarArgs = {
10693
+ id: {
10694
+ type: "positional",
10695
+ description: "Entity id or URN \u2014 comma-separate several to change them all in one call",
10696
+ required: false
10697
+ },
10698
+ "ids-file": { type: "string", description: "File of ids, one per line or comma-separated", required: false },
10699
+ ...writeAccountArgs
10700
+ };
10701
+ var MULTI_ID_NOTE = "Takes one id or many: comma-separate the ids, or pass --ids-file. Several ids stage together as one set \u2014 all of them or none. The list form is for real ids/URNs only: a li_temp_* ref AMENDS what this chat already staged, and an amend must be passed on its own.";
10702
+ registerSchema({
10703
+ command: "ads.linkedin.campaigns.pause",
10704
+ description: `Stage pausing a campaign (status \u2192 PAUSED on publish). ${MULTI_ID_NOTE}`,
10705
+ args: statusSugarArgs
10706
+ });
10707
+ registerSchema({
10708
+ command: "ads.linkedin.campaigns.resume",
10709
+ description: `Stage resuming a paused campaign (status \u2192 ACTIVE on publish). ${MULTI_ID_NOTE}`,
10710
+ args: statusSugarArgs
10711
+ });
10712
+ registerSchema({
10713
+ command: "ads.linkedin.campaigns.archive",
10714
+ description: `Stage archiving a campaign (status \u2192 ARCHIVED on publish). ${MULTI_ID_NOTE}`,
10715
+ args: statusSugarArgs
10716
+ });
10717
+ registerSchema({
10718
+ command: "ads.linkedin.campaign-groups.pause",
10719
+ description: `Stage pausing a campaign group. ${MULTI_ID_NOTE}`,
10720
+ args: statusSugarArgs
10721
+ });
10722
+ registerSchema({
10723
+ command: "ads.linkedin.campaign-groups.resume",
10724
+ description: `Stage resuming a campaign group. ${MULTI_ID_NOTE}`,
10725
+ args: statusSugarArgs
10726
+ });
10727
+ registerSchema({
10728
+ command: "ads.linkedin.creatives.pause",
10729
+ description: `Stage pausing a creative (intendedStatus \u2192 PAUSED on publish). Playbook early-kill rule: >30 impressions with 0 clicks. ${MULTI_ID_NOTE}`,
10730
+ args: statusSugarArgs
10731
+ });
10732
+ registerSchema({
10733
+ command: "ads.linkedin.creatives.resume",
10734
+ description: `Stage resuming a paused creative. ${MULTI_ID_NOTE}`,
10735
+ args: statusSugarArgs
10736
+ });
10737
+ var duplicateArgs = {
10738
+ id: { type: "positional", description: "Source id or URN", required: true },
10739
+ ...writeAccountArgs,
10740
+ name: { type: "string", description: "Name for the copy", required: false },
10741
+ file: { type: "string", description: "JSON file with field overrides for the copy", required: false }
10742
+ };
10743
+ registerSchema({
10744
+ command: "ads.linkedin.campaigns.duplicate",
10745
+ description: "Stage a copy of an existing campaign as a new DRAFT create (playbook: duplicate, never link-to-original). Reads the source at stage time; override fields via --name/--file.",
10746
+ args: duplicateArgs
10747
+ });
10748
+ registerSchema({
10749
+ command: "ads.linkedin.campaign-groups.duplicate",
10750
+ description: "Stage a copy of an existing campaign group as a new DRAFT create.",
10751
+ args: duplicateArgs
10752
+ });
10753
+ registerSchema({
10754
+ command: "ads.linkedin.creatives.duplicate",
10755
+ description: "Stage a copy of an existing ad, optionally with new content. Direct-content ads (text/spotlight/follower/jobs) copy field-by-field, so a copy is how their immutable content changes (they can also update in place via creatives.update). Post-based ads re-sponsor the SAME post \u2014 the post owns the copy, media and destination, so content flags are rejected here: stage creatives.create with the new content instead, then pause the old ad. --replace stages pausing the original, gated on the new ad publishing successfully, and the copy inherits the source's status.",
10756
+ args: {
10757
+ id: { type: "positional", description: "Source creative id or URN", required: true },
10758
+ ...writeAccountArgs,
10759
+ campaign: { type: "string", description: "Stage the copy under a different ad set", required: false },
10760
+ "intended-status": { type: "string", description: "Status for the copy (default DRAFT)", required: false },
10761
+ replace: { type: "boolean", description: "Also pause the original once the new ad publishes", required: false },
10762
+ headline: { type: "string", description: "New headline", required: false },
10763
+ description: { type: "string", description: "New description (text/spotlight)", required: false },
10764
+ intro: { type: "string", description: "New commentary / intro text", required: false },
10765
+ "landing-url": { type: "string", description: "New https destination URL", required: false },
10766
+ cta: { type: "string", description: "New CTA", required: false },
10767
+ "cta-label": { type: "string", description: "Spotlight custom CTA label", required: false },
10768
+ "image-id": { type: "string", description: "Baker image library id", required: false },
10769
+ "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10770
+ "video-id": { type: "string", description: "Baker video library id", required: false },
10771
+ "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10772
+ "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10773
+ title: { type: "string", description: "Media title", required: false },
10774
+ file: { type: "string", description: "JSON file with field overrides for the copy", required: false }
10964
10775
  }
10965
- return commaSplit(raw) ?? [];
10966
- }
10967
- function sortRows(rows) {
10968
- return [...rows].sort((a, b) => {
10969
- const cs = numberOf(b.costInUsd) - numberOf(a.costInUsd);
10970
- if (cs !== 0) return cs;
10971
- return numberOf(b.impressions) - numberOf(a.impressions);
10972
- });
10973
- }
10974
- function numberOf(v) {
10975
- if (typeof v === "number") return Number.isFinite(v) ? v : 0;
10976
- if (typeof v === "string") {
10977
- const n = Number(v);
10978
- return Number.isFinite(n) ? n : 0;
10776
+ });
10777
+ registerSchema({
10778
+ command: "ads.linkedin.creatives.create",
10779
+ description: "Stage a new creative (ad). Formats: image|video|text|spotlight|follower|document|carousel|conversation|tla|jobs|event|article. Media: --image-id/--video-id reference the Baker library (`baker images`/`baker videos`) and upload to LinkedIn at publish; --image-urn/--video-urn reference media already on LinkedIn. Limits: headline \u226470 (text ads \u226425), intro soft-truncates at 600 chars, landing URL must be https. Complex formats take --file with the full content object; conversation ads take --file with the message flow ({message: {subject, body (\u2264500), senderName?, buttons \u22645 \xD7 {label \u226425, type NESTED|LANDING_PAGE, nestedMessage?|landingPageUrl?}}}, \u226425 messages). Staged until publish.",
10780
+ args: {
10781
+ ...writeAccountArgs,
10782
+ campaign: { type: "string", description: "Campaign id/URN or li_temp_* ref", required: true },
10783
+ format: {
10784
+ type: "string",
10785
+ description: "image|video|text|spotlight|follower|document|carousel|conversation|tla|jobs|event|article",
10786
+ required: true
10787
+ },
10788
+ "image-id": { type: "string", description: "Baker image library id", required: false },
10789
+ "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10790
+ "video-id": { type: "string", description: "Baker video library id", required: false },
10791
+ "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10792
+ "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10793
+ intro: { type: "string", description: "Commentary / intro text", required: false },
10794
+ headline: { type: "string", description: "Headline", required: false },
10795
+ description: { type: "string", description: "Description (text/spotlight)", required: false },
10796
+ "landing-url": { type: "string", description: "https destination URL", required: false },
10797
+ cta: { type: "string", description: "LEARN_MORE|REQUEST_DEMO|SIGN_UP|REGISTER|DOWNLOAD|\u2026", required: false },
10798
+ "cta-label": { type: "string", description: "Spotlight CTA label (\u226418 chars)", required: false },
10799
+ "post-urn": { type: "string", description: "TLA: post to sponsor (urn:li:share|ugcPost:*)", required: false },
10800
+ "event-urn": {
10801
+ type: "string",
10802
+ description: "Event ads: the LinkedIn event to sponsor (urn:li:event:*)",
10803
+ required: false
10804
+ },
10805
+ "article-url": {
10806
+ type: "string",
10807
+ description: "Article ads: https URL of the article/newsletter to sponsor",
10808
+ required: false
10809
+ },
10810
+ "thumbnail-image-id": {
10811
+ type: "string",
10812
+ description: "Article ads: Baker image id for the link thumbnail",
10813
+ required: false
10814
+ },
10815
+ "thumbnail-urn": { type: "string", description: "Article ads: existing urn:li:image:* thumbnail", required: false },
10816
+ "intended-status": { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)", required: false },
10817
+ title: { type: "string", description: "Media title (image/video) or article title", required: false },
10818
+ file: { type: "string", description: "JSON file with the full content object", required: false }
10979
10819
  }
10980
- return 0;
10981
- }
10982
- var analyticsCommand = defineCommand34({
10983
- meta: {
10984
- name: "analytics",
10985
- description: `Performance reporting \u2014 the workhorse for AI agents.
10986
-
10987
- Three-axis design (LinkedIn's superpower over Meta/Google):
10988
- --level account | campaign-group | campaign | creative
10989
- --intent baseline | revenue | funnel | engagement | video | lead-gen | inmail | document | ranking | identity
10990
- --pivot none | job-title | company | industry | seniority | function | company-size | country | region
10991
- | device | placement | serving-location | card-index | objective
10992
- | conversation-node | conversation-node-button
10993
-
10994
- Smart defaults:
10995
- --level account, --intent baseline, --pivot none, --granularity DAILY, last 7 days
10996
- Pivot on a MEMBER_* dim \u2192 granularity auto-forced to ALL (LinkedIn rejects DAILY+demographic)
10997
- Demographic data delayed 12-24h with \u22653-event privacy floor \u2014 the CLI flags this in warnings.
10998
- Derived metrics injected client-side: ctr, cpc, cpm, frequency, leadCompletionRate
10999
-
11000
- Examples \u2014 common AI questions:
11001
- # "How is this account doing this week?"
11002
- baker ads linkedin analytics
11003
-
11004
- # "Who are we reaching, by job title?" (the LinkedIn killer)
11005
- baker ads linkedin analytics --level campaign --campaign-id 1234 --pivot job-title --intent baseline --last-days 30
11006
-
11007
- # "Top companies seeing my ads" (ABM feedback loop)
11008
- baker ads linkedin analytics --level campaign --campaign-id 1234 --pivot company --last-days 30
11009
-
11010
- # "Revenue by campaign over Q1"
11011
- baker ads linkedin analytics --level campaign --campaign-id 1,2,3 --intent revenue --start 2026-01-01 --end 2026-03-31 --granularity MONTHLY
11012
-
11013
- # "How is the lead form converting?"
11014
- baker ads linkedin analytics --level campaign --campaign-id 1234 --intent lead-gen
11015
-
11016
- # Custom field set (escape hatch)
11017
- baker ads linkedin analytics --metrics impressions,clicks,oneClickLeads --intent identity`
11018
- },
10820
+ });
10821
+ registerSchema({
10822
+ command: "ads.linkedin.creatives.update",
10823
+ description: `Stage changes to an existing creative. Direct-content ads (text/spotlight/follower/jobs) update copy/media/URL IN PLACE \u2014 pass only the changed fields (headline, description, landing-url, image-id, \u2026); the ad's current content carries over. Post-based ads (image/video/carousel/\u2026) sponsor a post that owns the copy, media and destination \u2014 neither this command nor creatives.duplicate can change it; stage creatives.create with the new content, then pause the old ad. Passing a li_temp_* ref AMENDS the creative staged in this chat instead. ${MULTI_ID_NOTE} Applies on chat publish.`,
11019
10824
  args: {
11020
- level: { type: "string", description: "Object scope (default: account)" },
11021
- "account-id": { type: "string", description: "Account ID \u2014 numeric or urn:li:sponsoredAccount:N (level=account)" },
11022
- "account-urn": { type: "string", description: "Alias for --account-id (URN form)" },
11023
- "campaign-group-id": { type: "string", description: "Comma-separated IDs (level=campaign-group)" },
11024
- "campaign-id": { type: "string", description: "Comma-separated IDs (level=campaign)" },
11025
- "creative-id": { type: "string", description: "Comma-separated IDs (level=creative)" },
11026
- ids: { type: "string", description: "Generic CSV of IDs at the chosen level (alternative to per-level flags)" },
11027
- intent: {
10825
+ id: {
10826
+ type: "positional",
10827
+ description: "Creative id/URN \u2014 comma-separate several. A li_temp_* ref amends what this chat staged and must be passed on its own.",
10828
+ required: false
10829
+ },
10830
+ "ids-file": {
11028
10831
  type: "string",
11029
- description: "baseline|revenue|funnel|engagement|video|lead-gen|inmail|document|ranking|identity"
10832
+ description: "File of creative ids, one per line or comma-separated",
10833
+ required: false
11030
10834
  },
11031
- pivot: { type: "string", description: "Pivot dim. Default: none. See `--list-pivots`." },
11032
- metrics: { type: "string", description: "CSV metric override \u2014 bypass --intent's bundle" },
11033
- "list-intents": { type: "boolean", description: "Print intent definitions and exit" },
11034
- "list-pivots": { type: "boolean", description: "Print pivot slugs and exit" },
11035
- start: { type: "string", description: "Start date YYYY-MM-DD (overrides --last-days)" },
11036
- end: { type: "string", description: "End date YYYY-MM-DD" },
11037
- "last-days": { type: "string", description: "Window relative to today (default: 7)" },
11038
- granularity: { type: "string", description: "DAILY|MONTHLY|YEARLY|ALL (default: DAILY)" },
11039
- limit: { type: "string", description: "Max rows (default: 1000)" },
11040
- "no-sort": { type: "boolean", description: "Skip default sort by costInUsd desc" },
11041
- "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11042
- output: { type: "string", description: "Output format json|csv|jsonl|md", default: "json" }
11043
- },
11044
- run: async ({ args }) => {
11045
- if (args["list-intents"]) {
11046
- writeAdsJson({ ok: true, data: listIntents() });
11047
- return;
11048
- }
11049
- if (args["list-pivots"]) {
11050
- writeAdsJson({ ok: true, data: ALL_PIVOTS });
11051
- return;
11052
- }
11053
- const level = parseLevel(args.level);
11054
- const intent = parseIntent(args.intent);
11055
- const pivot = parsePivot(args.pivot);
11056
- let granularity = parseGranularity(args.granularity);
11057
- if (MEMBER_PIVOTS.has(pivot) && granularity === "DAILY") {
11058
- process.stderr.write(`note: pivot=${pivot} forces --granularity ALL (LinkedIn rejects DAILY + demographic).
11059
- `);
11060
- granularity = "ALL";
11061
- }
11062
- const ids = resolveScopeIds(args, level);
11063
- if (ids.length === 0) {
11064
- handleLinkedinError(new Error(`No IDs resolved for --level ${level}`));
11065
- }
11066
- const range = resolveDateRange(args);
11067
- const metrics = commaSplit(args.metrics);
11068
- const limit = args.limit ? Number(args.limit) : 1e3;
11069
- const request = {
11070
- level,
11071
- ids,
11072
- intent,
11073
- pivot,
11074
- metrics,
11075
- start: range.start,
11076
- end: range.end,
11077
- granularity,
11078
- limit
11079
- };
11080
- try {
11081
- const data = await apiPost("/api/ads/linkedin/analytics", {
11082
- request,
11083
- skipCache: Boolean(args["skip-cache"])
11084
- });
11085
- const rows = args["no-sort"] ? data.rows : sortRows(data.rows);
11086
- const fmt = csvOrJson2(args);
11087
- if (fmt !== "json") {
11088
- writeAdsOutput(rows, fmt, data.fields);
11089
- return;
11090
- }
11091
- writeAdsJson({
11092
- ok: true,
11093
- data: {
11094
- rows,
11095
- fields: data.fields,
11096
- warnings: data.warnings,
11097
- query: data.query,
11098
- // Echo composed fields so an offline agent can pre-flight w/o this network call.
11099
- composedFields: composeFields(intent, level, metrics)
11100
- }
11101
- });
11102
- } catch (err) {
11103
- handleLinkedinError(err);
11104
- }
10835
+ ...writeAccountArgs,
10836
+ "intended-status": { type: "string", description: "ACTIVE|PAUSED|ARCHIVED|DRAFT", required: false },
10837
+ name: { type: "string", description: "New creative name", required: false },
10838
+ campaign: {
10839
+ type: "string",
10840
+ description: "Re-parent to a different ad set (id/URN or li_temp_*) \u2014 staged creatives only",
10841
+ required: false
10842
+ },
10843
+ headline: { type: "string", description: "New headline", required: false },
10844
+ intro: { type: "string", description: "Commentary / intro text", required: false },
10845
+ description: { type: "string", description: "Description (text/spotlight)", required: false },
10846
+ "landing-url": { type: "string", description: "New https destination URL", required: false },
10847
+ cta: { type: "string", description: "New CTA", required: false },
10848
+ "cta-label": { type: "string", description: "Spotlight custom CTA label", required: false },
10849
+ "image-id": { type: "string", description: "Baker image library id", required: false },
10850
+ "image-urn": { type: "string", description: "Existing urn:li:image:*", required: false },
10851
+ "video-id": { type: "string", description: "Baker video library id", required: false },
10852
+ "video-urn": { type: "string", description: "Existing urn:li:video:*", required: false },
10853
+ "logo-image-id": { type: "string", description: "Baker image id for the logo", required: false },
10854
+ "thumbnail-image-id": {
10855
+ type: "string",
10856
+ description: "Baker image id for the article thumbnail",
10857
+ required: false
10858
+ },
10859
+ "thumbnail-urn": { type: "string", description: "Existing urn:li:image:* thumbnail", required: false },
10860
+ title: { type: "string", description: "Media or article title", required: false },
10861
+ file: { type: "string", description: "JSON file with fields to change; flags override file keys", required: false }
11105
10862
  }
11106
10863
  });
11107
-
11108
- // src/commands/ads/linkedin/audience-size.ts
11109
- import { readFileSync as readFileSync4 } from "fs";
11110
- import { defineCommand as defineCommand35 } from "citty";
11111
- function loadTargeting(args) {
11112
- const inline = args.targeting;
11113
- if (inline) {
11114
- try {
11115
- return JSON.parse(inline);
11116
- } catch {
11117
- handleLinkedinError(new Error("--targeting must be valid JSON. See LinkedIn 'targetingCriteria' shape."));
11118
- }
10864
+ registerSchema({
10865
+ command: "ads.linkedin.audiences.create",
10866
+ description: "Stage a new matched audience. company-list/user-list take --list-file (CSV with header; companyName,domain for companies; email column is SHA-256 hashed locally). LinkedIn needs 300+ MATCHED members to serve \u2014 upload well above that. retargeting/engagement configs go in --file. Staged until publish.",
10867
+ args: {
10868
+ ...writeAccountArgs,
10869
+ name: { type: "string", description: "Audience name (\u2264100 chars)", required: true },
10870
+ type: { type: "string", description: "company-list|user-list|retargeting|engagement", required: true },
10871
+ "list-file": { type: "string", description: "CSV file with the list rows", required: false },
10872
+ file: { type: "string", description: "JSON raw source config for retargeting/engagement", required: false }
11119
10873
  }
11120
- const file = args["targeting-file"];
11121
- if (file) {
11122
- try {
11123
- return JSON.parse(readFileSync4(file, "utf-8"));
11124
- } catch (e) {
11125
- handleLinkedinError(
11126
- new Error(`Failed to read --targeting-file: ${e instanceof Error ? e.message : "I/O error"}`)
11127
- );
10874
+ });
10875
+ registerSchema({
10876
+ command: "ads.linkedin.audiences.upload",
10877
+ description: "Stage adding rows to an existing audience segment (or a li_temp_* segment staged in this chat). Same CSV rules as audiences.create.",
10878
+ args: {
10879
+ id: { type: "positional", description: "Segment URN/id or li_temp_* ref", required: true },
10880
+ ...writeAccountArgs,
10881
+ "list-file": { type: "string", description: "CSV file with the rows to add", required: true }
10882
+ }
10883
+ });
10884
+ registerSchema({
10885
+ command: "ads.linkedin.conversions.create",
10886
+ description: "Stage a new conversion rule. Playbook defaults: --post-click-window 30 --view-window 7, counting ONE_TIME_EACH_MEMBER for leads. Staged until publish.",
10887
+ args: {
10888
+ ...writeAccountArgs,
10889
+ name: { type: "string", description: "Rule name", required: true },
10890
+ type: {
10891
+ type: "string",
10892
+ description: "LEAD|SIGN_UP|DOWNLOAD|PURCHASE|KEY_PAGE_VIEW|ADD_TO_CART|INSTALL|OTHER",
10893
+ required: true
10894
+ },
10895
+ method: { type: "string", description: "INSIGHT_TAG|CONVERSIONS_API", required: true },
10896
+ "post-click-window": { type: "string", description: "1|7|30|90 days", required: false },
10897
+ "view-window": { type: "string", description: "1|7|30 days", required: false },
10898
+ "attribution-type": {
10899
+ type: "string",
10900
+ description: "LAST_TOUCH_BY_CAMPAIGN|LAST_TOUCH_BY_CONVERSION",
10901
+ required: false
10902
+ },
10903
+ "associate-all-campaigns": {
10904
+ type: "boolean",
10905
+ description: "Auto-associate with active campaigns",
10906
+ required: false
10907
+ },
10908
+ file: { type: "string", description: "JSON payload file (e.g. urlMatchRuleExpression)", required: false }
10909
+ }
10910
+ });
10911
+ registerSchema({
10912
+ command: "ads.linkedin.conversions.update",
10913
+ description: "Stage changes to a conversion rule (windows, attribution, enabled, name). Applies on chat publish.",
10914
+ args: {
10915
+ id: { type: "positional", description: "Conversion rule id or URN", required: true },
10916
+ ...writeAccountArgs,
10917
+ name: { type: "string", description: "New name", required: false },
10918
+ "post-click-window": { type: "string", description: "1|7|30|90 days", required: false },
10919
+ "view-window": { type: "string", description: "1|7|30 days", required: false },
10920
+ "attribution-type": { type: "string", description: "New attribution type", required: false },
10921
+ enabled: { type: "string", description: "on|off", required: false },
10922
+ file: { type: "string", description: "JSON file with fields to change", required: false }
10923
+ }
10924
+ });
10925
+ registerSchema({
10926
+ command: "ads.linkedin.lead-forms.create",
10927
+ description: "Stage a new Lead Gen Form from a JSON file: {name, headline \u226460, description? \u2264160, privacyPolicyUrl, questions[] \u226412 (playbook: \u22644 for completion), thankYou?, legalDisclaimer?}. Staged until publish.",
10928
+ args: {
10929
+ ...writeAccountArgs,
10930
+ file: { type: "string", description: "JSON form definition file", required: true },
10931
+ name: { type: "string", description: "Override the form name", required: false }
10932
+ }
10933
+ });
10934
+ registerSchema({
10935
+ command: "ads.linkedin.lead-forms.update",
10936
+ description: "Stage changes to a Lead Gen Form from a JSON file. Applies on chat publish.",
10937
+ args: {
10938
+ id: { type: "positional", description: "Lead form id or URN", required: true },
10939
+ ...writeAccountArgs,
10940
+ file: { type: "string", description: "JSON file with the fields to change", required: true },
10941
+ name: { type: "string", description: "New form name", required: false }
10942
+ }
10943
+ });
10944
+ registerSchema({
10945
+ command: "ads.linkedin.draft.list",
10946
+ description: "Show every LinkedIn write op staged in this chat: ref, summary, dependencies, warnings, mode (simulated = publish will NOT hit LinkedIn), and post-publish results (applied/simulated/failed/skipped). Ops are invisible to the read commands until publish \u2014 check here before finishing.",
10947
+ args: {
10948
+ json: {
10949
+ type: "boolean",
10950
+ description: "Print the raw JSON envelope instead of the readable tree",
10951
+ required: false
11128
10952
  }
11129
10953
  }
11130
- handleLinkedinError(new Error("Pass --targeting-file <path> or --targeting '{...JSON...}'"));
11131
- }
11132
- var audienceSizeCommand = defineCommand35({
11133
- meta: {
11134
- name: "audience-size",
11135
- description: `Estimate audience size for a targeting payload \u2014 pre-launch sanity check.
11136
-
11137
- Returns total + active counts plus playbook \xA704 warnings:
11138
- size <20K \u2192 too narrow (will not deliver)
11139
- size <50K \u2192 outside the 50K\u2013500K sweet spot
11140
- size >500K \u2192 outside the sweet spot
11141
- size >1M \u2192 too broad, refine ICP
11142
-
11143
- The targeting payload must follow LinkedIn's 'targetingCriteria' shape \u2014 an
11144
- include/exclude tree of and/or boolean operators with adTargetingFacet keys.
11145
- The simplest form:
11146
- { "include": { "and": [{ "or": { "urn:li:adTargetingFacet:locations": ["urn:li:geo:103644278"] }}]}}
11147
-
11148
- Examples:
11149
- baker ads linkedin audience-size --account-id 503001492 --targeting-file targeting.json
11150
- baker ads linkedin audience-size --account-id 503001492 --targeting '{"include":{"and":[]}}'`
11151
- },
10954
+ });
10955
+ registerSchema({
10956
+ command: "ads.linkedin.draft.remove",
10957
+ description: "Drop one staged LinkedIn op from this chat's draft. Removing a create cascades to every op that depends on it (a campaign removal drops its staged creatives).",
11152
10958
  args: {
11153
- "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11154
- "account-urn": { type: "string", description: "Alias for --account-id" },
11155
- targeting: { type: "string", description: "Inline JSON targetingCriteria payload" },
11156
- "targeting-file": { type: "string", description: "Path to JSON file with targetingCriteria" },
11157
- "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11158
- output: { type: "string", description: "json", default: "json" }
11159
- },
11160
- run: async ({ args }) => {
11161
- const accountId = resolveAccountIdArg2(args);
11162
- const targetingCriteria = loadTargeting(args);
11163
- try {
11164
- const data = await apiPost("/api/ads/linkedin/audience-size", {
11165
- accountId,
11166
- targetingCriteria,
11167
- skipCache: Boolean(args["skip-cache"])
11168
- });
11169
- writeAdsJson({ ok: true, data });
11170
- } catch (err) {
11171
- handleLinkedinError(err);
10959
+ ref: {
10960
+ type: "positional",
10961
+ description: "Op ref (li_temp_* for creates, target id/URN for updates)",
10962
+ required: true
11172
10963
  }
11173
10964
  }
11174
10965
  });
10966
+ registerSchema({
10967
+ command: "ads.linkedin.draft.clear",
10968
+ description: "Discard ALL staged LinkedIn ops in this chat's draft. Nothing applies on publish.",
10969
+ args: {}
10970
+ });
11175
10971
 
11176
- // src/commands/ads/linkedin/audit.ts
11177
- import { defineCommand as defineCommand36 } from "citty";
11178
- var SEVERITY_RANK = {
11179
- critical: 0,
11180
- high: 1,
11181
- medium: 2,
11182
- low: 3
11183
- };
11184
- var STATUS_RANK = {
11185
- fail: 0,
11186
- warn: 1,
11187
- partial: 2,
11188
- pass: 3,
11189
- n_a: 4
11190
- };
11191
- function filterFindings(findings, severity, area) {
11192
- return findings.filter((f) => {
11193
- if (severity && !severity.includes(f.severity)) return false;
11194
- if (area && !area.includes(f.area)) return false;
11195
- return true;
11196
- });
11197
- }
11198
- function sortFindings(findings) {
11199
- return [...findings].sort((a, b) => {
11200
- const sa = STATUS_RANK[a.status] - STATUS_RANK[b.status];
11201
- if (sa !== 0) return sa;
11202
- return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
11203
- });
11204
- }
11205
- function renderMarkdown(result) {
11206
- const lines = [];
11207
- lines.push(`# LinkedIn Ads Audit \u2014 ${result.account.name} (${result.account.id})`);
11208
- lines.push("");
11209
- lines.push(
11210
- `**Scope:** ${result.scope.campaigns} campaigns, ${result.scope.creatives} creatives, last ${result.scope.windowDays}d`
11211
- );
11212
- lines.push("");
11213
- lines.push(
11214
- `**Summary:** ${result.summary.pass} pass \u2022 ${result.summary.critical} critical \u2022 ${result.summary.high} high \u2022 ${result.summary.medium} medium \u2022 ${result.summary.low} low \u2022 ${result.summary.n_a} n/a (${result.summary.totalChecks} total)`
11215
- );
11216
- lines.push("");
11217
- lines.push("| # | Area | Check | Status | Severity | Notes |");
11218
- lines.push("|---|------|-------|--------|----------|-------|");
11219
- result.findings.forEach((f, idx) => {
11220
- const note = noteOf(f);
11221
- lines.push(
11222
- `| ${idx + 1} | ${f.area} | ${escapeMd(f.check)} | ${f.status.toUpperCase()} | ${f.severity} | ${escapeMd(note)} |`
11223
- );
11224
- });
11225
- return lines.join("\n");
11226
- }
11227
- function escapeMd(text) {
11228
- return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
11229
- }
11230
- function noteOf(f) {
11231
- if (f.status === "pass") return "";
11232
- const ev = f.evidence ? JSON.stringify(f.evidence) : "";
11233
- const fix = f.fix?.explanation ?? "";
11234
- return [fix, ev].filter(Boolean).join(" \u2014 ");
11235
- }
11236
- var auditCommand = defineCommand36({
10972
+ // src/commands/ads/linkedin/account.ts
10973
+ import { defineCommand as defineCommand32 } from "citty";
10974
+ var accountCommand = defineCommand32({
11237
10975
  meta: {
11238
- name: "audit",
11239
- description: `Run a LinkedIn Ads playbook audit \u2014 30+ checks across Settings, Tracking,
11240
- Audience, Campaigns, Creative, Performance, Bidding, and Compliance.
11241
-
11242
- Each finding has {id, area, check, status, severity, evidence, fix} fields with
11243
- playbook citations. The default JSON output is agent-friendly; --format md
11244
- renders a deliverable-ready table that mirrors the google-ads-playbook
11245
- 10-audit-summary.md style.
11246
-
11247
- Exit code 0 always. Use the JSON 'summary' counts to decide if action is needed.
10976
+ name: "account",
10977
+ description: `Single LinkedIn ad account detail (currency, status, type).
11248
10978
 
11249
10979
  Examples:
11250
- baker ads linkedin audit --account-id 503001492
11251
- baker ads linkedin audit --account-id 503001492 --campaign-id 1234 # narrow scope
11252
- baker ads linkedin audit --account-id 503001492 --window-days 90
11253
- baker ads linkedin audit --account-id 503001492 --severity critical,high
11254
- baker ads linkedin audit --account-id 503001492 --area Settings,Tracking
11255
- baker ads linkedin audit --account-id 503001492 --format md`
10980
+ baker ads linkedin account --account-id 503001492
10981
+ baker ads linkedin account --account-urn urn:li:sponsoredAccount:503001492 --output md`
11256
10982
  },
11257
10983
  args: {
11258
- "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11259
- "account-urn": { type: "string", description: "Alias for --account-id" },
11260
- "campaign-id": { type: "string", description: "Narrow scope to a single campaign" },
11261
- "window-days": { type: "string", description: "Lookback window for performance checks (default: 30)" },
11262
- severity: { type: "string", description: "CSV severity filter: critical,high,medium,low" },
11263
- area: {
11264
- type: "string",
11265
- description: "CSV area filter: Settings,Tracking,Audience,Campaigns,Creative,Performance,Bidding,Compliance,Hygiene"
11266
- },
11267
- format: { type: "string", description: "Output format: json | md (default: json)" },
11268
- "skip-cache": { type: "boolean", description: "Bypass server-side cache" }
10984
+ "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
10985
+ "account-urn": { type: "string", description: "Alias for --account-id" },
10986
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
10987
+ output: { type: "string", description: "json|csv|jsonl|md", default: "json" }
11269
10988
  },
11270
10989
  run: async ({ args }) => {
11271
10990
  const accountId = resolveAccountIdArg2(args);
11272
- const windowDays = args["window-days"] ? Number(args["window-days"]) : 30;
11273
- if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) {
11274
- handleLinkedinError(new Error("--window-days must be 1..365"));
11275
- }
11276
10991
  try {
11277
- const data = await apiPost("/api/ads/linkedin/audit", {
11278
- accountId,
11279
- campaignId: args["campaign-id"] ? String(args["campaign-id"]) : void 0,
11280
- windowDays,
11281
- skipCache: Boolean(args["skip-cache"])
11282
- });
11283
- const severityFilter = args.severity?.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
11284
- const areaFilter = args.area?.split(",").map((s) => s.trim()).filter(Boolean);
11285
- const filtered = filterFindings(data.findings, severityFilter, areaFilter);
11286
- const sorted = sortFindings(filtered);
11287
- const result = { ...data, findings: sorted };
11288
- const fmt = args.format ?? "json";
11289
- if (fmt === "md") {
11290
- process.stdout.write(`${renderMarkdown(result)}
11291
- `);
10992
+ const params = { "account-id": accountId };
10993
+ if (args["skip-cache"]) params["skip-cache"] = "true";
10994
+ const data = await apiGet("/api/ads/linkedin/account", params);
10995
+ const fmt = csvOrJson2(args);
10996
+ if (fmt !== "json") {
10997
+ writeAdsOutput([data], fmt);
11292
10998
  return;
11293
10999
  }
11294
- writeAdsJson({ ok: true, data: result });
11000
+ writeAdsJson({ ok: true, data });
11295
11001
  } catch (err) {
11296
11002
  handleLinkedinError(err);
11297
11003
  }
11298
11004
  }
11299
11005
  });
11300
11006
 
11301
- // src/commands/ads/linkedin/bid-pricing.ts
11302
- import { readFileSync as readFileSync5 } from "fs";
11303
- import { defineCommand as defineCommand37 } from "citty";
11304
- function loadTargeting2(args) {
11305
- const inline = args.targeting;
11306
- if (inline) {
11307
- try {
11308
- return JSON.parse(inline);
11309
- } catch {
11310
- handleLinkedinError(new Error("--targeting must be valid JSON. See LinkedIn 'targetingCriteria' shape."));
11311
- }
11312
- }
11313
- const file = args["targeting-file"];
11314
- if (file) {
11315
- try {
11316
- return JSON.parse(readFileSync5(file, "utf-8"));
11317
- } catch (e) {
11318
- handleLinkedinError(
11319
- new Error(`Failed to read --targeting-file: ${e instanceof Error ? e.message : "I/O error"}`)
11320
- );
11321
- }
11007
+ // src/commands/ads/linkedin/accounts.ts
11008
+ import { defineCommand as defineCommand33 } from "citty";
11009
+ var ACCOUNTS_TTL_MS = 60 * 60 * 1e3;
11010
+ function accountHints2(accounts, includeAll) {
11011
+ if (includeAll) {
11012
+ return [];
11322
11013
  }
11323
- handleLinkedinError(new Error("Pass --targeting-file <path> or --targeting '{...JSON...}'"));
11014
+ return connectedResourceHints({
11015
+ plural: "LinkedIn ad accounts",
11016
+ singular: "LinkedIn ad account",
11017
+ flag: "--account-id",
11018
+ resources: accounts.map((account) => ({ id: account.externalId, label: account.name }))
11019
+ });
11324
11020
  }
11325
- var bidPricingCommand = defineCommand37({
11021
+ var accountsCommand2 = defineCommand33({
11326
11022
  meta: {
11327
- name: "bid-pricing",
11328
- description: `Get LinkedIn's suggested bid range for a targeting + objective + cost type.
11329
-
11330
- Returns LinkedIn's min / suggested / max bid plus the playbook \xA706 floor
11331
- (2/3 of suggested \u2014 the recommended manual CPC starting point).
11332
-
11333
- Objective types: BRAND_AWARENESS | WEBSITE_TRAFFIC | WEBSITE_VISIT |
11334
- ENGAGEMENT | WEBSITE_CONVERSION | LEAD_GENERATION |
11335
- JOB_APPLICANT | VIDEO_VIEW
11336
- Cost types: CPC | CPM | CPV | CPS
11023
+ name: "accounts",
11024
+ description: `List LinkedIn ad accounts in this company's connected scope.
11337
11025
 
11338
11026
  Examples:
11339
- baker ads linkedin bid-pricing --account-id 503001492 --objective WEBSITE_CONVERSION --cost-type CPC --targeting-file targeting.json
11340
- baker ads linkedin bid-pricing --account-id 503001492 --objective LEAD_GENERATION --cost-type CPM --targeting '{"include":...}'`
11027
+ baker ads linkedin accounts
11028
+ baker ads linkedin accounts --include-all # ignore picker scope, list every account the token can see
11029
+ baker ads linkedin accounts --output csv`
11341
11030
  },
11342
11031
  args: {
11343
- "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11344
- "account-urn": { type: "string", description: "Alias for --account-id" },
11345
- objective: {
11346
- type: "string",
11347
- description: "Objective type (e.g. WEBSITE_CONVERSION, LEAD_GENERATION)",
11348
- required: true
11349
- },
11350
- "cost-type": { type: "string", description: "CPC | CPM | CPV | CPS", required: true },
11351
- targeting: { type: "string", description: "Inline JSON targetingCriteria" },
11352
- "targeting-file": { type: "string", description: "Path to JSON file with targetingCriteria" },
11353
- "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11354
- output: { type: "string", description: "json", default: "json" }
11032
+ "include-all": { type: "boolean", description: "List all accessible accounts, ignoring picker scope" },
11033
+ "no-cache": { type: "boolean", description: "Skip CLI-side cache" },
11034
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache (force re-fetch)" },
11035
+ output: { type: "string", description: "Output format: json|csv|jsonl|md", default: "json" }
11355
11036
  },
11356
11037
  run: async ({ args }) => {
11357
- const accountId = resolveAccountIdArg2(args);
11358
- const targetingCriteria = loadTargeting2(args);
11038
+ const includeAll = args["include-all"] === true;
11039
+ const useCache = !args["no-cache"];
11040
+ const cacheKey = `accounts:${includeAll ? "all" : "scoped"}`;
11041
+ if (useCache) {
11042
+ const cached = cacheGet("linkedin-accounts", cacheKey);
11043
+ if (cached) {
11044
+ writeJsonEnvelope({
11045
+ ok: true,
11046
+ data: cached.data,
11047
+ cached: true,
11048
+ hints: accountHints2(cached.data, includeAll)
11049
+ });
11050
+ return;
11051
+ }
11052
+ }
11359
11053
  try {
11360
- const data = await apiPost("/api/ads/linkedin/bid-pricing", {
11361
- accountId,
11362
- objectiveType: String(args.objective),
11363
- costType: String(args["cost-type"]).toUpperCase(),
11364
- targetingCriteria,
11365
- skipCache: Boolean(args["skip-cache"])
11366
- });
11367
- writeAdsJson({ ok: true, data });
11054
+ const params = {};
11055
+ if (includeAll) params["include-all"] = "true";
11056
+ if (args["skip-cache"]) params["skip-cache"] = "true";
11057
+ const data = await apiGet("/api/ads/linkedin/accounts", params);
11058
+ if (useCache) {
11059
+ cacheSet("linkedin-accounts", cacheKey, data, ACCOUNTS_TTL_MS);
11060
+ }
11061
+ const fmt = csvOrJson2(args);
11062
+ if (fmt !== "json") {
11063
+ writeAdsOutput(data, fmt);
11064
+ return;
11065
+ }
11066
+ writeJsonEnvelope({ ok: true, data, hints: accountHints2(data, includeAll) });
11368
11067
  } catch (err) {
11369
11068
  handleLinkedinError(err);
11370
11069
  }
11371
11070
  }
11372
11071
  });
11373
11072
 
11374
- // src/commands/ads/linkedin/campaign-groups.ts
11375
- import { defineCommand as defineCommand39 } from "citty";
11376
-
11377
- // src/commands/ads/linkedin/write-commands.ts
11378
- import { defineCommand as defineCommand38 } from "citty";
11073
+ // src/commands/ads/linkedin/analytics.ts
11074
+ import { defineCommand as defineCommand34 } from "citty";
11379
11075
 
11380
- // src/commands/ads/linkedin/write-shared.ts
11381
- import { createHash } from "crypto";
11382
- import { readFileSync as readFileSync6 } from "fs";
11383
- function bareAccountId(args) {
11384
- const raw = resolveAccountIdArg2(args);
11385
- return raw.replace(/^urn:li:sponsoredAccount:/, "").replace(/^sponsoredAccount:/, "");
11076
+ // src/commands/ads/linkedin/presets.ts
11077
+ var INTENTS = {
11078
+ baseline: {
11079
+ name: "baseline",
11080
+ description: "Spend, impressions, clicks. The default 'how is delivery?' question. Derives CTR, CPC, CPM.",
11081
+ metrics: ["impressions", "clicks", "costInUsd", "costInLocalCurrency", "approximateUniqueImpressions"],
11082
+ derived: ["ctr", "cpc", "cpm", "frequency"]
11083
+ },
11084
+ revenue: {
11085
+ name: "revenue",
11086
+ description: "Spend, conversions, conversion value. 'How much money are we making?'",
11087
+ metrics: [
11088
+ "costInUsd",
11089
+ "externalWebsiteConversions",
11090
+ "externalWebsitePostClickConversions",
11091
+ "externalWebsitePostViewConversions",
11092
+ "conversionValueInLocalCurrency",
11093
+ "oneClickLeads"
11094
+ ]
11095
+ },
11096
+ funnel: {
11097
+ name: "funnel",
11098
+ description: "Impression \u2192 click \u2192 landing \u2192 form open \u2192 lead \u2192 conversion. 'Where do users drop off?'",
11099
+ metrics: [
11100
+ "impressions",
11101
+ "clicks",
11102
+ "landingPageClicks",
11103
+ "oneClickLeadFormOpens",
11104
+ "oneClickLeads",
11105
+ "externalWebsiteConversions",
11106
+ "costInUsd"
11107
+ ],
11108
+ derived: ["ctr", "leadCompletionRate"]
11109
+ },
11110
+ engagement: {
11111
+ name: "engagement",
11112
+ description: "Likes, comments, shares, follows + viral spillover. TLA-aware \u2014 measures organic reach effect.",
11113
+ metrics: [
11114
+ "impressions",
11115
+ "likes",
11116
+ "comments",
11117
+ "shares",
11118
+ "reactions",
11119
+ "follows",
11120
+ "totalEngagements",
11121
+ "viralImpressions",
11122
+ "viralLikes",
11123
+ "viralShares",
11124
+ "viralOneClickLeads",
11125
+ "costInUsd"
11126
+ ]
11127
+ },
11128
+ video: {
11129
+ name: "video",
11130
+ description: "Video watch quartiles + cost. 'How are video creatives holding attention?'",
11131
+ metrics: [
11132
+ "videoStarts",
11133
+ "videoFirstQuartileCompletions",
11134
+ "videoMidpointCompletions",
11135
+ "videoThirdQuartileCompletions",
11136
+ "videoCompletions",
11137
+ "videoViews",
11138
+ "fullScreenPlays",
11139
+ "costInUsd",
11140
+ "impressions"
11141
+ ]
11142
+ },
11143
+ "lead-gen": {
11144
+ name: "lead-gen",
11145
+ description: "Lead Gen Form opens + completions. CLI derives form-completion-rate.",
11146
+ metrics: [
11147
+ "oneClickLeadFormOpens",
11148
+ "oneClickLeads",
11149
+ "viralOneClickLeads",
11150
+ "viralOneClickLeadFormOpens",
11151
+ "costInUsd"
11152
+ ],
11153
+ derived: ["leadCompletionRate"]
11154
+ },
11155
+ inmail: {
11156
+ name: "inmail",
11157
+ description: "Sponsored Message / Conversation Ads \u2014 sends, opens, clicks, lead-gen taps.",
11158
+ metrics: [
11159
+ "sends",
11160
+ "opens",
11161
+ "clicks",
11162
+ "costInUsd",
11163
+ "leadGenerationMailContactInfoShares",
11164
+ "leadGenerationMailInterestedClicks"
11165
+ ]
11166
+ },
11167
+ document: {
11168
+ name: "document",
11169
+ description: "Document Ads \u2014 quartile read-through, completion, downloads.",
11170
+ metrics: [
11171
+ "documentFirstQuartileCompletions",
11172
+ "documentMidpointCompletions",
11173
+ "documentThirdQuartileCompletions",
11174
+ "documentCompletions",
11175
+ "downloadClicks",
11176
+ "costInUsd",
11177
+ "impressions"
11178
+ ]
11179
+ },
11180
+ ranking: {
11181
+ name: "ranking",
11182
+ description: "Frequency + reach metrics for fatigue detection. CLI derives frequency = impressions/uniqueImpressions.",
11183
+ metrics: ["impressions", "clicks", "costInUsd", "approximateUniqueImpressions"],
11184
+ derived: ["frequency", "ctr"]
11185
+ },
11186
+ identity: {
11187
+ name: "identity",
11188
+ description: "Just spend + impressions. Cheap roll-up.",
11189
+ metrics: ["costInUsd", "impressions"]
11190
+ }
11191
+ };
11192
+ var MEMBER_PIVOTS = /* @__PURE__ */ new Set([
11193
+ "company",
11194
+ "job-title",
11195
+ "job-function",
11196
+ "seniority",
11197
+ "industry",
11198
+ "company-size",
11199
+ "country",
11200
+ "region"
11201
+ ]);
11202
+ var ALL_PIVOTS = [
11203
+ "none",
11204
+ "campaign",
11205
+ "campaign-group",
11206
+ "creative",
11207
+ "company",
11208
+ "account",
11209
+ "conversion",
11210
+ "job-title",
11211
+ "job-function",
11212
+ "seniority",
11213
+ "industry",
11214
+ "company-size",
11215
+ "country",
11216
+ "region",
11217
+ "device",
11218
+ "placement",
11219
+ "serving-location",
11220
+ "card-index",
11221
+ "objective",
11222
+ "conversation-node",
11223
+ "conversation-node-button"
11224
+ ];
11225
+ var ALL_LEVELS = ["account", "campaign-group", "campaign", "creative"];
11226
+ function listIntents() {
11227
+ return Object.values(INTENTS);
11386
11228
  }
11387
- function failWriteValidation2(message) {
11388
- writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
11389
- process.exit(1);
11229
+ function isPivot(slug) {
11230
+ return ALL_PIVOTS.includes(slug);
11390
11231
  }
11391
- function loadJsonFileArg2(path28) {
11392
- if (typeof path28 !== "string" || path28.length === 0) {
11393
- return {};
11394
- }
11395
- try {
11396
- const parsed = JSON.parse(readFileSync6(path28, "utf8"));
11397
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
11398
- failWriteValidation2(`${path28} must contain a JSON object`);
11399
- }
11400
- return parsed;
11401
- } catch (err) {
11402
- if (err instanceof SyntaxError) {
11403
- failWriteValidation2(`${path28} is not valid JSON: ${err.message}`);
11404
- }
11405
- throw err;
11406
- }
11232
+ function isLevel(slug) {
11233
+ return ALL_LEVELS.includes(slug);
11407
11234
  }
11408
- function mergePayload2(file, flags) {
11409
- const merged = { ...file };
11410
- for (const [key, value] of Object.entries(flags)) {
11411
- if (value !== void 0) {
11412
- merged[key] = value;
11235
+ function composeFields(intent, _level, metricsOverride) {
11236
+ const base = metricsOverride && metricsOverride.length > 0 ? metricsOverride : INTENTS[intent].metrics;
11237
+ const seen = /* @__PURE__ */ new Set();
11238
+ const out = [];
11239
+ for (const f of ["pivotValues", "dateRange", ...base]) {
11240
+ if (!seen.has(f)) {
11241
+ seen.add(f);
11242
+ out.push(f);
11413
11243
  }
11414
11244
  }
11415
- return merged;
11245
+ return out;
11416
11246
  }
11417
- function loadPatchArg2(args) {
11418
- const file = typeof args.file === "string" && args.file.length > 0 ? args.file : void 0;
11419
- const inline = typeof args.patch === "string" && args.patch.length > 0 ? args.patch : void 0;
11420
- if (Boolean(file) === Boolean(inline)) {
11421
- failWriteValidation2("pass exactly one of --file <patch.json> or --patch '<json>'");
11422
- }
11423
- if (file) {
11424
- return loadJsonFileArg2(file);
11425
- }
11426
- try {
11427
- const parsed = JSON.parse(inline);
11428
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
11429
- failWriteValidation2("--patch must be a JSON object");
11430
- }
11431
- return parsed;
11432
- } catch (err) {
11433
- if (err instanceof SyntaxError) {
11434
- failWriteValidation2(`--patch is not valid JSON: ${err.message}`);
11435
- }
11436
- throw err;
11437
- }
11247
+
11248
+ // src/commands/ads/linkedin/analytics.ts
11249
+ function commaSplit(input) {
11250
+ if (!input) return void 0;
11251
+ return input.split(",").map((s) => s.trim()).filter(Boolean);
11438
11252
  }
11439
- function parseMoneyFlag(amount, currency, flag) {
11440
- if (amount === void 0 || amount === null || amount === "") {
11441
- return void 0;
11442
- }
11443
- if (currency === void 0 || currency === null || currency === "") {
11444
- return { amount: String(amount) };
11445
- }
11446
- if (typeof currency !== "string" || currency.length !== 3) {
11447
- failWriteValidation2(`${flag} got an invalid --currency (expected a 3-letter code like EUR)`);
11253
+ function parseGranularity(raw) {
11254
+ const v = (raw ?? "DAILY").toUpperCase();
11255
+ if (v === "DAILY" || v === "MONTHLY" || v === "YEARLY" || v === "ALL") {
11256
+ return v;
11448
11257
  }
11449
- return { amount: String(amount), currencyCode: currency.toUpperCase() };
11258
+ handleLinkedinError(new Error(`Invalid --granularity "${raw}". Use DAILY | MONTHLY | YEARLY | ALL.`));
11450
11259
  }
11451
- function parseDateFlag(value, flag) {
11452
- if (value === void 0 || value === null || value === "") {
11453
- return void 0;
11454
- }
11455
- const raw = String(value);
11456
- if (/^\d{10,}$/.test(raw)) {
11457
- return Number(raw);
11458
- }
11459
- const parsed = Date.parse(raw);
11460
- if (Number.isNaN(parsed)) {
11461
- failWriteValidation2(`${flag} must be an ISO date (2026-07-10) or epoch milliseconds`);
11260
+ function parseLevel(raw) {
11261
+ const v = raw ?? "account";
11262
+ if (!isLevel(v)) {
11263
+ handleLinkedinError(new Error(`Invalid --level "${raw}". Use one of: ${ALL_LEVELS.join(", ")}.`));
11462
11264
  }
11463
- return parsed;
11265
+ return v;
11464
11266
  }
11465
- function parseOnOffFlag(value, flag) {
11466
- if (value === void 0 || value === null || value === "") {
11467
- return void 0;
11468
- }
11469
- const raw = String(value).toLowerCase();
11470
- if (raw === "on" || raw === "true") {
11471
- return true;
11472
- }
11473
- if (raw === "off" || raw === "false") {
11474
- return false;
11267
+ function parsePivot(raw) {
11268
+ const v = raw ?? "none";
11269
+ if (!isPivot(v)) {
11270
+ handleLinkedinError(new Error(`Invalid --pivot "${raw}". Use one of: ${ALL_PIVOTS.join(", ")}.`));
11475
11271
  }
11476
- failWriteValidation2(`${flag} must be on|off`);
11272
+ return v;
11477
11273
  }
11478
- function parseLocaleFlag(value) {
11479
- if (value === void 0 || value === null || value === "") {
11480
- return void 0;
11481
- }
11482
- const match = /^([a-z]{2})[_-]([A-Za-z]{2})$/.exec(String(value));
11483
- if (!match) {
11484
- failWriteValidation2("--locale must look like en_US (language_COUNTRY)");
11274
+ function parseIntent(raw) {
11275
+ const v = raw ?? "baseline";
11276
+ const known = [
11277
+ "baseline",
11278
+ "revenue",
11279
+ "funnel",
11280
+ "engagement",
11281
+ "video",
11282
+ "lead-gen",
11283
+ "inmail",
11284
+ "document",
11285
+ "ranking",
11286
+ "identity"
11287
+ ];
11288
+ if (!known.includes(v)) {
11289
+ handleLinkedinError(new Error(`Invalid --intent "${raw}". Run --list-intents to see options.`));
11485
11290
  }
11486
- return { language: match[1], country: match[2].toUpperCase() };
11291
+ return v;
11487
11292
  }
11488
- function loadTargetingFileArg(path28) {
11489
- if (typeof path28 !== "string" || path28.length === 0) {
11490
- return void 0;
11491
- }
11492
- const parsed = loadJsonFileArg2(path28);
11493
- const criteria = parsed.targetingCriteria ?? parsed;
11494
- if (!criteria.include) {
11495
- failWriteValidation2(
11496
- `${path28} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
11497
- );
11293
+ function resolveDateRange(args) {
11294
+ const start = args.start;
11295
+ const end = args.end;
11296
+ if (start) {
11297
+ return { start, end };
11498
11298
  }
11499
- return criteria;
11500
- }
11501
- function sha256Lower(value) {
11502
- return createHash("sha256").update(value.trim().toLowerCase()).digest("hex");
11503
- }
11504
- function parseCsvLine(line) {
11505
- const cells = [];
11506
- let current = "";
11507
- let inQuotes = false;
11508
- for (let i = 0; i < line.length; i++) {
11509
- const char = line[i];
11510
- if (inQuotes) {
11511
- if (char === '"' && line[i + 1] === '"') {
11512
- current += '"';
11513
- i++;
11514
- } else if (char === '"') {
11515
- inQuotes = false;
11516
- } else {
11517
- current += char;
11518
- }
11519
- } else if (char === '"') {
11520
- inQuotes = true;
11521
- } else if (char === ",") {
11522
- cells.push(current);
11523
- current = "";
11524
- } else {
11525
- current += char;
11526
- }
11299
+ const lastDaysRaw = args["last-days"];
11300
+ const days = lastDaysRaw ? Number(lastDaysRaw) : 7;
11301
+ if (!Number.isFinite(days) || days < 1 || days > 730) {
11302
+ handleLinkedinError(new Error(`Invalid --last-days "${lastDaysRaw}". Use a positive number \u2264 730.`));
11527
11303
  }
11528
- cells.push(current);
11529
- return cells.map((cell) => cell.trim());
11304
+ return { start: daysAgoIso2(days), end: todayIso2() };
11530
11305
  }
11531
- function parseListFileArg(path28, maxRows) {
11532
- if (typeof path28 !== "string" || path28.length === 0) {
11533
- return void 0;
11534
- }
11535
- const raw = readFileSync6(path28, "utf8");
11536
- const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
11537
- if (lines.length < 2) {
11538
- failWriteValidation2(`${path28} needs a header row and at least one data row`);
11539
- }
11540
- const columns = parseCsvLine(lines[0]).map((column) => column.trim());
11541
- const rows = [];
11542
- for (const line of lines.slice(1)) {
11543
- const cells = parseCsvLine(line);
11544
- const row = {};
11545
- columns.forEach((column, index) => {
11546
- const value = cells[index] ?? "";
11547
- if (!value) {
11548
- return;
11549
- }
11550
- row[column] = column.toLowerCase() === "email" ? sha256Lower(value) : value;
11551
- });
11552
- if (Object.keys(row).length > 0) {
11553
- rows.push(row);
11306
+ function resolveScopeIds(args, level) {
11307
+ if (level === "account") {
11308
+ const id = resolveAccountIdArg2(args);
11309
+ return [id];
11310
+ }
11311
+ if (level === "campaign-group") {
11312
+ const raw2 = args["campaign-group-id"] ?? args.ids;
11313
+ if (!raw2) {
11314
+ handleLinkedinError(new Error("Pass --campaign-group-id (CSV for multi) when --level campaign-group."));
11554
11315
  }
11316
+ return commaSplit(raw2) ?? [];
11555
11317
  }
11556
- if (rows.length > maxRows) {
11557
- failWriteValidation2(`${path28} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
11318
+ if (level === "campaign") {
11319
+ const raw2 = args["campaign-id"] ?? args.ids;
11320
+ if (!raw2) {
11321
+ handleLinkedinError(new Error("Pass --campaign-id (CSV for multi) when --level campaign."));
11322
+ }
11323
+ return commaSplit(raw2) ?? [];
11558
11324
  }
11559
- return { columns, rows };
11325
+ const raw = args["creative-id"] ?? args.ids;
11326
+ if (!raw) {
11327
+ handleLinkedinError(new Error("Pass --creative-id (CSV for multi) when --level creative."));
11328
+ }
11329
+ return commaSplit(raw) ?? [];
11560
11330
  }
11561
- function failPreflight(fields) {
11562
- writeJsonEnvelope({
11563
- ok: false,
11564
- error: {
11565
- code: "VALIDATION_ERROR",
11566
- message: fields.map((field) => field.path ? `${field.path}: ${field.message}` : field.message).join("; "),
11567
- fields,
11568
- fix: capabilityFix("linkedin-ads")
11569
- }
11331
+ function sortRows(rows) {
11332
+ return [...rows].sort((a, b) => {
11333
+ const cs = numberOf(b.costInUsd) - numberOf(a.costInUsd);
11334
+ if (cs !== 0) return cs;
11335
+ return numberOf(b.impressions) - numberOf(a.impressions);
11570
11336
  });
11571
- process.exit(1);
11572
- }
11573
- var BATCH_NUDGE = "This command takes several ids at once \u2014 pass them comma-separated or with --ids-file instead of one call per id.";
11574
- function batchNudge(data) {
11575
- return data.sameKindStaged === void 0 ? [] : [BATCH_NUDGE];
11576
11337
  }
11577
- async function stageOp(raw, hints) {
11578
- const preflight = linkedinDraftOpInputSchema.safeParse(raw);
11579
- if (!preflight.success) {
11580
- failPreflight(preflight.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })));
11581
- }
11582
- try {
11583
- const chatId = requireChatId();
11584
- const response = await apiPost("/api/ads/linkedin/draft/stage", {
11585
- chatId,
11586
- op: preflight.data
11587
- });
11588
- const allHints = [...batchNudge(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
11589
- writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
11590
- } catch (err) {
11591
- handleLinkedinError(err);
11338
+ function numberOf(v) {
11339
+ if (typeof v === "number") return Number.isFinite(v) ? v : 0;
11340
+ if (typeof v === "string") {
11341
+ const n = Number(v);
11342
+ return Number.isFinite(n) ? n : 0;
11592
11343
  }
11344
+ return 0;
11593
11345
  }
11594
- async function stageLinkedinOps(rawOps, hints) {
11595
- if (rawOps.length === 1 && rawOps[0]) {
11596
- await stageOp(rawOps[0], hints);
11597
- return;
11346
+ var analyticsCommand = defineCommand34({
11347
+ meta: {
11348
+ name: "analytics",
11349
+ description: `Performance reporting \u2014 the workhorse for AI agents.
11350
+
11351
+ Three-axis design (LinkedIn's superpower over Meta/Google):
11352
+ --level account | campaign-group | campaign | creative
11353
+ --intent baseline | revenue | funnel | engagement | video | lead-gen | inmail | document | ranking | identity
11354
+ --pivot none | job-title | company | industry | seniority | function | company-size | country | region
11355
+ | device | placement | serving-location | card-index | objective
11356
+ | conversation-node | conversation-node-button
11357
+
11358
+ Smart defaults:
11359
+ --level account, --intent baseline, --pivot none, --granularity DAILY, last 7 days
11360
+ Pivot on a MEMBER_* dim \u2192 granularity auto-forced to ALL (LinkedIn rejects DAILY+demographic)
11361
+ Demographic data delayed 12-24h with \u22653-event privacy floor \u2014 the CLI flags this in warnings.
11362
+ Derived metrics injected client-side: ctr, cpc, cpm, frequency, leadCompletionRate
11363
+
11364
+ Examples \u2014 common AI questions:
11365
+ # "How is this account doing this week?"
11366
+ baker ads linkedin analytics
11367
+
11368
+ # "Who are we reaching, by job title?" (the LinkedIn killer)
11369
+ baker ads linkedin analytics --level campaign --campaign-id 1234 --pivot job-title --intent baseline --last-days 30
11370
+
11371
+ # "Top companies seeing my ads" (ABM feedback loop)
11372
+ baker ads linkedin analytics --level campaign --campaign-id 1234 --pivot company --last-days 30
11373
+
11374
+ # "Revenue by campaign over Q1"
11375
+ baker ads linkedin analytics --level campaign --campaign-id 1,2,3 --intent revenue --start 2026-01-01 --end 2026-03-31 --granularity MONTHLY
11376
+
11377
+ # "How is the lead form converting?"
11378
+ baker ads linkedin analytics --level campaign --campaign-id 1234 --intent lead-gen
11379
+
11380
+ # Custom field set (escape hatch)
11381
+ baker ads linkedin analytics --metrics impressions,clicks,oneClickLeads --intent identity`
11382
+ },
11383
+ args: {
11384
+ level: { type: "string", description: "Object scope (default: account)" },
11385
+ "account-id": { type: "string", description: "Account ID \u2014 numeric or urn:li:sponsoredAccount:N (level=account)" },
11386
+ "account-urn": { type: "string", description: "Alias for --account-id (URN form)" },
11387
+ "campaign-group-id": { type: "string", description: "Comma-separated IDs (level=campaign-group)" },
11388
+ "campaign-id": { type: "string", description: "Comma-separated IDs (level=campaign)" },
11389
+ "creative-id": { type: "string", description: "Comma-separated IDs (level=creative)" },
11390
+ ids: { type: "string", description: "Generic CSV of IDs at the chosen level (alternative to per-level flags)" },
11391
+ intent: {
11392
+ type: "string",
11393
+ description: "baseline|revenue|funnel|engagement|video|lead-gen|inmail|document|ranking|identity"
11394
+ },
11395
+ pivot: { type: "string", description: "Pivot dim. Default: none. See `--list-pivots`." },
11396
+ metrics: { type: "string", description: "CSV metric override \u2014 bypass --intent's bundle" },
11397
+ "list-intents": { type: "boolean", description: "Print intent definitions and exit" },
11398
+ "list-pivots": { type: "boolean", description: "Print pivot slugs and exit" },
11399
+ start: { type: "string", description: "Start date YYYY-MM-DD (overrides --last-days)" },
11400
+ end: { type: "string", description: "End date YYYY-MM-DD" },
11401
+ "last-days": { type: "string", description: "Window relative to today (default: 7)" },
11402
+ granularity: { type: "string", description: "DAILY|MONTHLY|YEARLY|ALL (default: DAILY)" },
11403
+ limit: { type: "string", description: "Max rows (default: 1000)" },
11404
+ "no-sort": { type: "boolean", description: "Skip default sort by costInUsd desc" },
11405
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11406
+ output: { type: "string", description: "Output format json|csv|jsonl|md", default: "json" }
11407
+ },
11408
+ run: async ({ args }) => {
11409
+ if (args["list-intents"]) {
11410
+ writeAdsJson({ ok: true, data: listIntents() });
11411
+ return;
11412
+ }
11413
+ if (args["list-pivots"]) {
11414
+ writeAdsJson({ ok: true, data: ALL_PIVOTS });
11415
+ return;
11416
+ }
11417
+ const level = parseLevel(args.level);
11418
+ const intent = parseIntent(args.intent);
11419
+ const pivot = parsePivot(args.pivot);
11420
+ let granularity = parseGranularity(args.granularity);
11421
+ if (MEMBER_PIVOTS.has(pivot) && granularity === "DAILY") {
11422
+ process.stderr.write(`note: pivot=${pivot} forces --granularity ALL (LinkedIn rejects DAILY + demographic).
11423
+ `);
11424
+ granularity = "ALL";
11425
+ }
11426
+ const ids = resolveScopeIds(args, level);
11427
+ if (ids.length === 0) {
11428
+ handleLinkedinError(new Error(`No IDs resolved for --level ${level}`));
11429
+ }
11430
+ const range = resolveDateRange(args);
11431
+ const metrics = commaSplit(args.metrics);
11432
+ const limit = args.limit ? Number(args.limit) : 1e3;
11433
+ const request = {
11434
+ level,
11435
+ ids,
11436
+ intent,
11437
+ pivot,
11438
+ metrics,
11439
+ start: range.start,
11440
+ end: range.end,
11441
+ granularity,
11442
+ limit
11443
+ };
11444
+ try {
11445
+ const data = await apiPost("/api/ads/linkedin/analytics", {
11446
+ request,
11447
+ skipCache: Boolean(args["skip-cache"])
11448
+ });
11449
+ const rows = args["no-sort"] ? data.rows : sortRows(data.rows);
11450
+ const fmt = csvOrJson2(args);
11451
+ if (fmt !== "json") {
11452
+ writeAdsOutput(rows, fmt, data.fields);
11453
+ return;
11454
+ }
11455
+ writeAdsJson({
11456
+ ok: true,
11457
+ data: {
11458
+ rows,
11459
+ fields: data.fields,
11460
+ warnings: data.warnings,
11461
+ query: data.query,
11462
+ // Echo composed fields so an offline agent can pre-flight w/o this network call.
11463
+ composedFields: composeFields(intent, level, metrics)
11464
+ }
11465
+ });
11466
+ } catch (err) {
11467
+ handleLinkedinError(err);
11468
+ }
11598
11469
  }
11599
- const ops = [];
11600
- for (const [index, raw] of rawOps.entries()) {
11601
- const preflight = linkedinDraftOpInputSchema.safeParse(raw);
11602
- if (!preflight.success) {
11603
- failPreflight(
11604
- preflight.error.issues.map((issue) => ({
11605
- path: `ops[${index}]${issue.path.length > 0 ? `.${issue.path.join(".")}` : ""}`,
11606
- message: issue.message
11607
- }))
11470
+ });
11471
+
11472
+ // src/commands/ads/linkedin/audience-size.ts
11473
+ import { readFileSync as readFileSync5 } from "fs";
11474
+ import { defineCommand as defineCommand35 } from "citty";
11475
+ function loadTargeting(args) {
11476
+ const inline = args.targeting;
11477
+ if (inline) {
11478
+ try {
11479
+ return JSON.parse(inline);
11480
+ } catch {
11481
+ handleLinkedinError(new Error("--targeting must be valid JSON. See LinkedIn 'targetingCriteria' shape."));
11482
+ }
11483
+ }
11484
+ const file = args["targeting-file"];
11485
+ if (file) {
11486
+ try {
11487
+ return JSON.parse(readFileSync5(file, "utf-8"));
11488
+ } catch (e) {
11489
+ handleLinkedinError(
11490
+ new Error(`Failed to read --targeting-file: ${e instanceof Error ? e.message : "I/O error"}`)
11608
11491
  );
11609
11492
  }
11610
- ops.push(preflight.data);
11611
- }
11612
- try {
11613
- const chatId = requireChatId();
11614
- const response = await apiPost("/api/ads/linkedin/draft/stage-batch", { chatId, ops });
11615
- const allHints = [...hints ?? [], ...adCopyHints(ops)];
11616
- writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
11617
- } catch (err) {
11618
- handleLinkedinError(err);
11619
11493
  }
11494
+ handleLinkedinError(new Error("Pass --targeting-file <path> or --targeting '{...JSON...}'"));
11495
+ }
11496
+ var audienceSizeCommand = defineCommand35({
11497
+ meta: {
11498
+ name: "audience-size",
11499
+ description: `Estimate audience size for a targeting payload \u2014 pre-launch sanity check.
11500
+
11501
+ Returns total + active counts plus playbook \xA704 warnings:
11502
+ size <20K \u2192 too narrow (will not deliver)
11503
+ size <50K \u2192 outside the 50K\u2013500K sweet spot
11504
+ size >500K \u2192 outside the sweet spot
11505
+ size >1M \u2192 too broad, refine ICP
11506
+
11507
+ The targeting payload must follow LinkedIn's 'targetingCriteria' shape \u2014 an
11508
+ include/exclude tree of and/or boolean operators with adTargetingFacet keys.
11509
+ The simplest form:
11510
+ { "include": { "and": [{ "or": { "urn:li:adTargetingFacet:locations": ["urn:li:geo:103644278"] }}]}}
11511
+
11512
+ Examples:
11513
+ baker ads linkedin audience-size --account-id 503001492 --targeting-file targeting.json
11514
+ baker ads linkedin audience-size --account-id 503001492 --targeting '{"include":{"and":[]}}'`
11515
+ },
11516
+ args: {
11517
+ "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11518
+ "account-urn": { type: "string", description: "Alias for --account-id" },
11519
+ targeting: { type: "string", description: "Inline JSON targetingCriteria payload" },
11520
+ "targeting-file": { type: "string", description: "Path to JSON file with targetingCriteria" },
11521
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11522
+ output: { type: "string", description: "json", default: "json" }
11523
+ },
11524
+ run: async ({ args }) => {
11525
+ const accountId = resolveAccountIdArg2(args);
11526
+ const targetingCriteria = loadTargeting(args);
11527
+ try {
11528
+ const data = await apiPost("/api/ads/linkedin/audience-size", {
11529
+ accountId,
11530
+ targetingCriteria,
11531
+ skipCache: Boolean(args["skip-cache"])
11532
+ });
11533
+ writeAdsJson({ ok: true, data });
11534
+ } catch (err) {
11535
+ handleLinkedinError(err);
11536
+ }
11537
+ }
11538
+ });
11539
+
11540
+ // src/commands/ads/linkedin/audit.ts
11541
+ import { defineCommand as defineCommand36 } from "citty";
11542
+ var SEVERITY_RANK = {
11543
+ critical: 0,
11544
+ high: 1,
11545
+ medium: 2,
11546
+ low: 3
11547
+ };
11548
+ var STATUS_RANK = {
11549
+ fail: 0,
11550
+ warn: 1,
11551
+ partial: 2,
11552
+ pass: 3,
11553
+ n_a: 4
11554
+ };
11555
+ function filterFindings(findings, severity, area) {
11556
+ return findings.filter((f) => {
11557
+ if (severity && !severity.includes(f.severity)) return false;
11558
+ if (area && !area.includes(f.area)) return false;
11559
+ return true;
11560
+ });
11620
11561
  }
11621
- async function stageStatusChange(input) {
11622
- const payload = input.kind === "creative.update" ? { intendedStatus: input.status } : { status: input.status };
11623
- await stageLinkedinOps(
11624
- input.targets.map((target) => ({ kind: input.kind, accountId: input.accountId, target, payload }))
11625
- );
11562
+ function sortFindings(findings) {
11563
+ return [...findings].sort((a, b) => {
11564
+ const sa = STATUS_RANK[a.status] - STATUS_RANK[b.status];
11565
+ if (sa !== 0) return sa;
11566
+ return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
11567
+ });
11626
11568
  }
11627
- function requireTarget2(args, entity) {
11628
- const inverted = booleanWordPositionalError(args, entity);
11629
- if (inverted !== void 0) {
11630
- failWriteValidation2(inverted);
11631
- }
11632
- const positionals = readPositionals(args);
11633
- const target = args.id ?? args.target ?? args.ref ?? positionals[0];
11634
- if (typeof target !== "string" || target.length === 0) {
11635
- failWriteValidation2(`pass the ${entity} id or URN as the positional argument`);
11636
- }
11637
- if (positionals.length > 1 || splitIdList(target).length > 1) {
11638
- failWriteValidation2(
11639
- `this command takes one ${entity} id \u2014 run it once per id. Only pause/resume/archive and 'creatives update' accept several ids in one call.`
11569
+ function renderMarkdown(result) {
11570
+ const lines = [];
11571
+ lines.push(`# LinkedIn Ads Audit \u2014 ${result.account.name} (${result.account.id})`);
11572
+ lines.push("");
11573
+ lines.push(
11574
+ `**Scope:** ${result.scope.campaigns} campaigns, ${result.scope.creatives} creatives, last ${result.scope.windowDays}d`
11575
+ );
11576
+ lines.push("");
11577
+ lines.push(
11578
+ `**Summary:** ${result.summary.pass} pass \u2022 ${result.summary.critical} critical \u2022 ${result.summary.high} high \u2022 ${result.summary.medium} medium \u2022 ${result.summary.low} low \u2022 ${result.summary.n_a} n/a (${result.summary.totalChecks} total)`
11579
+ );
11580
+ lines.push("");
11581
+ lines.push("| # | Area | Check | Status | Severity | Notes |");
11582
+ lines.push("|---|------|-------|--------|----------|-------|");
11583
+ result.findings.forEach((f, idx) => {
11584
+ const note = noteOf(f);
11585
+ lines.push(
11586
+ `| ${idx + 1} | ${f.area} | ${escapeMd(f.check)} | ${f.status.toUpperCase()} | ${f.severity} | ${escapeMd(note)} |`
11640
11587
  );
11641
- }
11642
- return target;
11588
+ });
11589
+ return lines.join("\n");
11643
11590
  }
11644
- function readPositionals(args) {
11645
- if (!Array.isArray(args._)) {
11646
- return [];
11647
- }
11648
- return args._.filter((value) => typeof value === "string" && value.length > 0);
11591
+ function escapeMd(text) {
11592
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
11649
11593
  }
11650
- function splitIdList(raw) {
11651
- return raw.split(",").map((id) => id.trim()).filter(Boolean);
11594
+ function noteOf(f) {
11595
+ if (f.status === "pass") return "";
11596
+ const ev = f.evidence ? JSON.stringify(f.evidence) : "";
11597
+ const fix = f.fix?.explanation ?? "";
11598
+ return [fix, ev].filter(Boolean).join(" \u2014 ");
11652
11599
  }
11653
- function idsFileEntries(path28) {
11654
- if (typeof path28 !== "string" || path28.length === 0) {
11655
- return [];
11600
+ var auditCommand = defineCommand36({
11601
+ meta: {
11602
+ name: "audit",
11603
+ description: `Run a LinkedIn Ads playbook audit \u2014 30+ checks across Settings, Tracking,
11604
+ Audience, Campaigns, Creative, Performance, Bidding, and Compliance.
11605
+
11606
+ Each finding has {id, area, check, status, severity, evidence, fix} fields with
11607
+ playbook citations. The default JSON output is agent-friendly; --format md
11608
+ renders a deliverable-ready table that mirrors the google-ads-playbook
11609
+ 10-audit-summary.md style.
11610
+
11611
+ Exit code 0 always. Use the JSON 'summary' counts to decide if action is needed.
11612
+
11613
+ Examples:
11614
+ baker ads linkedin audit --account-id 503001492
11615
+ baker ads linkedin audit --account-id 503001492 --campaign-id 1234 # narrow scope
11616
+ baker ads linkedin audit --account-id 503001492 --window-days 90
11617
+ baker ads linkedin audit --account-id 503001492 --severity critical,high
11618
+ baker ads linkedin audit --account-id 503001492 --area Settings,Tracking
11619
+ baker ads linkedin audit --account-id 503001492 --format md`
11620
+ },
11621
+ args: {
11622
+ "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11623
+ "account-urn": { type: "string", description: "Alias for --account-id" },
11624
+ "campaign-id": { type: "string", description: "Narrow scope to a single campaign" },
11625
+ "window-days": { type: "string", description: "Lookback window for performance checks (default: 30)" },
11626
+ severity: { type: "string", description: "CSV severity filter: critical,high,medium,low" },
11627
+ area: {
11628
+ type: "string",
11629
+ description: "CSV area filter: Settings,Tracking,Audience,Campaigns,Creative,Performance,Bidding,Compliance,Hygiene"
11630
+ },
11631
+ format: { type: "string", description: "Output format: json | md (default: json)" },
11632
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache" }
11633
+ },
11634
+ run: async ({ args }) => {
11635
+ const accountId = resolveAccountIdArg2(args);
11636
+ const windowDays = args["window-days"] ? Number(args["window-days"]) : 30;
11637
+ if (!Number.isFinite(windowDays) || windowDays < 1 || windowDays > 365) {
11638
+ handleLinkedinError(new Error("--window-days must be 1..365"));
11639
+ }
11640
+ try {
11641
+ const data = await apiPost("/api/ads/linkedin/audit", {
11642
+ accountId,
11643
+ campaignId: args["campaign-id"] ? String(args["campaign-id"]) : void 0,
11644
+ windowDays,
11645
+ skipCache: Boolean(args["skip-cache"])
11646
+ });
11647
+ const severityFilter = args.severity?.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
11648
+ const areaFilter = args.area?.split(",").map((s) => s.trim()).filter(Boolean);
11649
+ const filtered = filterFindings(data.findings, severityFilter, areaFilter);
11650
+ const sorted = sortFindings(filtered);
11651
+ const result = { ...data, findings: sorted };
11652
+ const fmt = args.format ?? "json";
11653
+ if (fmt === "md") {
11654
+ process.stdout.write(`${renderMarkdown(result)}
11655
+ `);
11656
+ return;
11657
+ }
11658
+ writeAdsJson({ ok: true, data: result });
11659
+ } catch (err) {
11660
+ handleLinkedinError(err);
11661
+ }
11656
11662
  }
11657
- return readFileSync6(path28, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).flatMap(splitIdList);
11658
- }
11659
- function requireTargets(args, entity) {
11660
- const positionals = readPositionals(args);
11661
- const named = [args.id, args.target, args.ref].filter(
11662
- (value) => typeof value === "string" && value.length > 0
11663
- );
11664
- const fromArgs = positionals.length > 0 ? positionals : named;
11665
- const targets = [.../* @__PURE__ */ new Set([...fromArgs.flatMap(splitIdList), ...idsFileEntries(args["ids-file"])])];
11666
- if (targets.length === 0) {
11667
- failWriteValidation2(
11668
- `pass the ${entity} id or URN as the positional argument (comma-separated for several), or --ids-file <path>`
11669
- );
11663
+ });
11664
+
11665
+ // src/commands/ads/linkedin/bid-pricing.ts
11666
+ import { readFileSync as readFileSync6 } from "fs";
11667
+ import { defineCommand as defineCommand37 } from "citty";
11668
+ function loadTargeting2(args) {
11669
+ const inline = args.targeting;
11670
+ if (inline) {
11671
+ try {
11672
+ return JSON.parse(inline);
11673
+ } catch {
11674
+ handleLinkedinError(new Error("--targeting must be valid JSON. See LinkedIn 'targetingCriteria' shape."));
11675
+ }
11670
11676
  }
11671
- if (targets.length > LINKEDIN_DRAFT_BATCH_MAX) {
11672
- failWriteValidation2(
11673
- `${targets.length} ids exceeds the batch limit of ${LINKEDIN_DRAFT_BATCH_MAX} \u2014 split the list`
11674
- );
11677
+ const file = args["targeting-file"];
11678
+ if (file) {
11679
+ try {
11680
+ return JSON.parse(readFileSync6(file, "utf-8"));
11681
+ } catch (e) {
11682
+ handleLinkedinError(
11683
+ new Error(`Failed to read --targeting-file: ${e instanceof Error ? e.message : "I/O error"}`)
11684
+ );
11685
+ }
11675
11686
  }
11676
- return targets;
11687
+ handleLinkedinError(new Error("Pass --targeting-file <path> or --targeting '{...JSON...}'"));
11677
11688
  }
11689
+ var bidPricingCommand = defineCommand37({
11690
+ meta: {
11691
+ name: "bid-pricing",
11692
+ description: `Get LinkedIn's suggested bid range for a targeting + objective + cost type.
11693
+
11694
+ Returns LinkedIn's min / suggested / max bid plus the playbook \xA706 floor
11695
+ (2/3 of suggested \u2014 the recommended manual CPC starting point).
11696
+
11697
+ Objective types: BRAND_AWARENESS | WEBSITE_TRAFFIC | WEBSITE_VISIT |
11698
+ ENGAGEMENT | WEBSITE_CONVERSION | LEAD_GENERATION |
11699
+ JOB_APPLICANT | VIDEO_VIEW
11700
+ Cost types: CPC | CPM | CPV | CPS
11701
+
11702
+ Examples:
11703
+ baker ads linkedin bid-pricing --account-id 503001492 --objective WEBSITE_CONVERSION --cost-type CPC --targeting-file targeting.json
11704
+ baker ads linkedin bid-pricing --account-id 503001492 --objective LEAD_GENERATION --cost-type CPM --targeting '{"include":...}'`
11705
+ },
11706
+ args: {
11707
+ "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
11708
+ "account-urn": { type: "string", description: "Alias for --account-id" },
11709
+ objective: {
11710
+ type: "string",
11711
+ description: "Objective type (e.g. WEBSITE_CONVERSION, LEAD_GENERATION)",
11712
+ required: true
11713
+ },
11714
+ "cost-type": { type: "string", description: "CPC | CPM | CPV | CPS", required: true },
11715
+ targeting: { type: "string", description: "Inline JSON targetingCriteria" },
11716
+ "targeting-file": { type: "string", description: "Path to JSON file with targetingCriteria" },
11717
+ "skip-cache": { type: "boolean", description: "Bypass server-side cache" },
11718
+ output: { type: "string", description: "json", default: "json" }
11719
+ },
11720
+ run: async ({ args }) => {
11721
+ const accountId = resolveAccountIdArg2(args);
11722
+ const targetingCriteria = loadTargeting2(args);
11723
+ try {
11724
+ const data = await apiPost("/api/ads/linkedin/bid-pricing", {
11725
+ accountId,
11726
+ objectiveType: String(args.objective),
11727
+ costType: String(args["cost-type"]).toUpperCase(),
11728
+ targetingCriteria,
11729
+ skipCache: Boolean(args["skip-cache"])
11730
+ });
11731
+ writeAdsJson({ ok: true, data });
11732
+ } catch (err) {
11733
+ handleLinkedinError(err);
11734
+ }
11735
+ }
11736
+ });
11737
+
11738
+ // src/commands/ads/linkedin/campaign-groups.ts
11739
+ import { defineCommand as defineCommand39 } from "citty";
11678
11740
 
11679
11741
  // src/commands/ads/linkedin/write-commands.ts
11742
+ import { defineCommand as defineCommand38 } from "citty";
11680
11743
  var STAGED_NOTE = "Staged until publish \u2014 review with `baker ads linkedin draft`.";
11681
11744
  var accountArgs = {
11682
11745
  "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
@@ -11701,7 +11764,7 @@ Example: baker ads linkedin campaign-groups create --name "Q3 ABM" --start 2026-
11701
11764
  name: { type: "string", description: "Campaign group name (\u2264100 chars)" },
11702
11765
  start: { type: "string", description: "Run schedule start (ISO date or epoch ms)" },
11703
11766
  end: { type: "string", description: "Run schedule end" },
11704
- "total-budget": { type: "string", description: "Lifetime budget amount" },
11767
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP },
11705
11768
  currency: { type: "string", description: "3-letter currency code (defaults to the ad account currency)" },
11706
11769
  status: { type: "string", description: "DRAFT|ACTIVE|PAUSED (default DRAFT)" },
11707
11770
  file: { type: "string", description: "JSON file with the full payload; flags override file keys" }
@@ -11730,7 +11793,7 @@ Example: baker ads linkedin campaign-groups update 635137195 --total-budget 8000
11730
11793
  name: { type: "string", description: "New name" },
11731
11794
  start: { type: "string", description: "New run schedule start" },
11732
11795
  end: { type: "string", description: "New run schedule end" },
11733
- "total-budget": { type: "string", description: "New lifetime budget" },
11796
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP },
11734
11797
  currency: { type: "string", description: "3-letter currency code (defaults to the ad account currency)" },
11735
11798
  status: { type: "string", description: "ACTIVE|PAUSED|ARCHIVED|\u2026" },
11736
11799
  file: { type: "string", description: "JSON file with fields to change" }
@@ -11781,7 +11844,7 @@ var campaignFlagArgs = {
11781
11844
  "cost-type": { type: "string", description: "CPC|CPM|CPV" },
11782
11845
  bid: { type: "string", description: "Manual bid (unitCost) \u2014 playbook mandates manual bidding" },
11783
11846
  "daily-budget": { type: "string", description: "Daily budget amount" },
11784
- "total-budget": { type: "string", description: "Lifetime budget amount" },
11847
+ "total-budget": { type: "string", description: TOTAL_BUDGET_HELP },
11785
11848
  currency: { type: "string", description: "3-letter currency code (defaults to the ad account currency)" },
11786
11849
  start: { type: "string", description: "Run schedule start (ISO date or epoch ms)" },
11787
11850
  end: { type: "string", description: "Run schedule end" },