@telepat/ideon 0.1.14 → 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/README.md CHANGED
@@ -1,16 +1,23 @@
1
- # Ideon
1
+ <p align="center"><img src="./ideon-logo.webp" width="128" alt="Ideon"></p>
2
+ <h1 align="center">Ideon</h1>
3
+ <p align="center"><em>One idea. Endless formats.</em></p>
4
+
5
+ <p align="center">
6
+ <a href="https://docs.telepat.io/ideon">📖 Docs</a>
7
+ · <a href="./README.md">🇺🇸 English</a>
8
+ · <a href="./README.zh-CN.md">🇨🇳 简体中文</a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/telepat-io/ideon/actions/workflows/ci.yml"><img src="https://github.com/telepat-io/ideon/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build"></a>
13
+ <a href="https://codecov.io/gh/telepat-io/ideon"><img src="https://codecov.io/gh/telepat-io/ideon/graph/badge.svg" alt="Codecov"></a>
14
+ <a href="https://www.npmjs.com/package/@telepat/ideon"><img src="https://img.shields.io/npm/v/@telepat/ideon" alt="npm"></a>
15
+ <a href="https://github.com/telepat-io/ideon/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-yellow.svg" alt="License"></a>
16
+ </p>
2
17
 
3
18
  Ideon is an AI content writer that turns one idea into publish-ready content across multiple formats, styles, and channels.
4
19
 
