@telepat/ideon 0.1.13 → 0.1.15

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/ideon.js CHANGED
@@ -93,6 +93,12 @@ function resolveTargetLengthAlias(targetLengthWords) {
93
93
  }
94
94
  return "large";
95
95
  }
96
+ function resolveDefaultMaxLinks(targetLengthWords) {
97
+ const alias = resolveTargetLengthAlias(targetLengthWords);
98
+ if (alias === "small") return 5;
99
+ if (alias === "medium") return 8;
100
+ return 12;
101
+ }
96
102
  var contentTargetRoleValues = ["primary", "secondary"];
97
103
  var contentTargetSchema = z.object({
98
104
  contentType: z.enum(contentTypeValues),
@@ -966,12 +972,18 @@ var writeToolInputSchema = {
966
972
  intent: z3.enum(contentIntentValues).optional(),
967
973
  length: z3.union([z3.enum(targetLengthValues), z3.coerce.number().int().positive()]).optional(),
968
974
  dryRun: z3.boolean().optional(),
969
- enrichLinks: z3.boolean().optional()
975
+ enrichLinks: z3.boolean().optional(),
976
+ link: z3.array(z3.string()).optional(),
977
+ unlink: z3.array(z3.string()).optional(),
978
+ maxLinks: z3.coerce.number().int().positive().optional()
970
979
  };
971
980
  var writeToolInputZodSchema = z3.object(writeToolInputSchema);
972
981
  var writeResumeToolInputSchema = {
973
982
  dryRun: z3.boolean().optional(),
974
- enrichLinks: z3.boolean().optional()
983
+ enrichLinks: z3.boolean().optional(),
984
+ link: z3.array(z3.string()).optional(),
985
+ unlink: z3.array(z3.string()).optional(),
986
+ maxLinks: z3.coerce.number().int().positive().optional()
975
987
  };
976
988
  var writeResumeToolInputZodSchema = z3.object(writeResumeToolInputSchema);
977
989
  var deleteToolInputSchema = {
@@ -987,6 +999,20 @@ var configSetToolInputSchema = {
987
999
  value: z3.string()
988
1000
  };
989
1001
  var configSetToolInputZodSchema = z3.object(configSetToolInputSchema);
1002
+ var linksToolInputSchema = {
1003
+ slug: z3.string().min(1),
1004
+ mode: z3.enum(["fresh", "append"]).optional(),
1005
+ link: z3.array(z3.string()).optional(),
1006
+ unlink: z3.array(z3.string()).optional(),
1007
+ maxLinks: z3.coerce.number().int().positive().optional()
1008
+ };
1009
+ var linksToolInputZodSchema = z3.object(linksToolInputSchema);
1010
+ var configListToolInputSchema = {};
1011
+ var configListToolInputZodSchema = z3.object(configListToolInputSchema);
1012
+ var configUnsetToolInputSchema = {
1013
+ key: z3.enum(configKeys)
1014
+ };
1015
+ var configUnsetToolInputZodSchema = z3.object(configUnsetToolInputSchema);
990
1016
  var ideonToolContracts = [
991
1017
  {
992
1018
  name: "ideon_write",
@@ -997,6 +1023,23 @@ var ideonToolContracts = [
997
1023
  length: [...targetLengthValues]
998
1024
  }
999
1025
  },
1026
+ {
1027
+ name: "ideon_write_resume",
1028
+ required: [],
1029
+ enums: {}
1030
+ },
1031
+ {
1032
+ name: "ideon_delete",
1033
+ required: ["slug"],
1034
+ enums: {}
1035
+ },
1036
+ {
1037
+ name: "ideon_links",
1038
+ required: ["slug"],
1039
+ enums: {
1040
+ mode: ["fresh", "append"]
1041
+ }
1042
+ },
1000
1043
  {
1001
1044
  name: "ideon_config_set",
1002
1045
  required: ["key", "value"],
@@ -1010,6 +1053,18 @@ var ideonToolContracts = [
1010
1053
  enums: {
1011
1054
  key: [...configKeys]
1012
1055
  }
1056
+ },
1057
+ {
1058
+ name: "ideon_config_list",
1059
+ required: [],
1060
+ enums: {}
1061
+ },
1062
+ {
1063
+ name: "ideon_config_unset",
1064
+ required: ["key"],
1065
+ enums: {
1066
+ key: [...configKeys]
1067
+ }
1013
1068
  }
1014
1069
  ];
1015
1070
 
@@ -1299,7 +1354,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1299
1354
  // package.json
1300
1355
  var package_default = {
1301
1356
  name: "@telepat/ideon",
1302
- version: "0.1.13",
1357
+ version: "0.1.15",
1303
1358
  description: "CLI for generating rich articles and images from ideas.",
1304
1359
  type: "module",
1305
1360
  repository: {
@@ -1508,7 +1563,7 @@ import path8 from "path";
1508
1563
  import { readFile as readFile4 } from "fs/promises";
1509
1564
 
1510
1565
  // src/llm/prompts/linkEnrichment.ts
1511
- function buildLinkCandidatesJsonSchema() {
1566
+ function buildLinkCandidatesJsonSchema(maxLinks = 10) {
1512
1567
  return {
1513
1568
  type: "object",
1514
1569
  additionalProperties: false,
@@ -1517,13 +1572,13 @@ function buildLinkCandidatesJsonSchema() {
1517
1572
  expressions: {
1518
1573
  type: "array",
1519
1574
  minItems: 0,
1520
- maxItems: 10,
1575
+ maxItems: maxLinks,
1521
1576
  items: { type: "string", minLength: 2 }
1522
1577
  }
1523
1578
  }
1524
1579
  };
1525
1580
  }
1526
- function buildLinkCandidatesMessages(content, contentType) {
1581
+ function buildLinkCandidatesMessages(content, contentType, maxLinks = 10) {
1527
1582
  return [
1528
1583
  {
1529
1584
  role: "system",
@@ -1539,7 +1594,7 @@ function buildLinkCandidatesMessages(content, contentType) {
1539
1594
  role: "user",
1540
1595
  content: [
1541
1596
  `Content type: ${contentType}`,
1542
- "Select up to 10 expressions that should become links in this content.",
1597
+ `Select up to ${maxLinks} expressions that should become links in this content.`,
1543
1598
  "Each expression must be copied exactly from the text and be useful to link.",
1544
1599
  "",
1545
1600
  "Content:",
@@ -1594,6 +1649,8 @@ async function enrichLinks({
1594
1649
  openRouter,
1595
1650
  settings,
1596
1651
  dryRun,
1652
+ customLinks = [],
1653
+ maxLinks = 10,
1597
1654
  onLlmMetrics,
1598
1655
  onItemProgress,
1599
1656
  onInteraction
@@ -1615,7 +1672,8 @@ async function enrichLinks({
1615
1672
  fileId: item.fileId,
1616
1673
  contentType: item.contentType,
1617
1674
  markdownPath: item.markdownPath,
1618
- links: []
1675
+ links: [],
1676
+ customLinks
1619
1677
  });
1620
1678
  continue;
1621
1679
  }
@@ -1634,7 +1692,8 @@ async function enrichLinks({
1634
1692
  fileId: item.fileId,
1635
1693
  contentType: item.contentType,
1636
1694
  markdownPath: item.markdownPath,
1637
- links: []
1695
+ links: [],
1696
+ customLinks
1638
1697
  });
1639
1698
  continue;
1640
1699
  }
@@ -1645,8 +1704,8 @@ async function enrichLinks({
1645
1704
  });
1646
1705
  const candidateResult = await openRouter.requestStructured({
1647
1706
  schemaName: "link_candidates",
1648
- schema: buildLinkCandidatesJsonSchema(),
1649
- messages: buildLinkCandidatesMessages(content, item.contentType),
1707
+ schema: buildLinkCandidatesJsonSchema(maxLinks),
1708
+ messages: buildLinkCandidatesMessages(content, item.contentType, maxLinks),
1650
1709
  settings,
1651
1710
  reasoning: LINKS_REASONING_SETTINGS,
1652
1711
  interactionContext: {
@@ -1657,7 +1716,10 @@ async function enrichLinks({
1657
1716
  parse(data) {
1658
1717
  const record = data;
1659
1718
  const expressions = Array.isArray(record.expressions) ? record.expressions.filter((value2) => typeof value2 === "string") : [];
1660
- return { expressions: dedupeExpressions(expressions).slice(0, 10) };
1719
+ const customExpressions = new Set(customLinks.map((e) => e.expression.trim().toLowerCase()));
1720
+ return {
1721
+ expressions: dedupeExpressions(expressions).filter((expr) => !customExpressions.has(expr.trim().toLowerCase())).slice(0, maxLinks)
1722
+ };
1661
1723
  },
1662
1724
  onMetrics(metrics) {
1663
1725
  onLlmMetrics?.(item.fileId, metrics);
@@ -1734,7 +1796,8 @@ async function enrichLinks({
1734
1796
  fileId: item.fileId,
1735
1797
  contentType: item.contentType,
1736
1798
  markdownPath: item.markdownPath,
1737
- links
1799
+ links,
1800
+ customLinks
1738
1801
  });
1739
1802
  }
1740
1803
  return results;
@@ -4355,7 +4418,8 @@ var linksResultSchema = z6.object({
4355
4418
  fileId: z6.string().min(1),
4356
4419
  contentType: z6.string().min(1),
4357
4420
  markdownPath: z6.string().min(1),
4358
- links: z6.array(linkEntrySchema)
4421
+ links: z6.array(linkEntrySchema),
4422
+ customLinks: z6.array(linkEntrySchema).default([])
4359
4423
  });
4360
4424
  var pipelineArtifactSummarySchema = z6.object({
4361
4425
  title: z6.string().min(1),
@@ -4553,6 +4617,9 @@ async function runPipelineShell(input, options = {}) {
4553
4617
  const shouldEnrichLinks = options.enrichLinks ?? false;
4554
4618
  const runMode = options.runMode ?? "fresh";
4555
4619
  const workingDir = options.workingDir ?? process.cwd();
4620
+ const pipelineCustomLinkRaws = options.customLinks ?? [];
4621
+ const pipelineUnlinks = options.unlinks ?? [];
4622
+ const pipelineMaxLinks = options.maxLinks;
4556
4623
  const outputPaths = resolveOutputPaths(input.config.settings, workingDir);
4557
4624
  const hasArticlePrimary = isArticlePrimary;
4558
4625
  const stageTracking = /* @__PURE__ */ new Map();
@@ -5422,15 +5489,19 @@ async function runPipelineShell(input, options = {}) {
5422
5489
  options.onUpdate?.(cloneStages(stages));
5423
5490
  } else if (linksResult) {
5424
5491
  const linksByFileId = new Map(linksResult.map((item) => [item.fileId, item.links]));
5425
- linksResult = eligibleOutputsForLinks.map((output) => ({
5492
+ const customLinksByFileId = new Map(linksResult.map((item) => [item.fileId, item.customLinks]));
5493
+ const resumedLinks = eligibleOutputsForLinks.map((output) => ({
5426
5494
  fileId: output.fileId,
5427
5495
  contentType: output.contentType,
5428
5496
  markdownPath: output.markdownPath,
5429
- links: linksByFileId.get(output.fileId) ?? []
5497
+ links: linksByFileId.get(output.fileId) ?? [],
5498
+ customLinks: customLinksByFileId.get(output.fileId) ?? []
5430
5499
  }));
5431
- for (const item of linksResult) {
5500
+ linksResult = resumedLinks;
5501
+ for (const item of resumedLinks) {
5432
5502
  await writeLinksFile(item.markdownPath, {
5433
- version: 1,
5503
+ version: 2,
5504
+ customLinks: item.customLinks,
5434
5505
  links: item.links
5435
5506
  });
5436
5507
  }
@@ -5439,7 +5510,7 @@ async function runPipelineShell(input, options = {}) {
5439
5510
  ...stages[6],
5440
5511
  status: "succeeded",
5441
5512
  detail: "Reused saved link metadata from .ideon/write.",
5442
- summary: `${linksResult.reduce((sum, item) => sum + item.links.length, 0)} links`,
5513
+ summary: `${resumedLinks.reduce((sum, item) => sum + item.links.length, 0)} links`,
5443
5514
  items: (stages[6].items ?? []).map((item) => ({
5444
5515
  ...item,
5445
5516
  status: "succeeded",
@@ -5457,6 +5528,8 @@ async function runPipelineShell(input, options = {}) {
5457
5528
  openRouter,
5458
5529
  settings: input.config.settings,
5459
5530
  dryRun,
5531
+ customLinks: parsePipelineCustomLinks(pipelineCustomLinkRaws, pipelineUnlinks),
5532
+ maxLinks: pipelineMaxLinks ?? resolveDefaultMaxLinks(input.config.settings.targetLength),
5460
5533
  onInteraction(interaction) {
5461
5534
  onLlmInteraction(interaction);
5462
5535
  },
@@ -5501,7 +5574,8 @@ async function runPipelineShell(input, options = {}) {
5501
5574
  costSource
5502
5575
  });
5503
5576
  await writeLinksFile(item.markdownPath, {
5504
- version: 1,
5577
+ version: 2,
5578
+ customLinks: item.customLinks,
5505
5579
  links: item.links
5506
5580
  });
5507
5581
  }
@@ -5866,7 +5940,6 @@ function toFilePrefix(contentType) {
5866
5940
  if (contentType === "reddit-post") return "reddit";
5867
5941
  if (contentType === "linkedin-post") return "linkedin";
5868
5942
  if (contentType === "newsletter") return "newsletter";
5869
- if (contentType === "landing-page-copy") return "landing";
5870
5943
  return contentType.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "content";
5871
5944
  }
5872
5945
  function getPrimaryTarget(contentTargets) {
@@ -5962,71 +6035,351 @@ function asWriteStageId(stageId) {
5962
6035
  }
5963
6036
  return null;
5964
6037
  }
6038
+ function parsePipelineCustomLinks(rawLinks, unlinks) {
6039
+ const result = /* @__PURE__ */ new Map();
6040
+ for (const raw of rawLinks) {
6041
+ const separatorIndex = raw.indexOf("->");
6042
+ if (separatorIndex < 0) {
6043
+ continue;
6044
+ }
6045
+ const expression = raw.slice(0, separatorIndex).trim();
6046
+ const url = raw.slice(separatorIndex + 2).trim();
6047
+ if (expression && url) {
6048
+ result.set(expression.toLowerCase(), { expression, url, title: null });
6049
+ }
6050
+ }
6051
+ for (const expr of unlinks) {
6052
+ result.delete(expr.trim().toLowerCase());
6053
+ }
6054
+ return Array.from(result.values());
6055
+ }
5965
6056
 
5966
- // src/cli/commands/writeTargetSpecs.ts
5967
- function parseTargetSpec(spec) {
5968
- const trimmed = spec.trim();
5969
- if (!trimmed) {
5970
- throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
6057
+ // src/cli/commands/links.ts
6058
+ import { readFile as readFile6, stat as stat3 } from "fs/promises";
6059
+ import path9 from "path";
6060
+ async function runLinksCommand(options, dependencies = {}) {
6061
+ const slug = normalizeSlug2(options.slug);
6062
+ const mode = normalizeMode(options.mode);
6063
+ const cwd2 = dependencies.cwd ?? process.cwd();
6064
+ const log = dependencies.log ?? ((message) => console.log(message));
6065
+ const resolved = await resolveRunInput({
6066
+ idea: `Enrich links for ${slug}`
6067
+ });
6068
+ const markdownPath = await resolveMarkdownPathForSlug2(resolved.config.settings, slug, cwd2);
6069
+ const frontmatter = await readFrontmatter(markdownPath);
6070
+ const fileId = path9.parse(markdownPath).name;
6071
+ const articleTitle = frontmatter.title ?? toTitleFromSlug(slug);
6072
+ const articleDescription = frontmatter.description ?? "";
6073
+ const openRouterApiKey = resolved.config.secrets.openRouterApiKey;
6074
+ if (!openRouterApiKey) {
6075
+ throw new ReportedError(
6076
+ "Missing OpenRouter API key. Run `ideon settings` to configure credentials or set IDEON_OPENROUTER_API_KEY."
6077
+ );
5971
6078
  }
5972
- const [rawType, rawCount] = trimmed.split("=");
5973
- if (!rawType || !rawCount) {
5974
- throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
6079
+ const openRouter = new OpenRouterClient(openRouterApiKey);
6080
+ const linksPath = resolveLinksPath(markdownPath);
6081
+ const existing = await readExistingLinks(linksPath);
6082
+ const updatedCustomLinks = resolveCustomLinks(existing?.customLinks ?? [], options.links ?? [], options.unlinks ?? []);
6083
+ const effectiveMaxLinks = options.maxLinks;
6084
+ const linksResult = await enrichLinks({
6085
+ markdownFiles: [{ markdownPath, fileId, contentType: "article" }],
6086
+ articleTitle,
6087
+ articleDescription,
6088
+ openRouter,
6089
+ settings: resolved.config.settings,
6090
+ dryRun: false,
6091
+ customLinks: updatedCustomLinks,
6092
+ maxLinks: effectiveMaxLinks,
6093
+ onItemProgress(event) {
6094
+ logProgress(event, log);
6095
+ }
6096
+ });
6097
+ const generatedLinks = linksResult[0]?.links ?? [];
6098
+ const mergedGeneratedLinks = mode === "append" ? mergeLinks(existing?.links ?? [], generatedLinks) : generatedLinks;
6099
+ const appendedCount = Math.max(0, mergedGeneratedLinks.length - (existing?.links.length ?? 0));
6100
+ await writeLinksFile(markdownPath, {
6101
+ version: 2,
6102
+ customLinks: updatedCustomLinks,
6103
+ links: mergedGeneratedLinks
6104
+ });
6105
+ const relativeMarkdownPath = formatRelativePath2(cwd2, markdownPath);
6106
+ const relativeLinksPath = formatRelativePath2(cwd2, linksPath);
6107
+ if (mode === "fresh") {
6108
+ const replaced = existing ? "Replaced existing links." : "Created links sidecar.";
6109
+ log(`Enriched links for "${slug}".`);
6110
+ log(`${replaced} Saved ${generatedLinks.length} generated + ${updatedCustomLinks.length} custom links to ${relativeLinksPath} (${relativeMarkdownPath}).`);
6111
+ return;
5975
6112
  }
5976
- const contentType = rawType.trim();
5977
- if (!contentTypeValues.includes(contentType)) {
6113
+ const baseCount = existing?.links.length ?? 0;
6114
+ const verb = existing ? "Appended and deduplicated links." : "Created links sidecar.";
6115
+ log(`Enriched links for "${slug}".`);
6116
+ log(`${verb} Base ${baseCount}, added ${appendedCount}, total ${mergedGeneratedLinks.length} generated + ${updatedCustomLinks.length} custom in ${relativeLinksPath} (${relativeMarkdownPath}).`);
6117
+ }
6118
+ function normalizeMode(rawMode) {
6119
+ const normalized = rawMode.trim().toLowerCase();
6120
+ if (normalized === "fresh" || normalized === "append") {
6121
+ return normalized;
6122
+ }
6123
+ throw new ReportedError(`Unsupported --mode value "${rawMode}". Use "fresh" or "append".`);
6124
+ }
6125
+ function normalizeSlug2(rawSlug) {
6126
+ const slug = rawSlug.trim();
6127
+ if (!slug) {
6128
+ throw new ReportedError("Slug cannot be empty. Pass the generated article slug, for example `ideon links my-article-slug`.");
6129
+ }
6130
+ if (slug.toLowerCase().endsWith(".md")) {
6131
+ throw new ReportedError(`Expected a slug, not a markdown filename: ${slug}. Pass the slug without .md.`);
6132
+ }
6133
+ if (slug === "." || slug === ".." || /[/\\]/.test(slug)) {
6134
+ throw new ReportedError(`Invalid slug "${slug}". Pass the article slug only, without any path separators.`);
6135
+ }
6136
+ return slug;
6137
+ }
6138
+ async function resolveMarkdownPathForSlug2(settings, slug, cwd2) {
6139
+ const outputPaths = resolveOutputPaths(settings, cwd2);
6140
+ const directPath = path9.join(outputPaths.markdownOutputDir, `${slug}.md`);
6141
+ if (await isReadableFile(directPath)) {
6142
+ return directPath;
6143
+ }
6144
+ const markdownFiles = await listMarkdownFilesRecursively(outputPaths.markdownOutputDir);
6145
+ const matches = [];
6146
+ for (const candidate of markdownFiles) {
6147
+ if (path9.basename(candidate) === `${slug}.md`) {
6148
+ matches.push(candidate);
6149
+ continue;
6150
+ }
6151
+ const frontmatter = await readFrontmatter(candidate);
6152
+ if (frontmatter.slug === slug) {
6153
+ matches.push(candidate);
6154
+ }
6155
+ }
6156
+ if (matches.length === 0) {
5978
6157
  throw new ReportedError(
5979
- `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6158
+ `Could not find article "${slug}". Expected a markdown file in ${outputPaths.markdownOutputDir} with frontmatter slug "${slug}".`
5980
6159
  );
5981
6160
  }
5982
- const count = Number.parseInt(rawCount.trim(), 10);
5983
- if (!Number.isFinite(count) || count <= 0) {
5984
- throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
6161
+ return newestPath(matches);
6162
+ }
6163
+ async function newestPath(paths) {
6164
+ let latestPath = paths[0];
6165
+ let latestMtime = 0;
6166
+ for (const candidate of paths) {
6167
+ const candidateStat = await stat3(candidate);
6168
+ if (candidateStat.mtimeMs >= latestMtime) {
6169
+ latestMtime = candidateStat.mtimeMs;
6170
+ latestPath = candidate;
6171
+ }
5985
6172
  }
6173
+ return latestPath;
6174
+ }
6175
+ async function readFrontmatter(markdownPath) {
6176
+ const markdown = await readFile6(markdownPath, "utf8");
6177
+ return parseFrontmatter(markdown);
6178
+ }
6179
+ function parseFrontmatter(markdown) {
6180
+ if (!markdown.startsWith("---\n")) {
6181
+ return { slug: null, title: null, description: null };
6182
+ }
6183
+ const frontmatterEnd = markdown.indexOf("\n---\n", 4);
6184
+ if (frontmatterEnd < 0) {
6185
+ return { slug: null, title: null, description: null };
6186
+ }
6187
+ const block = markdown.slice(4, frontmatterEnd);
5986
6188
  return {
5987
- contentType,
5988
- count
6189
+ slug: parseFrontmatterValue(block, "slug"),
6190
+ title: parseFrontmatterValue(block, "title"),
6191
+ description: parseFrontmatterValue(block, "description")
5989
6192
  };
5990
6193
  }
5991
- function parsePrimaryAndSecondarySpecs(options) {
5992
- const { primarySpec, secondarySpecs } = options;
5993
- if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
5994
- return void 0;
6194
+ function parseFrontmatterValue(block, key) {
6195
+ const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
6196
+ const match = block.match(pattern);
6197
+ if (!match || !match[1]) {
6198
+ return null;
5995
6199
  }
5996
- if (!primarySpec) {
5997
- throw new ReportedError("Missing required --primary <content-type=count>.");
6200
+ const rawValue = match[1].trim();
6201
+ if (!rawValue) {
6202
+ return null;
5998
6203
  }
5999
- const primary = parseTargetSpec(primarySpec);
6000
- if (primary.count !== 1) {
6001
- throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
6204
+ if (rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'")) {
6205
+ return rawValue.slice(1, -1);
6002
6206
  }
6003
- const secondaryDedupedByType = /* @__PURE__ */ new Map();
6004
- for (const spec of secondarySpecs ?? []) {
6005
- const parsed = parseTargetSpec(spec);
6006
- if (parsed.contentType === primary.contentType) {
6007
- throw new ReportedError(
6008
- `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6009
- );
6207
+ return rawValue;
6208
+ }
6209
+ function toTitleFromSlug(slug) {
6210
+ return slug.split("-").filter((part) => part.length > 0).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
6211
+ }
6212
+ async function isReadableFile(filePath) {
6213
+ try {
6214
+ const fileStat = await stat3(filePath);
6215
+ return fileStat.isFile();
6216
+ } catch {
6217
+ return false;
6218
+ }
6219
+ }
6220
+ async function readExistingLinks(linksPath) {
6221
+ try {
6222
+ const raw = await readFile6(linksPath, "utf8");
6223
+ const parsed = JSON.parse(raw);
6224
+ const links = Array.isArray(parsed.links) ? parsed.links.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6225
+ expression: entry.expression.trim(),
6226
+ url: entry.url.trim(),
6227
+ title: typeof entry.title === "string" ? entry.title : null
6228
+ })) : null;
6229
+ if (!links) {
6230
+ throw new ReportedError(`Invalid links sidecar format at ${linksPath}. Expected { version, links[] }.`);
6010
6231
  }
6011
- const previous = secondaryDedupedByType.get(parsed.contentType);
6012
- if (previous) {
6013
- previous.count += parsed.count;
6232
+ const customLinks = Array.isArray(parsed.customLinks) ? parsed.customLinks.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6233
+ expression: entry.expression.trim(),
6234
+ url: entry.url.trim(),
6235
+ title: typeof entry.title === "string" ? entry.title : null
6236
+ })) : [];
6237
+ return {
6238
+ version: typeof parsed.version === "number" ? parsed.version : 1,
6239
+ customLinks,
6240
+ links
6241
+ };
6242
+ } catch (error) {
6243
+ if (readErrorCode2(error) === "ENOENT") {
6244
+ return null;
6245
+ }
6246
+ if (error instanceof ReportedError) {
6247
+ throw error;
6248
+ }
6249
+ const message = error instanceof Error ? error.message : "Unknown links sidecar read error.";
6250
+ throw new ReportedError(`Failed to read existing links from ${linksPath}: ${message}`);
6251
+ }
6252
+ }
6253
+ function mergeLinks(existingLinks, generatedLinks) {
6254
+ const merged = [];
6255
+ const seen = /* @__PURE__ */ new Set();
6256
+ for (const entry of [...existingLinks, ...generatedLinks]) {
6257
+ const key = `${entry.expression.trim().toLowerCase()}::${entry.url.trim().toLowerCase()}`;
6258
+ if (seen.has(key)) {
6014
6259
  continue;
6015
6260
  }
6016
- secondaryDedupedByType.set(parsed.contentType, {
6017
- ...parsed,
6018
- role: "secondary"
6019
- });
6261
+ seen.add(key);
6262
+ merged.push(entry);
6020
6263
  }
6021
- return [
6022
- {
6023
- ...primary,
6024
- role: "primary"
6025
- },
6026
- ...secondaryDedupedByType.values()
6027
- ];
6264
+ return merged;
6028
6265
  }
6029
-
6266
+ function isValidLinkEntry(value2) {
6267
+ if (typeof value2 !== "object" || value2 === null) {
6268
+ return false;
6269
+ }
6270
+ const record = value2;
6271
+ return typeof record.expression === "string" && typeof record.url === "string" && (typeof record.title === "string" || record.title === null || record.title === void 0);
6272
+ }
6273
+ function readErrorCode2(error) {
6274
+ if (typeof error !== "object" || error === null || !("code" in error)) {
6275
+ return null;
6276
+ }
6277
+ const code = error.code;
6278
+ return typeof code === "string" ? code : null;
6279
+ }
6280
+ function formatRelativePath2(cwd2, targetPath) {
6281
+ const relativePath = path9.relative(cwd2, targetPath);
6282
+ return relativePath.length > 0 ? relativePath : targetPath;
6283
+ }
6284
+ function logProgress(event, log) {
6285
+ if (event.phase === "resolving-expression" || event.phase === "selecting-expressions") {
6286
+ return;
6287
+ }
6288
+ log(event.detail);
6289
+ }
6290
+ function parseCustomLinkFlag(raw) {
6291
+ const separatorIndex = raw.indexOf("->");
6292
+ if (separatorIndex < 0) {
6293
+ throw new ReportedError(`Invalid --link value "${raw}". Expected format: "expression->url".`);
6294
+ }
6295
+ const expression = raw.slice(0, separatorIndex).trim();
6296
+ const url = raw.slice(separatorIndex + 2).trim();
6297
+ if (!expression) {
6298
+ throw new ReportedError(`Invalid --link value "${raw}": expression (left side of ->) cannot be empty.`);
6299
+ }
6300
+ if (!url) {
6301
+ throw new ReportedError(`Invalid --link value "${raw}": url (right side of ->) cannot be empty.`);
6302
+ }
6303
+ return { expression, url };
6304
+ }
6305
+ function resolveCustomLinks(existing, addRaw, removeExpressions) {
6306
+ const result = new Map(
6307
+ existing.map((entry) => [entry.expression.trim().toLowerCase(), entry])
6308
+ );
6309
+ for (const raw of addRaw) {
6310
+ const { expression, url } = parseCustomLinkFlag(raw);
6311
+ result.set(expression.toLowerCase(), { expression, url, title: null });
6312
+ }
6313
+ for (const expr of removeExpressions) {
6314
+ result.delete(expr.trim().toLowerCase());
6315
+ }
6316
+ return Array.from(result.values());
6317
+ }
6318
+
6319
+ // src/cli/commands/writeTargetSpecs.ts
6320
+ function parseTargetSpec(spec) {
6321
+ const trimmed = spec.trim();
6322
+ if (!trimmed) {
6323
+ throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
6324
+ }
6325
+ const [rawType, rawCount] = trimmed.split("=");
6326
+ if (!rawType || !rawCount) {
6327
+ throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
6328
+ }
6329
+ const contentType = rawType.trim();
6330
+ if (!contentTypeValues.includes(contentType)) {
6331
+ throw new ReportedError(
6332
+ `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6333
+ );
6334
+ }
6335
+ const count = Number.parseInt(rawCount.trim(), 10);
6336
+ if (!Number.isFinite(count) || count <= 0) {
6337
+ throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
6338
+ }
6339
+ return {
6340
+ contentType,
6341
+ count
6342
+ };
6343
+ }
6344
+ function parsePrimaryAndSecondarySpecs(options) {
6345
+ const { primarySpec, secondarySpecs } = options;
6346
+ if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6347
+ return void 0;
6348
+ }
6349
+ if (!primarySpec) {
6350
+ throw new ReportedError("Missing required --primary <content-type=count>.");
6351
+ }
6352
+ const primary = parseTargetSpec(primarySpec);
6353
+ if (primary.count !== 1) {
6354
+ throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
6355
+ }
6356
+ const secondaryDedupedByType = /* @__PURE__ */ new Map();
6357
+ for (const spec of secondarySpecs ?? []) {
6358
+ const parsed = parseTargetSpec(spec);
6359
+ if (parsed.contentType === primary.contentType) {
6360
+ throw new ReportedError(
6361
+ `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6362
+ );
6363
+ }
6364
+ const previous = secondaryDedupedByType.get(parsed.contentType);
6365
+ if (previous) {
6366
+ previous.count += parsed.count;
6367
+ continue;
6368
+ }
6369
+ secondaryDedupedByType.set(parsed.contentType, {
6370
+ ...parsed,
6371
+ role: "secondary"
6372
+ });
6373
+ }
6374
+ return [
6375
+ {
6376
+ ...primary,
6377
+ role: "primary"
6378
+ },
6379
+ ...secondaryDedupedByType.values()
6380
+ ];
6381
+ }
6382
+
6030
6383
  // src/integrations/mcp/server.ts
6031
6384
  async function startIdeonMcpServer() {
6032
6385
  const server = new McpServer({
@@ -6051,6 +6404,7 @@ async function startIdeonMcpServer() {
6051
6404
  audience: input.audience,
6052
6405
  jobPath: input.jobPath,
6053
6406
  style: input.style,
6407
+ intent: input.intent,
6054
6408
  targetLength: input.length,
6055
6409
  contentTargets: parsedTargets
6056
6410
  });
@@ -6058,7 +6412,10 @@ async function startIdeonMcpServer() {
6058
6412
  workingDir: cwd(),
6059
6413
  runMode: "fresh",
6060
6414
  dryRun: input.dryRun ?? false,
6061
- enrichLinks: input.enrichLinks ?? true
6415
+ enrichLinks: input.enrichLinks ?? false,
6416
+ customLinks: input.link,
6417
+ unlinks: input.unlink,
6418
+ maxLinks: input.maxLinks
6062
6419
  });
6063
6420
  return {
6064
6421
  content: [
@@ -6114,7 +6471,10 @@ async function startIdeonMcpServer() {
6114
6471
  workingDir: cwd(),
6115
6472
  runMode: "resume",
6116
6473
  dryRun: input.dryRun ?? false,
6117
- enrichLinks: input.enrichLinks ?? true
6474
+ enrichLinks: input.enrichLinks ?? false,
6475
+ customLinks: input.link,
6476
+ unlinks: input.unlink,
6477
+ maxLinks: input.maxLinks
6118
6478
  });
6119
6479
  return {
6120
6480
  content: [
@@ -6173,6 +6533,48 @@ async function startIdeonMcpServer() {
6173
6533
  }
6174
6534
  }
6175
6535
  );
6536
+ server.registerTool(
6537
+ "ideon_links",
6538
+ {
6539
+ title: "Ideon Links",
6540
+ description: "Run link enrichment for a previously generated article by slug.",
6541
+ inputSchema: linksToolInputSchema
6542
+ },
6543
+ async (input) => {
6544
+ try {
6545
+ const messages = [];
6546
+ await runLinksCommand(
6547
+ {
6548
+ slug: input.slug,
6549
+ mode: input.mode ?? "fresh",
6550
+ links: input.link,
6551
+ unlinks: input.unlink,
6552
+ maxLinks: input.maxLinks
6553
+ },
6554
+ {
6555
+ cwd: cwd(),
6556
+ log: (message) => {
6557
+ messages.push(message);
6558
+ }
6559
+ }
6560
+ );
6561
+ return {
6562
+ content: [
6563
+ {
6564
+ type: "text",
6565
+ text: messages.length > 0 ? messages.join("\n") : `Enriched links for ${input.slug}.`
6566
+ }
6567
+ ],
6568
+ structuredContent: {
6569
+ slug: input.slug,
6570
+ mode: input.mode ?? "fresh"
6571
+ }
6572
+ };
6573
+ } catch (error) {
6574
+ return formatToolError(error);
6575
+ }
6576
+ }
6577
+ );
6176
6578
  server.registerTool(
6177
6579
  "ideon_config_get",
6178
6580
  {
@@ -6234,6 +6636,60 @@ async function startIdeonMcpServer() {
6234
6636
  }
6235
6637
  }
6236
6638
  );
6639
+ server.registerTool(
6640
+ "ideon_config_list",
6641
+ {
6642
+ title: "Ideon Config List",
6643
+ description: "List current settings and secret availability flags.",
6644
+ inputSchema: configListToolInputSchema
6645
+ },
6646
+ async (_input) => {
6647
+ try {
6648
+ const result = await configList();
6649
+ return {
6650
+ content: [
6651
+ {
6652
+ type: "text",
6653
+ text: JSON.stringify(result, null, 2)
6654
+ }
6655
+ ],
6656
+ structuredContent: result
6657
+ };
6658
+ } catch (error) {
6659
+ return formatToolError(error);
6660
+ }
6661
+ }
6662
+ );
6663
+ server.registerTool(
6664
+ "ideon_config_unset",
6665
+ {
6666
+ title: "Ideon Config Unset",
6667
+ description: "Reset a setting to its default or delete a stored secret.",
6668
+ inputSchema: configUnsetToolInputSchema
6669
+ },
6670
+ async (input) => {
6671
+ try {
6672
+ if (!isConfigKey(input.key)) {
6673
+ throw new ReportedError(`Unsupported config key: ${input.key}`);
6674
+ }
6675
+ await configUnset(input.key);
6676
+ return {
6677
+ content: [
6678
+ {
6679
+ type: "text",
6680
+ text: `Unset ${input.key}.`
6681
+ }
6682
+ ],
6683
+ structuredContent: {
6684
+ key: input.key,
6685
+ updated: true
6686
+ }
6687
+ };
6688
+ } catch (error) {
6689
+ return formatToolError(error);
6690
+ }
6691
+ }
6692
+ );
6237
6693
  const transport = new StdioServerTransport();
6238
6694
  await server.connect(transport);
6239
6695
  }
@@ -6250,229 +6706,6 @@ async function runMcpServeCommand() {
6250
6706
  await startIdeonMcpServer();
6251
6707
  }
6252
6708
 
6253
- // src/cli/commands/links.ts
6254
- import { readFile as readFile6, stat as stat3 } from "fs/promises";
6255
- import path9 from "path";
6256
- async function runLinksCommand(options, dependencies = {}) {
6257
- const slug = normalizeSlug2(options.slug);
6258
- const mode = normalizeMode(options.mode);
6259
- const cwd2 = dependencies.cwd ?? process.cwd();
6260
- const log = dependencies.log ?? ((message) => console.log(message));
6261
- const resolved = await resolveRunInput({
6262
- idea: `Enrich links for ${slug}`
6263
- });
6264
- const markdownPath = await resolveMarkdownPathForSlug2(resolved.config.settings, slug, cwd2);
6265
- const frontmatter = await readFrontmatter(markdownPath);
6266
- const fileId = path9.parse(markdownPath).name;
6267
- const articleTitle = frontmatter.title ?? toTitleFromSlug(slug);
6268
- const articleDescription = frontmatter.description ?? "";
6269
- const openRouterApiKey = resolved.config.secrets.openRouterApiKey;
6270
- if (!openRouterApiKey) {
6271
- throw new ReportedError(
6272
- "Missing OpenRouter API key. Run `ideon settings` to configure credentials or set IDEON_OPENROUTER_API_KEY."
6273
- );
6274
- }
6275
- const openRouter = new OpenRouterClient(openRouterApiKey);
6276
- const linksResult = await enrichLinks({
6277
- markdownFiles: [{ markdownPath, fileId, contentType: "article" }],
6278
- articleTitle,
6279
- articleDescription,
6280
- openRouter,
6281
- settings: resolved.config.settings,
6282
- dryRun: false,
6283
- onItemProgress(event) {
6284
- logProgress(event, log);
6285
- }
6286
- });
6287
- const generatedLinks = linksResult[0]?.links ?? [];
6288
- const linksPath = resolveLinksPath(markdownPath);
6289
- const existing = await readExistingLinks(linksPath);
6290
- const mergedLinks = mode === "append" ? mergeLinks(existing?.links ?? [], generatedLinks) : generatedLinks;
6291
- const appendedCount = Math.max(0, mergedLinks.length - (existing?.links.length ?? 0));
6292
- await writeLinksFile(markdownPath, {
6293
- version: 1,
6294
- links: mergedLinks
6295
- });
6296
- const relativeMarkdownPath = formatRelativePath2(cwd2, markdownPath);
6297
- const relativeLinksPath = formatRelativePath2(cwd2, linksPath);
6298
- if (mode === "fresh") {
6299
- const replaced = existing ? "Replaced existing links." : "Created links sidecar.";
6300
- log(`Enriched links for "${slug}".`);
6301
- log(`${replaced} Saved ${generatedLinks.length} links to ${relativeLinksPath} (${relativeMarkdownPath}).`);
6302
- return;
6303
- }
6304
- const baseCount = existing?.links.length ?? 0;
6305
- const verb = existing ? "Appended and deduplicated links." : "Created links sidecar.";
6306
- log(`Enriched links for "${slug}".`);
6307
- log(`${verb} Base ${baseCount}, added ${appendedCount}, total ${mergedLinks.length} in ${relativeLinksPath} (${relativeMarkdownPath}).`);
6308
- }
6309
- function normalizeMode(rawMode) {
6310
- const normalized = rawMode.trim().toLowerCase();
6311
- if (normalized === "fresh" || normalized === "append") {
6312
- return normalized;
6313
- }
6314
- throw new ReportedError(`Unsupported --mode value "${rawMode}". Use "fresh" or "append".`);
6315
- }
6316
- function normalizeSlug2(rawSlug) {
6317
- const slug = rawSlug.trim();
6318
- if (!slug) {
6319
- throw new ReportedError("Slug cannot be empty. Pass the generated article slug, for example `ideon links my-article-slug`.");
6320
- }
6321
- if (slug.toLowerCase().endsWith(".md")) {
6322
- throw new ReportedError(`Expected a slug, not a markdown filename: ${slug}. Pass the slug without .md.`);
6323
- }
6324
- if (slug === "." || slug === ".." || /[/\\]/.test(slug)) {
6325
- throw new ReportedError(`Invalid slug "${slug}". Pass the article slug only, without any path separators.`);
6326
- }
6327
- return slug;
6328
- }
6329
- async function resolveMarkdownPathForSlug2(settings, slug, cwd2) {
6330
- const outputPaths = resolveOutputPaths(settings, cwd2);
6331
- const directPath = path9.join(outputPaths.markdownOutputDir, `${slug}.md`);
6332
- if (await isReadableFile(directPath)) {
6333
- return directPath;
6334
- }
6335
- const markdownFiles = await listMarkdownFilesRecursively(outputPaths.markdownOutputDir);
6336
- const matches = [];
6337
- for (const candidate of markdownFiles) {
6338
- if (path9.basename(candidate) === `${slug}.md`) {
6339
- matches.push(candidate);
6340
- continue;
6341
- }
6342
- const frontmatter = await readFrontmatter(candidate);
6343
- if (frontmatter.slug === slug) {
6344
- matches.push(candidate);
6345
- }
6346
- }
6347
- if (matches.length === 0) {
6348
- throw new ReportedError(
6349
- `Could not find article "${slug}". Expected a markdown file in ${outputPaths.markdownOutputDir} with frontmatter slug "${slug}".`
6350
- );
6351
- }
6352
- return newestPath(matches);
6353
- }
6354
- async function newestPath(paths) {
6355
- let latestPath = paths[0];
6356
- let latestMtime = 0;
6357
- for (const candidate of paths) {
6358
- const candidateStat = await stat3(candidate);
6359
- if (candidateStat.mtimeMs >= latestMtime) {
6360
- latestMtime = candidateStat.mtimeMs;
6361
- latestPath = candidate;
6362
- }
6363
- }
6364
- return latestPath;
6365
- }
6366
- async function readFrontmatter(markdownPath) {
6367
- const markdown = await readFile6(markdownPath, "utf8");
6368
- return parseFrontmatter(markdown);
6369
- }
6370
- function parseFrontmatter(markdown) {
6371
- if (!markdown.startsWith("---\n")) {
6372
- return { slug: null, title: null, description: null };
6373
- }
6374
- const frontmatterEnd = markdown.indexOf("\n---\n", 4);
6375
- if (frontmatterEnd < 0) {
6376
- return { slug: null, title: null, description: null };
6377
- }
6378
- const block = markdown.slice(4, frontmatterEnd);
6379
- return {
6380
- slug: parseFrontmatterValue(block, "slug"),
6381
- title: parseFrontmatterValue(block, "title"),
6382
- description: parseFrontmatterValue(block, "description")
6383
- };
6384
- }
6385
- function parseFrontmatterValue(block, key) {
6386
- const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
6387
- const match = block.match(pattern);
6388
- if (!match || !match[1]) {
6389
- return null;
6390
- }
6391
- const rawValue = match[1].trim();
6392
- if (!rawValue) {
6393
- return null;
6394
- }
6395
- if (rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'")) {
6396
- return rawValue.slice(1, -1);
6397
- }
6398
- return rawValue;
6399
- }
6400
- function toTitleFromSlug(slug) {
6401
- return slug.split("-").filter((part) => part.length > 0).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
6402
- }
6403
- async function isReadableFile(filePath) {
6404
- try {
6405
- const fileStat = await stat3(filePath);
6406
- return fileStat.isFile();
6407
- } catch {
6408
- return false;
6409
- }
6410
- }
6411
- async function readExistingLinks(linksPath) {
6412
- try {
6413
- const raw = await readFile6(linksPath, "utf8");
6414
- const parsed = JSON.parse(raw);
6415
- const links = Array.isArray(parsed.links) ? parsed.links.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6416
- expression: entry.expression.trim(),
6417
- url: entry.url.trim(),
6418
- title: typeof entry.title === "string" ? entry.title : null
6419
- })) : null;
6420
- if (!links) {
6421
- throw new ReportedError(`Invalid links sidecar format at ${linksPath}. Expected { version, links[] }.`);
6422
- }
6423
- return {
6424
- version: typeof parsed.version === "number" ? parsed.version : 1,
6425
- links
6426
- };
6427
- } catch (error) {
6428
- if (readErrorCode2(error) === "ENOENT") {
6429
- return null;
6430
- }
6431
- if (error instanceof ReportedError) {
6432
- throw error;
6433
- }
6434
- const message = error instanceof Error ? error.message : "Unknown links sidecar read error.";
6435
- throw new ReportedError(`Failed to read existing links from ${linksPath}: ${message}`);
6436
- }
6437
- }
6438
- function mergeLinks(existingLinks, generatedLinks) {
6439
- const merged = [];
6440
- const seen = /* @__PURE__ */ new Set();
6441
- for (const entry of [...existingLinks, ...generatedLinks]) {
6442
- const key = `${entry.expression.trim().toLowerCase()}::${entry.url.trim().toLowerCase()}`;
6443
- if (seen.has(key)) {
6444
- continue;
6445
- }
6446
- seen.add(key);
6447
- merged.push(entry);
6448
- }
6449
- return merged;
6450
- }
6451
- function isValidLinkEntry(value2) {
6452
- if (typeof value2 !== "object" || value2 === null) {
6453
- return false;
6454
- }
6455
- const record = value2;
6456
- return typeof record.expression === "string" && typeof record.url === "string" && (typeof record.title === "string" || record.title === null || record.title === void 0);
6457
- }
6458
- function readErrorCode2(error) {
6459
- if (typeof error !== "object" || error === null || !("code" in error)) {
6460
- return null;
6461
- }
6462
- const code = error.code;
6463
- return typeof code === "string" ? code : null;
6464
- }
6465
- function formatRelativePath2(cwd2, targetPath) {
6466
- const relativePath = path9.relative(cwd2, targetPath);
6467
- return relativePath.length > 0 ? relativePath : targetPath;
6468
- }
6469
- function logProgress(event, log) {
6470
- if (event.phase === "resolving-expression" || event.phase === "selecting-expressions") {
6471
- return;
6472
- }
6473
- log(event.detail);
6474
- }
6475
-
6476
6709
  // src/cli/commands/settings.tsx
6477
6710
  import { render } from "ink";
6478
6711
 
@@ -6852,7 +7085,7 @@ import { spawn } from "child_process";
6852
7085
  import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
6853
7086
  import path10 from "path";
6854
7087
  var DEFAULT_PORT = 4173;
6855
- var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter", "landing-page-copy"];
7088
+ var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
6856
7089
  var FILE_PREFIX_TO_CONTENT_TYPE = {
6857
7090
  article: "article",
6858
7091
  blog: "blog-post",
@@ -6861,8 +7094,7 @@ var FILE_PREFIX_TO_CONTENT_TYPE = {
6861
7094
  x: "x-post",
6862
7095
  reddit: "reddit-post",
6863
7096
  linkedin: "linkedin-post",
6864
- newsletter: "newsletter",
6865
- landing: "landing-page-copy"
7097
+ newsletter: "newsletter"
6866
7098
  };
6867
7099
  var CONTENT_TYPE_LABELS = {
6868
7100
  article: "Article",
@@ -6871,8 +7103,7 @@ var CONTENT_TYPE_LABELS = {
6871
7103
  "x-post": "X Post",
6872
7104
  "reddit-post": "Reddit Post",
6873
7105
  "linkedin-post": "LinkedIn Post",
6874
- newsletter: "Newsletter",
6875
- "landing-page-copy": "Landing Page Copy"
7106
+ newsletter: "Newsletter"
6876
7107
  };
6877
7108
  function parsePort(portOption) {
6878
7109
  if (!portOption) {
@@ -7630,10 +7861,6 @@ function renderShell({
7630
7861
  --newsletter-bg: #fffdf4;
7631
7862
  --newsletter-header-bg: #fff5cc;
7632
7863
  --newsletter-border: #cfb95a;
7633
- --landing-bg: linear-gradient(155deg, #10395c 0%, #3d7fa0 100%);
7634
- --landing-text: #f8fdff;
7635
- --landing-link: #d7f0ff;
7636
- --landing-border: rgba(255, 255, 255, 0.3);
7637
7864
  color-scheme: light;
7638
7865
  }
7639
7866
 
@@ -7673,10 +7900,6 @@ function renderShell({
7673
7900
  --newsletter-bg: #2e291b;
7674
7901
  --newsletter-header-bg: #3b331e;
7675
7902
  --newsletter-border: #d6b25f;
7676
- --landing-bg: linear-gradient(155deg, #0f2236 0%, #245d7e 100%);
7677
- --landing-text: #e7f4ff;
7678
- --landing-link: #b8e4ff;
7679
- --landing-border: rgba(220, 239, 255, 0.35);
7680
7903
  color-scheme: dark;
7681
7904
  }
7682
7905
  }
@@ -7716,10 +7939,6 @@ function renderShell({
7716
7939
  --newsletter-bg: #fffdf4;
7717
7940
  --newsletter-header-bg: #fff5cc;
7718
7941
  --newsletter-border: #cfb95a;
7719
- --landing-bg: linear-gradient(155deg, #10395c 0%, #3d7fa0 100%);
7720
- --landing-text: #f8fdff;
7721
- --landing-link: #d7f0ff;
7722
- --landing-border: rgba(255, 255, 255, 0.3);
7723
7942
  color-scheme: light;
7724
7943
  }
7725
7944
 
@@ -7758,10 +7977,6 @@ function renderShell({
7758
7977
  --newsletter-bg: #2e291b;
7759
7978
  --newsletter-header-bg: #3b331e;
7760
7979
  --newsletter-border: #d6b25f;
7761
- --landing-bg: linear-gradient(155deg, #0f2236 0%, #245d7e 100%);
7762
- --landing-text: #e7f4ff;
7763
- --landing-link: #b8e4ff;
7764
- --landing-border: rgba(220, 239, 255, 0.35);
7765
7980
  color-scheme: dark;
7766
7981
  }
7767
7982
 
@@ -8281,21 +8496,6 @@ function renderShell({
8281
8496
  background: var(--newsletter-header-bg);
8282
8497
  }
8283
8498
 
8284
- .channel-landing-page-copy {
8285
- background: var(--landing-bg);
8286
- color: var(--landing-text);
8287
- border: none;
8288
- }
8289
-
8290
- .channel-landing-page-copy .channel-header {
8291
- border-bottom: 1px solid var(--landing-border);
8292
- }
8293
-
8294
- .channel-landing-page-copy .channel-meta,
8295
- .channel-landing-page-copy a {
8296
- color: var(--landing-link);
8297
- }
8298
-
8299
8499
  .channel-article,
8300
8500
  .channel-blog-post {
8301
8501
  background: var(--paper);
@@ -8481,7 +8681,7 @@ function renderShell({
8481
8681
  const articleElement = document.getElementById('article');
8482
8682
  const articleListElement = document.getElementById('articleList');
8483
8683
  const themeToggleButton = document.getElementById('themeToggle');
8484
- const typeOrder = ['article', 'blog-post', 'x-thread', 'x-post', 'linkedin-post', 'reddit-post', 'newsletter', 'landing-page-copy'];
8684
+ const typeOrder = ['article', 'blog-post', 'x-thread', 'x-post', 'linkedin-post', 'reddit-post', 'newsletter'];
8485
8685
  const stageOrder = ['shared-brief', 'planning', 'sections', 'image-prompts', 'images', 'output', 'links'];
8486
8686
 
8487
8687
  let currentGeneration = null;
@@ -9582,7 +9782,7 @@ function formatPipelineStageCost(stage) {
9582
9782
  }
9583
9783
  return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9584
9784
  }
9585
- async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
9785
+ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks) {
9586
9786
  let previousStages = /* @__PURE__ */ new Map();
9587
9787
  let previousItemStatuses = /* @__PURE__ */ new Map();
9588
9788
  const notificationsEnabled = input.config.settings.notifications.enabled;
@@ -9596,6 +9796,9 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
9596
9796
  dryRun,
9597
9797
  enrichLinks: enrichLinks2,
9598
9798
  runMode,
9799
+ customLinks: links,
9800
+ unlinks,
9801
+ maxLinks,
9599
9802
  onUpdate(stages) {
9600
9803
  for (const stage of stages) {
9601
9804
  const previous = previousStages.get(stage.id);
@@ -9954,6 +10157,9 @@ function WriteApp({
9954
10157
  dryRun,
9955
10158
  enrichLinks: enrichLinks2,
9956
10159
  runMode,
10160
+ links,
10161
+ unlinks,
10162
+ maxLinks,
9957
10163
  onError
9958
10164
  }) {
9959
10165
  const { exit } = useApp3();
@@ -9977,6 +10183,9 @@ function WriteApp({
9977
10183
  dryRun,
9978
10184
  enrichLinks: enrichLinks2,
9979
10185
  runMode,
10186
+ customLinks: links,
10187
+ unlinks,
10188
+ maxLinks,
9980
10189
  onUpdate(nextStages) {
9981
10190
  if (mounted) {
9982
10191
  setStages(nextStages);
@@ -10009,7 +10218,7 @@ function WriteApp({
10009
10218
  return () => {
10010
10219
  mounted = false;
10011
10220
  };
10012
- }, [dryRun, enrichLinks2, input, onError, runMode]);
10221
+ }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, onError, runMode]);
10013
10222
  useEffect2(() => {
10014
10223
  if (!result && !errorMessage) {
10015
10224
  return;
@@ -10025,7 +10234,7 @@ function WriteApp({
10025
10234
  }
10026
10235
  async function runWriteCommand(options) {
10027
10236
  const input = await resolveInputWithInteractiveIdeaFallback(options);
10028
- await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive);
10237
+ await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks);
10029
10238
  }
10030
10239
  async function runWriteResumeCommand(options = {}) {
10031
10240
  const session = await loadWriteSession();
@@ -10047,9 +10256,9 @@ async function runWriteResumeCommand(options = {}) {
10047
10256
  secrets: resolved.config.secrets
10048
10257
  }
10049
10258
  };
10050
- await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false);
10259
+ await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks);
10051
10260
  }
10052
- async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive) {
10261
+ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks) {
10053
10262
  let interruptHandled = false;
10054
10263
  const handleSignal = (signal) => {
10055
10264
  if (interruptHandled) {
@@ -10083,7 +10292,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10083
10292
  process.on("SIGTERM", onSigterm);
10084
10293
  try {
10085
10294
  if (noInteractive || !process.stdout.isTTY) {
10086
- await renderPlainPipeline(input, dryRun, enrichLinks2, runMode);
10295
+ await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks);
10087
10296
  return;
10088
10297
  }
10089
10298
  let commandError = null;
@@ -10095,6 +10304,9 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10095
10304
  dryRun,
10096
10305
  enrichLinks: enrichLinks2,
10097
10306
  runMode,
10307
+ links,
10308
+ unlinks,
10309
+ maxLinks,
10098
10310
  onError: (error) => {
10099
10311
  commandError = error;
10100
10312
  }
@@ -10298,10 +10510,13 @@ async function runCli(argv) {
10298
10510
  force: options.force
10299
10511
  });
10300
10512
  });
10301
- program.command("links").description("Run link enrichment for a previously generated article by slug.").argument("<slug>", "Slug of the generated article to enrich").option("--mode <mode>", "Link merge mode: fresh or append", "fresh").action(async (slug, options) => {
10513
+ program.command("links").description("Run link enrichment for a previously generated article by slug.").argument("<slug>", "Slug of the generated article to enrich").option("--mode <mode>", "Link merge mode: fresh or append", "fresh").option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (slug, options) => {
10302
10514
  await runLinksCommand({
10303
10515
  slug,
10304
- mode: options.mode
10516
+ mode: options.mode,
10517
+ links: options.link,
10518
+ unlinks: options.unlink,
10519
+ maxLinks: options.maxLinks
10305
10520
  });
10306
10521
  });
10307
10522
  program.command("preview").description("Preview a generated article in a local browser with linked assets.").argument("[markdownPath]", "Path to the markdown file to preview").option("-p, --port <port>", "Port for the local preview server (default: 4173)").option("--no-open", "Do not auto-open browser after server startup").option("--watch", "Rebuild the preview UI on source changes and auto-reload the browser", false).action(async (markdownPath, options) => {
@@ -10312,7 +10527,7 @@ async function runCli(argv) {
10312
10527
  watch: options.watch
10313
10528
  });
10314
10529
  });
10315
- const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (academic, analytical, authoritative, conversational, empathetic, friendly, journalistic, minimalist, persuasive, playful, professional, storytelling, technical)").option("--intent <intent>", "Content intent (announcement, case-study, cornerstone, counterargument, critique-review, deep-dive-analysis, how-to-guide, interview-q-and-a, listicle, opinion-piece, personal-essay, roundup-curation, tutorial)").option("--length <size>", "Target length: small, medium, large, or a positive integer word count").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--enrich-links", "Run link enrichment after markdown generation", false).action(async (ideaArg, options) => {
10530
+ const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (academic, analytical, authoritative, conversational, empathetic, friendly, journalistic, minimalist, persuasive, playful, professional, storytelling, technical)").option("--intent <intent>", "Content intent (announcement, case-study, cornerstone, counterargument, critique-review, deep-dive-analysis, how-to-guide, interview-q-and-a, listicle, opinion-piece, personal-essay, roundup-curation, tutorial)").option("--length <size>", "Target length: small, medium, large, or a positive integer word count").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (ideaArg, options) => {
10316
10531
  await runWriteCommand({
10317
10532
  idea: options.idea ?? ideaArg,
10318
10533
  audience: options.audience,
@@ -10324,13 +10539,19 @@ async function runCli(argv) {
10324
10539
  length: options.length,
10325
10540
  noInteractive: !options.interactive,
10326
10541
  dryRun: options.dryRun,
10327
- enrichLinks: options.enrichLinks
10542
+ enrichLinks: options.enrichLinks,
10543
+ links: options.link,
10544
+ unlinks: options.unlink,
10545
+ maxLinks: options.maxLinks
10328
10546
  });
10329
10547
  });
10330
- writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).option("--enrich-links", "Run link enrichment after markdown generation", false).action(async (options) => {
10548
+ writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (options) => {
10331
10549
  await runWriteResumeCommand({
10332
10550
  noInteractive: options.noInteractive,
10333
- enrichLinks: options.enrichLinks
10551
+ enrichLinks: options.enrichLinks,
10552
+ links: options.link,
10553
+ unlinks: options.unlink,
10554
+ maxLinks: options.maxLinks
10334
10555
  });
10335
10556
  });
10336
10557
  await program.parseAsync(argv);