5
- [🇺🇸 English](./README.md) | [🇨🇳 简体中文](./README.zh-Hans.md)
6
-
7
- [![CI](https://github.com/telepat-io/ideon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/telepat-io/ideon/actions/workflows/ci.yml)
8
- [![Coverage](https://codecov.io/gh/telepat-io/ideon/graph/badge.svg)](https://codecov.io/gh/telepat-io/ideon)
9
- [![npm version](https://img.shields.io/npm/v/%40telepat%2Fideon)](https://www.npmjs.com/package/@telepat/ideon)
10
- [![Docs](https://img.shields.io/badge/docs-live-1f6feb)](https://docs.telepat.io/ideon)
11
- [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://github.com/telepat-io/ideon/blob/main/LICENSE)
12
-
13
- ## Why Teams Use Ideon
20
+ ## What It Solves
14
21
 
15
22
  Ideon helps teams move from idea to publishable content faster, with less manual rewriting between channels.
16
23
 
@@ -48,15 +55,6 @@ Expected outcome:
48
55
  - OpenRouter API key
49
56
  - Replicate API token
50
57
 
51
- ## Core Capabilities
52
-
53
- - Multi-format writing from one idea: article, blog, newsletter, Reddit, LinkedIn, X thread, X post, landing-page copy.
54
- - Style control per run: choose one voice and apply it consistently across all outputs.
55
- - Research and enrichment: generate planning briefs and add relevant links via link enrichment.
56
- - Image generation: render cover and inline visuals for article-focused runs.
57
- - Iteration workflows: rerun with different targets/styles, resume interrupted jobs, and use job files for repeatable execution.
58
- - Local review: preview generated outputs and assets in a browser before publishing.
59
-
60
58
  ## How It Works
61
59
 
62
60
  Ideon runs a staged writing pipeline: planning, drafting, image prompt expansion, image rendering, channel output generation, and optional link enrichment.
@@ -74,14 +72,18 @@ ideon write --job ./job.json
74
72
  ideon write resume
75
73
  ideon delete my-article-slug
76
74
  ideon preview --no-open
77
- ideon mcp serve
78
- ideon agent status --json
79
75
  ```
80
76
 
81
- Agent integration scope:
77
+ ## Using With AI Agents
78
+
79
+ Ideon is built for agentic workflows:
82
80
 
83
- - Supported: CLI and MCP runtime workflows.
84
- - Not supported: Cursor and VS Code runtime integrations.
81
+ - **MCP server** — `ideon mcp serve` exposes 5 tools over stdio for content generation, resume, deletion, and config management. Compatible with Claude Code, ChatGPT, Gemini, and any generic MCP host.
82
+ - **Agent runtime registration** `ideon agent install <runtime>` registers integration profiles for supported platforms. Check status with `ideon agent status --json`.
83
+ - **Non-interactive mode** — `ideon write --no-interactive ...` removes all prompts for CI and automation.
84
+ - **Machine-readable config** — `ideon config list --json` and `ideon config get <key> --json` for agent inspection.
85
+ - **Skill package** — Install `ideon-cli-skill/` into your agent host for a full lifecycle skill covering install, setup, operations, and debugging.
86
+ - **Agent docs** — [For Agents](https://docs.telepat.io/ideon/for-agents) covers MCP servers, skills, and maintenance.
85
87
 
86
88
  ## Security And Trust
87
89
 
@@ -94,18 +96,18 @@ To report a security issue, open a private report through the repository securit
94
96
 
95
97
  ## Documentation And Support
96
98
 
97
- - Documentation site: https://docs.telepat.io/ideon
98
- - Language support: English and Simplified Chinese (`README.md` / `README.zh-Hans.md`, plus docs locales)
99
- - Getting started: `docs/getting-started/quickstart.md`
100
- - CLI reference: `docs/reference/cli-reference.md`
101
- - Configuration guide: `docs/guides/configuration.md`
102
- - Troubleshooting guide: `docs/guides/troubleshooting.md`
103
- - Repository: https://github.com/telepat-io/ideon
104
- - npm package: https://www.npmjs.com/package/@telepat/ideon
99
+ - [Documentation site](https://docs.telepat.io/ideon)
100
+ - [Quickstart](https://docs.telepat.io/ideon/getting-started/quickstart)
101
+ - [CLI reference](https://docs.telepat.io/ideon/reference/cli-reference)
102
+ - [Configuration guide](https://docs.telepat.io/ideon/guides/configuration)
103
+ - [Troubleshooting](https://docs.telepat.io/ideon/guides/troubleshooting)
104
+ - [For Agents](https://docs.telepat.io/ideon/for-agents)
105
+ - [Repository](https://github.com/telepat-io/ideon)
106
+ - [npm package](https://www.npmjs.com/package/@telepat/ideon)
105
107
 
106
108
  ## Contributing
107
109
 
108
- Contributions are welcome. Start with `docs/contributing/development.md` for setup, workflow, and quality gates, then follow `docs/contributing/releasing-and-docs-deploy.md` for release and docs deployment details.
110
+ Contributions are welcome. Start with [Development](https://docs.telepat.io/ideon/contributing/development) for setup, workflow, and quality gates, then follow [Releasing and Docs Deploy](https://docs.telepat.io/ideon/contributing/releasing-and-docs-deploy) for release and docs deployment details.
109
111
 
110
112
  For user-facing documentation changes, update both English and Simplified Chinese content in the same change.
111
113
 
@@ -0,0 +1,116 @@
1
+ <p align="center"><img src="./ideon-logo.webp" width="128" alt="Ideon"></p>
2
+ <h1 align="center">Ideon</h1>
3
+ <p align="center"><em>一个想法,无限格式。</em></p>
4
+
5
+ <p align="center">
6
+ <a href="https://docs.telepat.io/ideon">📖 文档</a>
7
+ · <a href="./README.md">🇺🇸 English</a>
8
+ · <a href="./README.zh-CN.md">🇨🇳 简体中文</a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/telepat-io/ideon/actions/workflows/ci.yml"><img src="https://github.com/telepat-io/ideon/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build"></a>
13
+ <a href="https://codecov.io/gh/telepat-io/ideon"><img src="https://codecov.io/gh/telepat-io/ideon/graph/badge.svg" alt="Codecov"></a>
14
+ <a href="https://www.npmjs.com/package/@telepat/ideon"><img src="https://img.shields.io/npm/v/@telepat/ideon" alt="npm"></a>
15
+ <a href="https://github.com/telepat-io/ideon/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-yellow.svg" alt="License"></a>
16
+ </p>
17
+
18
+ Ideon 是一款 AI 内容写作工具,可将一个想法转化为多格式、多风格、可发布的内容。
19
+
20
+ ## 它能解决什么问题
21
+
22
+ Ideon 帮助团队更快地从想法走到可发布内容,减少跨渠道重复改写的工作量。
23
+
24
+ 一次运行,Ideon 可以:
25
+
26
+ - 基于同一核心想法生成多种输出类型:article、blog、newsletter、Reddit、LinkedIn、X thread、X post 等。
27
+ - 在所有输出中统一应用写作风格(`professional`、`friendly`、`technical`、`academic`、`opinionated`、`storytelling`)。
28
+ - 生成研究导向的内容 brief,补充相关链接,并在文章型任务中生成配图。
29
+ - 通过作业文件、可配置参数与断点恢复能力支持持续迭代。
30
+
31
+ 这使 Ideon 适用于内容团队、开发者关系、产品营销、创始人以及需要按节奏进行多渠道写作的个人或团队。
32
+
33
+ ## 快速开始
34
+
35
+ 安装并完成第一次内容生成:
36
+
37
+ ```bash
38
+ npm i -g @telepat/ideon
39
+ ideon settings
40
+ ideon write "How small editorial teams can productionize AI writing" --primary article=1 --secondary x-post=1
41
+ ideon preview
42
+ ```
43
+
44
+ 预期结果:
45
+
46
+ - 在 `output/<timestamp>-<slug>/` 下生成一个运行目录。
47
+ - 产出一个或多个可发布的 Markdown 输出。
48
+ - 保存 analytics 与元数据,便于复现与回溯。
49
+ - 本地预览自动打开,便于检查内容、链接与资源。
50
+
51
+ ## 环境要求
52
+
53
+ - Node.js 20+
54
+ - npm 10+
55
+ - OpenRouter API key
56
+ - Replicate API token
57
+
58
+ ## 工作原理
59
+
60
+ Ideon 采用分阶段写作流水线:内容规划、正文生成、图像提示扩展、图像渲染、渠道内容生成,以及可选的链接增强。
61
+
62
+ 运行时会合并 settings、环境变量、job 文件与 CLI 参数,并输出结构化产物,便于追踪与复用。
63
+
64
+ 核心命令:
65
+
66
+ ```bash
67
+ ideon settings
68
+ ideon config list --json
69
+ ideon write "An article idea" --primary article=1
70
+ ideon write --no-interactive --idea "An article idea" --primary article=1 --style technical --length medium
71
+ ideon write --job ./job.json
72
+ ideon write resume
73
+ ideon delete my-article-slug
74
+ ideon preview --no-open
75
+ ```
76
+
77
+ ## 与 AI Agent 一起使用
78
+
79
+ Ideon 专为智能体工作流打造:
80
+
81
+ - **MCP 服务器** — `ideon mcp serve` 通过 stdio 暴露 5 个工具,覆盖内容生成、恢复、删除和配置管理。兼容 Claude Code、ChatGPT、Gemini 及任何通用 MCP 主机。
82
+ - **Agent 运行时注册** — `ideon agent install <runtime>` 为支持的平台注册集成配置文件。使用 `ideon agent status --json` 查看状态。
83
+ - **非交互模式** — `ideon write --no-interactive ...` 移除所有提示,适用于 CI 和自动化场景。
84
+ - **机器可读配置** — `ideon config list --json` 与 `ideon config get <key> --json` 供智能体 inspection。
85
+ - **Skill 包** — 将 `ideon-cli-skill/` 安装到智能体主机,获得覆盖安装、配置、操作与调试的完整生命周期 skill。
86
+ - **Agent 文档** — [For Agents](https://docs.telepat.io/ideon/for-agents) 涵盖 MCP 服务器、skill 与维护指南。
87
+
88
+ ## 安全与信任
89
+
90
+ - 默认通过 `ideon settings` 将密钥保存到系统钥匙串。
91
+ - 在 CI 或容器环境中,请使用 `IDEON_OPENROUTER_API_KEY` 和 `IDEON_REPLICATE_API_TOKEN`。
92
+ - 在无法访问钥匙串时设置 `IDEON_DISABLE_KEYTAR=true`。
93
+ - 生成内容来自模型输出,发布前请进行人工审阅。
94
+
95
+ 如需报告安全问题,请通过仓库安全报告通道私下提交,或通过仓库 issue 渠道联系维护者并避免包含敏感细节。
96
+
97
+ ## 文档与支持
98
+
99
+ - [文档站点](https://docs.telepat.io/ideon)
100
+ - [快速上手](https://docs.telepat.io/ideon/getting-started/quickstart)
101
+ - [CLI 参考](https://docs.telepat.io/ideon/reference/cli-reference)
102
+ - [配置指南](https://docs.telepat.io/ideon/guides/configuration)
103
+ - [故障排查](https://docs.telepat.io/ideon/guides/troubleshooting)
104
+ - [For Agents](https://docs.telepat.io/ideon/for-agents)
105
+ - [仓库](https://github.com/telepat-io/ideon)
106
+ - [npm 包](https://www.npmjs.com/package/@telepat/ideon)
107
+
108
+ ## 贡献
109
+
110
+ 欢迎贡献。请先阅读 [Development](https://docs.telepat.io/ideon/contributing/development)(开发环境与质量门禁),再参考 [Releasing and Docs Deploy](https://docs.telepat.io/ideon/contributing/releasing-and-docs-deploy)(发布与文档部署流程)。
111
+
112
+ 如修改面向用户的文档内容,请在同一变更中同时更新 English 与 简体中文版本。
113
+
114
+ ## 许可证
115
+
116
+ MIT。详见 [LICENSE](./LICENSE)。
package/dist/ideon.js CHANGED
@@ -972,12 +972,18 @@ var writeToolInputSchema = {
972
972
  intent: z3.enum(contentIntentValues).optional(),
973
973
  length: z3.union([z3.enum(targetLengthValues), z3.coerce.number().int().positive()]).optional(),
974
974
  dryRun: z3.boolean().optional(),
975
- 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()
976
979
  };
977
980
  var writeToolInputZodSchema = z3.object(writeToolInputSchema);
978
981
  var writeResumeToolInputSchema = {
979
982
  dryRun: z3.boolean().optional(),
980
- 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()
981
987
  };
982
988
  var writeResumeToolInputZodSchema = z3.object(writeResumeToolInputSchema);
983
989
  var deleteToolInputSchema = {
@@ -993,6 +999,20 @@ var configSetToolInputSchema = {
993
999
  value: z3.string()
994
1000
  };
995
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);
996
1016
  var ideonToolContracts = [
997
1017
  {
998
1018
  name: "ideon_write",
@@ -1003,6 +1023,23 @@ var ideonToolContracts = [
1003
1023
  length: [...targetLengthValues]
1004
1024
  }
1005
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
+ },
1006
1043
  {
1007
1044
  name: "ideon_config_set",
1008
1045
  required: ["key", "value"],
@@ -1016,6 +1053,18 @@ var ideonToolContracts = [
1016
1053
  enums: {
1017
1054
  key: [...configKeys]
1018
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
+ }
1019
1068
  }
1020
1069
  ];
1021
1070
 
@@ -1305,7 +1354,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1305
1354
  // package.json
1306
1355
  var package_default = {
1307
1356
  name: "@telepat/ideon",
1308
- version: "0.1.14",
1357
+ version: "0.1.15",
1309
1358
  description: "CLI for generating rich articles and images from ideas.",
1310
1359
  type: "module",
1311
1360
  repository: {
@@ -6005,79 +6054,341 @@ function parsePipelineCustomLinks(rawLinks, unlinks) {
6005
6054
  return Array.from(result.values());
6006
6055
  }
6007
6056
 
6008
- // src/cli/commands/writeTargetSpecs.ts
6009
- function parseTargetSpec(spec) {
6010
- const trimmed = spec.trim();
6011
- if (!trimmed) {
6012
- 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
+ );
6013
6078
  }
6014
- const [rawType, rawCount] = trimmed.split("=");
6015
- if (!rawType || !rawCount) {
6016
- 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;
6017
6112
  }
6018
- const contentType = rawType.trim();
6019
- 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) {
6020
6157
  throw new ReportedError(
6021
- `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}".`
6022
6159
  );
6023
6160
  }
6024
- const count = Number.parseInt(rawCount.trim(), 10);
6025
- if (!Number.isFinite(count) || count <= 0) {
6026
- 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
+ }
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 };
6027
6186
  }
6187
+ const block = markdown.slice(4, frontmatterEnd);
6028
6188
  return {
6029
- contentType,
6030
- count
6189
+ slug: parseFrontmatterValue(block, "slug"),
6190
+ title: parseFrontmatterValue(block, "title"),
6191
+ description: parseFrontmatterValue(block, "description")
6031
6192
  };
6032
6193
  }
6033
- function parsePrimaryAndSecondarySpecs(options) {
6034
- const { primarySpec, secondarySpecs } = options;
6035
- if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6036
- 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;
6037
6199
  }
6038
- if (!primarySpec) {
6039
- throw new ReportedError("Missing required --primary <content-type=count>.");
6200
+ const rawValue = match[1].trim();
6201
+ if (!rawValue) {
6202
+ return null;
6040
6203
  }
6041
- const primary = parseTargetSpec(primarySpec);
6042
- if (primary.count !== 1) {
6043
- 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);
6044
6206
  }
6045
- const secondaryDedupedByType = /* @__PURE__ */ new Map();
6046
- for (const spec of secondarySpecs ?? []) {
6047
- const parsed = parseTargetSpec(spec);
6048
- if (parsed.contentType === primary.contentType) {
6049
- throw new ReportedError(
6050
- `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6051
- );
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[] }.`);
6052
6231
  }
6053
- const previous = secondaryDedupedByType.get(parsed.contentType);
6054
- if (previous) {
6055
- previous.count += parsed.count;
6056
- continue;
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;
6057
6245
  }
6058
- secondaryDedupedByType.set(parsed.contentType, {
6059
- ...parsed,
6060
- role: "secondary"
6061
- });
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}`);
6062
6251
  }
6063
- return [
6064
- {
6065
- ...primary,
6066
- role: "primary"
6067
- },
6068
- ...secondaryDedupedByType.values()
6069
- ];
6070
6252
  }
6071
-
6072
- // src/integrations/mcp/server.ts
6073
- async function startIdeonMcpServer() {
6074
- const server = new McpServer({
6075
- name: "ideon",
6076
- version: package_default.version
6077
- });
6078
- server.registerTool(
6079
- "ideon_write",
6080
- {
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)) {
6259
+ continue;
6260
+ }
6261
+ seen.add(key);
6262
+ merged.push(entry);
6263
+ }
6264
+ return merged;
6265
+ }
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
+
6383
+ // src/integrations/mcp/server.ts
6384
+ async function startIdeonMcpServer() {
6385
+ const server = new McpServer({
6386
+ name: "ideon",
6387
+ version: package_default.version
6388
+ });
6389
+ server.registerTool(
6390
+ "ideon_write",
6391
+ {
6081
6392
  title: "Ideon Write",
6082
6393
  description: "Generate content from an idea using the Ideon pipeline.",
6083
6394
  inputSchema: writeToolInputSchema
@@ -6093,6 +6404,7 @@ async function startIdeonMcpServer() {
6093
6404
  audience: input.audience,
6094
6405
  jobPath: input.jobPath,
6095
6406
  style: input.style,
6407
+ intent: input.intent,
6096
6408
  targetLength: input.length,
6097
6409
  contentTargets: parsedTargets
6098
6410
  });
@@ -6100,7 +6412,10 @@ async function startIdeonMcpServer() {
6100
6412
  workingDir: cwd(),
6101
6413
  runMode: "fresh",
6102
6414
  dryRun: input.dryRun ?? false,
6103
- enrichLinks: input.enrichLinks ?? true
6415
+ enrichLinks: input.enrichLinks ?? false,
6416
+ customLinks: input.link,
6417
+ unlinks: input.unlink,
6418
+ maxLinks: input.maxLinks
6104
6419
  });
6105
6420
  return {
6106
6421
  content: [
@@ -6156,7 +6471,10 @@ async function startIdeonMcpServer() {
6156
6471
  workingDir: cwd(),
6157
6472
  runMode: "resume",
6158
6473
  dryRun: input.dryRun ?? false,
6159
- enrichLinks: input.enrichLinks ?? true
6474
+ enrichLinks: input.enrichLinks ?? false,
6475
+ customLinks: input.link,
6476
+ unlinks: input.unlink,
6477
+ maxLinks: input.maxLinks
6160
6478
  });
6161
6479
  return {
6162
6480
  content: [
@@ -6215,6 +6533,48 @@ async function startIdeonMcpServer() {
6215
6533
  }
6216
6534
  }
6217
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
+ );
6218
6578
  server.registerTool(
6219
6579
  "ideon_config_get",
6220
6580
  {
@@ -6276,6 +6636,60 @@ async function startIdeonMcpServer() {
6276
6636
  }
6277
6637
  }
6278
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
+ );
6279
6693
  const transport = new StdioServerTransport();
6280
6694
  await server.connect(transport);
6281
6695
  }
@@ -6292,268 +6706,6 @@ async function runMcpServeCommand() {
6292
6706
  await startIdeonMcpServer();
6293
6707
  }
6294
6708
 
6295
- // src/cli/commands/links.ts
6296
- import { readFile as readFile6, stat as stat3 } from "fs/promises";
6297
- import path9 from "path";
6298
- async function runLinksCommand(options, dependencies = {}) {
6299
- const slug = normalizeSlug2(options.slug);
6300
- const mode = normalizeMode(options.mode);
6301
- const cwd2 = dependencies.cwd ?? process.cwd();
6302
- const log = dependencies.log ?? ((message) => console.log(message));
6303
- const resolved = await resolveRunInput({
6304
- idea: `Enrich links for ${slug}`
6305
- });
6306
- const markdownPath = await resolveMarkdownPathForSlug2(resolved.config.settings, slug, cwd2);
6307
- const frontmatter = await readFrontmatter(markdownPath);
6308
- const fileId = path9.parse(markdownPath).name;
6309
- const articleTitle = frontmatter.title ?? toTitleFromSlug(slug);
6310
- const articleDescription = frontmatter.description ?? "";
6311
- const openRouterApiKey = resolved.config.secrets.openRouterApiKey;
6312
- if (!openRouterApiKey) {
6313
- throw new ReportedError(
6314
- "Missing OpenRouter API key. Run `ideon settings` to configure credentials or set IDEON_OPENROUTER_API_KEY."
6315
- );
6316
- }
6317
- const openRouter = new OpenRouterClient(openRouterApiKey);
6318
- const linksPath = resolveLinksPath(markdownPath);
6319
- const existing = await readExistingLinks(linksPath);
6320
- const updatedCustomLinks = resolveCustomLinks(existing?.customLinks ?? [], options.links ?? [], options.unlinks ?? []);
6321
- const effectiveMaxLinks = options.maxLinks;
6322
- const linksResult = await enrichLinks({
6323
- markdownFiles: [{ markdownPath, fileId, contentType: "article" }],
6324
- articleTitle,
6325
- articleDescription,
6326
- openRouter,
6327
- settings: resolved.config.settings,
6328
- dryRun: false,
6329
- customLinks: updatedCustomLinks,
6330
- maxLinks: effectiveMaxLinks,
6331
- onItemProgress(event) {
6332
- logProgress(event, log);
6333
- }
6334
- });
6335
- const generatedLinks = linksResult[0]?.links ?? [];
6336
- const mergedGeneratedLinks = mode === "append" ? mergeLinks(existing?.links ?? [], generatedLinks) : generatedLinks;
6337
- const appendedCount = Math.max(0, mergedGeneratedLinks.length - (existing?.links.length ?? 0));
6338
- await writeLinksFile(markdownPath, {
6339
- version: 2,
6340
- customLinks: updatedCustomLinks,
6341
- links: mergedGeneratedLinks
6342
- });
6343
- const relativeMarkdownPath = formatRelativePath2(cwd2, markdownPath);
6344
- const relativeLinksPath = formatRelativePath2(cwd2, linksPath);
6345
- if (mode === "fresh") {
6346
- const replaced = existing ? "Replaced existing links." : "Created links sidecar.";
6347
- log(`Enriched links for "${slug}".`);
6348
- log(`${replaced} Saved ${generatedLinks.length} generated + ${updatedCustomLinks.length} custom links to ${relativeLinksPath} (${relativeMarkdownPath}).`);
6349
- return;
6350
- }
6351
- const baseCount = existing?.links.length ?? 0;
6352
- const verb = existing ? "Appended and deduplicated links." : "Created links sidecar.";
6353
- log(`Enriched links for "${slug}".`);
6354
- log(`${verb} Base ${baseCount}, added ${appendedCount}, total ${mergedGeneratedLinks.length} generated + ${updatedCustomLinks.length} custom in ${relativeLinksPath} (${relativeMarkdownPath}).`);
6355
- }
6356
- function normalizeMode(rawMode) {
6357
- const normalized = rawMode.trim().toLowerCase();
6358
- if (normalized === "fresh" || normalized === "append") {
6359
- return normalized;
6360
- }
6361
- throw new ReportedError(`Unsupported --mode value "${rawMode}". Use "fresh" or "append".`);
6362
- }
6363
- function normalizeSlug2(rawSlug) {
6364
- const slug = rawSlug.trim();
6365
- if (!slug) {
6366
- throw new ReportedError("Slug cannot be empty. Pass the generated article slug, for example `ideon links my-article-slug`.");
6367
- }
6368
- if (slug.toLowerCase().endsWith(".md")) {
6369
- throw new ReportedError(`Expected a slug, not a markdown filename: ${slug}. Pass the slug without .md.`);
6370
- }
6371
- if (slug === "." || slug === ".." || /[/\\]/.test(slug)) {
6372
- throw new ReportedError(`Invalid slug "${slug}". Pass the article slug only, without any path separators.`);
6373
- }
6374
- return slug;
6375
- }
6376
- async function resolveMarkdownPathForSlug2(settings, slug, cwd2) {
6377
- const outputPaths = resolveOutputPaths(settings, cwd2);
6378
- const directPath = path9.join(outputPaths.markdownOutputDir, `${slug}.md`);
6379
- if (await isReadableFile(directPath)) {
6380
- return directPath;
6381
- }
6382
- const markdownFiles = await listMarkdownFilesRecursively(outputPaths.markdownOutputDir);
6383
- const matches = [];
6384
- for (const candidate of markdownFiles) {
6385
- if (path9.basename(candidate) === `${slug}.md`) {
6386
- matches.push(candidate);
6387
- continue;
6388
- }
6389
- const frontmatter = await readFrontmatter(candidate);
6390
- if (frontmatter.slug === slug) {
6391
- matches.push(candidate);
6392
- }
6393
- }
6394
- if (matches.length === 0) {
6395
- throw new ReportedError(
6396
- `Could not find article "${slug}". Expected a markdown file in ${outputPaths.markdownOutputDir} with frontmatter slug "${slug}".`
6397
- );
6398
- }
6399
- return newestPath(matches);
6400
- }
6401
- async function newestPath(paths) {
6402
- let latestPath = paths[0];
6403
- let latestMtime = 0;
6404
- for (const candidate of paths) {
6405
- const candidateStat = await stat3(candidate);
6406
- if (candidateStat.mtimeMs >= latestMtime) {
6407
- latestMtime = candidateStat.mtimeMs;
6408
- latestPath = candidate;
6409
- }
6410
- }
6411
- return latestPath;
6412
- }
6413
- async function readFrontmatter(markdownPath) {
6414
- const markdown = await readFile6(markdownPath, "utf8");
6415
- return parseFrontmatter(markdown);
6416
- }
6417
- function parseFrontmatter(markdown) {
6418
- if (!markdown.startsWith("---\n")) {
6419
- return { slug: null, title: null, description: null };
6420
- }
6421
- const frontmatterEnd = markdown.indexOf("\n---\n", 4);
6422
- if (frontmatterEnd < 0) {
6423
- return { slug: null, title: null, description: null };
6424
- }
6425
- const block = markdown.slice(4, frontmatterEnd);
6426
- return {
6427
- slug: parseFrontmatterValue(block, "slug"),
6428
- title: parseFrontmatterValue(block, "title"),
6429
- description: parseFrontmatterValue(block, "description")
6430
- };
6431
- }
6432
- function parseFrontmatterValue(block, key) {
6433
- const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
6434
- const match = block.match(pattern);
6435
- if (!match || !match[1]) {
6436
- return null;
6437
- }
6438
- const rawValue = match[1].trim();
6439
- if (!rawValue) {
6440
- return null;
6441
- }
6442
- if (rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'")) {
6443
- return rawValue.slice(1, -1);
6444
- }
6445
- return rawValue;
6446
- }
6447
- function toTitleFromSlug(slug) {
6448
- return slug.split("-").filter((part) => part.length > 0).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
6449
- }
6450
- async function isReadableFile(filePath) {
6451
- try {
6452
- const fileStat = await stat3(filePath);
6453
- return fileStat.isFile();
6454
- } catch {
6455
- return false;
6456
- }
6457
- }
6458
- async function readExistingLinks(linksPath) {
6459
- try {
6460
- const raw = await readFile6(linksPath, "utf8");
6461
- const parsed = JSON.parse(raw);
6462
- const links = Array.isArray(parsed.links) ? parsed.links.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6463
- expression: entry.expression.trim(),
6464
- url: entry.url.trim(),
6465
- title: typeof entry.title === "string" ? entry.title : null
6466
- })) : null;
6467
- if (!links) {
6468
- throw new ReportedError(`Invalid links sidecar format at ${linksPath}. Expected { version, links[] }.`);
6469
- }
6470
- const customLinks = Array.isArray(parsed.customLinks) ? parsed.customLinks.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6471
- expression: entry.expression.trim(),
6472
- url: entry.url.trim(),
6473
- title: typeof entry.title === "string" ? entry.title : null
6474
- })) : [];
6475
- return {
6476
- version: typeof parsed.version === "number" ? parsed.version : 1,
6477
- customLinks,
6478
- links
6479
- };
6480
- } catch (error) {
6481
- if (readErrorCode2(error) === "ENOENT") {
6482
- return null;
6483
- }
6484
- if (error instanceof ReportedError) {
6485
- throw error;
6486
- }
6487
- const message = error instanceof Error ? error.message : "Unknown links sidecar read error.";
6488
- throw new ReportedError(`Failed to read existing links from ${linksPath}: ${message}`);
6489
- }
6490
- }
6491
- function mergeLinks(existingLinks, generatedLinks) {
6492
- const merged = [];
6493
- const seen = /* @__PURE__ */ new Set();
6494
- for (const entry of [...existingLinks, ...generatedLinks]) {
6495
- const key = `${entry.expression.trim().toLowerCase()}::${entry.url.trim().toLowerCase()}`;
6496
- if (seen.has(key)) {
6497
- continue;
6498
- }
6499
- seen.add(key);
6500
- merged.push(entry);
6501
- }
6502
- return merged;
6503
- }
6504
- function isValidLinkEntry(value2) {
6505
- if (typeof value2 !== "object" || value2 === null) {
6506
- return false;
6507
- }
6508
- const record = value2;
6509
- return typeof record.expression === "string" && typeof record.url === "string" && (typeof record.title === "string" || record.title === null || record.title === void 0);
6510
- }
6511
- function readErrorCode2(error) {
6512
- if (typeof error !== "object" || error === null || !("code" in error)) {
6513
- return null;
6514
- }
6515
- const code = error.code;
6516
- return typeof code === "string" ? code : null;
6517
- }
6518
- function formatRelativePath2(cwd2, targetPath) {
6519
- const relativePath = path9.relative(cwd2, targetPath);
6520
- return relativePath.length > 0 ? relativePath : targetPath;
6521
- }
6522
- function logProgress(event, log) {
6523
- if (event.phase === "resolving-expression" || event.phase === "selecting-expressions") {
6524
- return;
6525
- }
6526
- log(event.detail);
6527
- }
6528
- function parseCustomLinkFlag(raw) {
6529
- const separatorIndex = raw.indexOf("->");
6530
- if (separatorIndex < 0) {
6531
- throw new ReportedError(`Invalid --link value "${raw}". Expected format: "expression->url".`);
6532
- }
6533
- const expression = raw.slice(0, separatorIndex).trim();
6534
- const url = raw.slice(separatorIndex + 2).trim();
6535
- if (!expression) {
6536
- throw new ReportedError(`Invalid --link value "${raw}": expression (left side of ->) cannot be empty.`);
6537
- }
6538
- if (!url) {
6539
- throw new ReportedError(`Invalid --link value "${raw}": url (right side of ->) cannot be empty.`);
6540
- }
6541
- return { expression, url };
6542
- }
6543
- function resolveCustomLinks(existing, addRaw, removeExpressions) {
6544
- const result = new Map(
6545
- existing.map((entry) => [entry.expression.trim().toLowerCase(), entry])
6546
- );
6547
- for (const raw of addRaw) {
6548
- const { expression, url } = parseCustomLinkFlag(raw);
6549
- result.set(expression.toLowerCase(), { expression, url, title: null });
6550
- }
6551
- for (const expr of removeExpressions) {
6552
- result.delete(expr.trim().toLowerCase());
6553
- }
6554
- return Array.from(result.values());
6555
- }
6556
-
6557
6709
  // src/cli/commands/settings.tsx
6558
6710
  import { render } from "ink";
6559
6711
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/ideon",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "CLI for generating rich articles and images from ideas.",
5
5
  "type": "module",
6
6
  "repository": {
package/README.zh-Hans.md DELETED
@@ -1,114 +0,0 @@
1
- # Ideon
2
-
3
- Ideon 是一款 AI 内容写作工具,可将一个想法转化为多格式、多风格、可发布的内容。
4
-
5
- [🇺🇸 English](./README.md) | [🇨🇳 简体中文](./README.zh-Hans.md)
6
-
7
- [![CI](https://github.com/telepat-io/ideon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/telepat-io/ideon/actions/workflows/ci.yml)
8
- [![Coverage](https://codecov.io/gh/telepat-io/ideon/graph/badge.svg)](https://codecov.io/gh/telepat-io/ideon)
9
- [![npm version](https://img.shields.io/npm/v/%40telepat%2Fideon)](https://www.npmjs.com/package/@telepat/ideon)
10
- [![Docs](https://img.shields.io/badge/docs-live-1f6feb)](https://docs.telepat.io/ideon)
11
- [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://github.com/telepat-io/ideon/blob/main/LICENSE)
12
-
13
- ## 为什么团队使用 Ideon
14
-
15
- Ideon 帮助团队更快地从想法走到可发布内容,减少跨渠道重复改写的工作量。
16
-
17
- 一次运行,Ideon 可以:
18
-
19
- - 基于同一核心想法生成多种输出类型:article、blog、newsletter、Reddit、LinkedIn、X thread、X post 等。
20
- - 在所有输出中统一应用写作风格(`professional`、`friendly`、`technical`、`academic`、`opinionated`、`storytelling`)。
21
- - 生成研究导向的内容 brief,补充相关链接,并在文章型任务中生成配图。
22
- - 通过作业文件、可配置参数与断点恢复能力支持持续迭代。
23
-
24
- 这使 Ideon 适用于内容团队、开发者关系、产品营销、创始人以及需要按节奏进行多渠道写作的个人或团队。
25
-
26
- ## 快速开始
27
-
28
- 安装并完成第一次内容生成:
29
-
30
- ```bash
31
- npm i -g @telepat/ideon
32
- ideon settings
33
- ideon write "How small editorial teams can productionize AI writing" --primary article=1 --secondary x-post=1
34
- ideon preview
35
- ```
36
-
37
- 预期结果:
38
-
39
- - 在 `output/<timestamp>-<slug>/` 下生成一个运行目录。
40
- - 产出一个或多个可发布的 Markdown 输出。
41
- - 保存 analytics 与元数据,便于复现与回溯。
42
- - 本地预览自动打开,便于检查内容、链接与资源。
43
-
44
- ## 环境要求
45
-
46
- - Node.js 20+
47
- - npm 10+
48
- - OpenRouter API key
49
- - Replicate API token
50
-
51
- ## 核心能力
52
-
53
- - 一次想法,多格式写作:article、blog、newsletter、Reddit、LinkedIn、X thread、X post、landing-page copy。
54
- - 风格控制:按运行设置统一写作语气与表达风格。
55
- - 研究与链接增强:生成规划 brief,并为内容补充相关链接。
56
- - 图像生成:为文章型任务生成封面图与文内配图。
57
- - 迭代能力:可通过不同目标/风格重复生成,支持中断恢复和作业文件复用。
58
- - 本地审阅:在发布前通过浏览器预览内容与资源。
59
-
60
- ## 工作原理
61
-
62
- Ideon 采用分阶段写作流水线:内容规划、正文生成、图像提示扩展、图像渲染、渠道内容生成,以及可选的链接增强。
63
-
64
- 运行时会合并 settings、环境变量、job 文件与 CLI 参数,并输出结构化产物,便于追踪与复用。
65
-
66
- 核心命令:
67
-
68
- ```bash
69
- ideon settings
70
- ideon config list --json
71
- ideon write "An article idea" --primary article=1
72
- ideon write --no-interactive --idea "An article idea" --primary article=1 --style technical --length medium
73
- ideon write --job ./job.json
74
- ideon write resume
75
- ideon delete my-article-slug
76
- ideon preview --no-open
77
- ideon mcp serve
78
- ideon agent status --json
79
- ```
80
-
81
- Agent 集成范围:
82
-
83
- - 支持:CLI 与 MCP runtime 工作流。
84
- - 不支持:Cursor 与 VS Code runtime 集成。
85
-
86
- ## 安全与信任
87
-
88
- - 默认通过 `ideon settings` 将密钥保存到系统钥匙串。
89
- - 在 CI 或容器环境中,请使用 `IDEON_OPENROUTER_API_KEY` 和 `IDEON_REPLICATE_API_TOKEN`。
90
- - 在无法访问钥匙串时设置 `IDEON_DISABLE_KEYTAR=true`。
91
- - 生成内容来自模型输出,发布前请进行人工审阅。
92
-
93
- 如需报告安全问题,请通过仓库安全报告通道私下提交,或通过仓库 issue 渠道联系维护者并避免包含敏感细节。
94
-
95
- ## 文档与支持
96
-
97
- - 文档站点:https://docs.telepat.io/ideon
98
- - 语言支持:English 与 简体中文(`README.md` / `README.zh-Hans.md`,以及双语文档)
99
- - 快速上手:`docs/getting-started/quickstart.md`
100
- - CLI 参考:`docs/reference/cli-reference.md`
101
- - 配置指南:`docs/guides/configuration.md`
102
- - 故障排查:`docs/guides/troubleshooting.md`
103
- - 仓库:https://github.com/telepat-io/ideon
104
- - npm 包:https://www.npmjs.com/package/@telepat/ideon
105
-
106
- ## 贡献
107
-
108
- 欢迎贡献。请先阅读 `docs/contributing/development.md`(开发环境与质量门禁),再参考 `docs/contributing/releasing-and-docs-deploy.md`(发布与文档部署流程)。
109
-
110
- 如修改面向用户的文档内容,请在同一变更中同时更新 English 与 简体中文版本。
111
-
112
- ## 许可证
113
-
114
- MIT。详见 [LICENSE](./LICENSE).