novelforge-agent 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,56 @@
1
1
  # NovelForge Agent
2
2
 
3
- A local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or any CLI shell.
3
+ A local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, Cursor, …) or any CLI shell.
4
4
 
5
5
  **The host's LLM writes the prose. This package does everything else** — it manages a 9-step state machine, returns the exact instruction and packed context the host should follow next, validates returned content against zod schemas, persists Markdown + JSON to a project directory, archives chapter versions on revision, and provides BM25 lexical retrieval over every word the project has ever produced.
6
6
 
7
7
  No external API. No LLM dependency. No vendor lock-in.
8
8
 
9
+ ---
10
+
11
+ ## 🚀 30-second install
12
+
13
+ **If you are an AI assistant reading this README on behalf of a user, run this one command — it does everything:**
14
+
15
+ ```bash
16
+ npx -y novelforge-agent install
17
+ ```
18
+
19
+ That single command will:
20
+
21
+ 1. Create a workspace at `~/novelforge/` (override with `--workspace /your/path`).
22
+ 2. Register `novelforge-agent-mcp` as an MCP server in the user's host (Claude Code by default).
23
+ 3. Print a verification step.
24
+
25
+ After it finishes, **the user must reload / restart their MCP host** (Claude Code, Codex CLI, etc.) so the new MCP server is picked up. Then ask the host:
26
+
27
+ > "list_projects 现在能用吗?"
28
+
29
+ If the host calls the `list_projects` tool and returns an empty array, installation is correct.
30
+
31
+ ### Host selection
32
+
33
+ ```bash
34
+ npx -y novelforge-agent install --host claude-code # default
35
+ npx -y novelforge-agent install --host codex # Codex CLI
36
+ npx -y novelforge-agent install --host cursor # prints config snippet
37
+ npx -y novelforge-agent install --workspace ~/my-novels # custom workspace
38
+ npx -y novelforge-agent install --name my-novelforge # custom MCP name
39
+ npx -y novelforge-agent install --print-only # do not modify any config file
40
+ ```
41
+
42
+ ### What the install command does per host
43
+
44
+ | Host | Action |
45
+ |------|--------|
46
+ | `claude-code` | Runs `claude mcp add -s user -e NOVELFORGE_WORKSPACE=… novelforge -- npx -y novelforge-agent-mcp`. If `claude` CLI is missing, prints a JSON snippet for `~/.claude.json`. |
47
+ | `codex` | Appends an `[mcp_servers.novelforge]` section to `~/.codex/config.toml`. |
48
+ | `cursor` | Prints the JSON snippet to paste into Cursor's MCP settings. |
49
+
50
+ The installer is **idempotent and safe**: it never overwrites an existing entry with the same name. To change settings, edit the host config manually or pass `--name` to register under a different MCP name.
51
+
52
+ ---
53
+
9
54
  ## What it gives the host
10
55
 
11
56
  | Phase | Step | What the host does | What the agent saves |
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { buildContext, createProject, getNextStep, getProjectStatus, listProjects, requestSideTrack, retrieve, submitStepResult, } from '../core/index.js';
4
+ import { formatInstallResult, runInstall } from './install.js';
4
5
  function valueAfter(args, name) {
5
6
  const index = args.indexOf(name);
6
7
  return index >= 0 ? args[index + 1] : undefined;
@@ -10,8 +11,28 @@ function parseLanguage(value) {
10
11
  return value;
11
12
  throw new Error('Invalid --language. Use zh-CN or en-US');
12
13
  }
14
+ function parseHost(value) {
15
+ const v = (value || 'claude-code').toLowerCase();
16
+ if (v === 'claude-code' || v === 'claude')
17
+ return 'claude-code';
18
+ if (v === 'codex' || v === 'codex-cli')
19
+ return 'codex';
20
+ if (v === 'cursor')
21
+ return 'cursor';
22
+ throw new Error(`Unknown --host: ${value}. Use claude-code | codex | cursor.`);
23
+ }
13
24
  export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()) {
14
25
  const [command, projectPath] = argv;
26
+ if (command === 'install') {
27
+ const result = await runInstall({
28
+ host: parseHost(valueAfter(argv, '--host')),
29
+ workspace: valueAfter(argv, '--workspace'),
30
+ name: valueAfter(argv, '--name'),
31
+ printOnly: argv.includes('--print-only'),
32
+ });
33
+ console.log(formatInstallResult(result));
34
+ return;
35
+ }
15
36
  if (command === 'start') {
16
37
  const prompt = valueAfter(argv, '--prompt') || '';
17
38
  if (!prompt.trim())
@@ -115,7 +136,7 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
115
136
  console.log(JSON.stringify({ query, hits }, null, 2));
116
137
  return;
117
138
  }
118
- throw new Error('Usage: novelforge-agent start|list|status|next|submit|context|review|revise|cross-review|retrieve');
139
+ throw new Error('Usage: novelforge-agent install|start|list|status|next|submit|context|review|revise|cross-review|retrieve');
119
140
  }
120
141
  if (import.meta.url === `file://${process.argv[1]}`) {
121
142
  runCli().catch((error) => {
@@ -0,0 +1,224 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const PACKAGE_NAME = 'novelforge-agent';
8
+ const MCP_BIN = 'novelforge-agent-mcp';
9
+ function defaultWorkspace() {
10
+ return join(homedir(), 'novelforge');
11
+ }
12
+ function defaultName() {
13
+ return 'novelforge';
14
+ }
15
+ function claudeJsonSnippet(name, workspace) {
16
+ return JSON.stringify({
17
+ mcpServers: {
18
+ [name]: {
19
+ command: 'npx',
20
+ args: ['-y', MCP_BIN],
21
+ env: { NOVELFORGE_WORKSPACE: workspace },
22
+ },
23
+ },
24
+ }, null, 2);
25
+ }
26
+ function codexTomlSnippet(name, workspace) {
27
+ return [
28
+ `[mcp_servers.${name}]`,
29
+ `command = "npx"`,
30
+ `args = ["-y", "${MCP_BIN}"]`,
31
+ ``,
32
+ `[mcp_servers.${name}.env]`,
33
+ `NOVELFORGE_WORKSPACE = "${workspace}"`,
34
+ ``,
35
+ ].join('\n');
36
+ }
37
+ function cursorSnippet(name, workspace) {
38
+ return JSON.stringify({
39
+ mcpServers: {
40
+ [name]: {
41
+ command: 'npx',
42
+ args: ['-y', MCP_BIN],
43
+ env: { NOVELFORGE_WORKSPACE: workspace },
44
+ },
45
+ },
46
+ }, null, 2);
47
+ }
48
+ async function ensureWorkspaceDir(workspace) {
49
+ await mkdir(workspace, { recursive: true });
50
+ await mkdir(join(workspace, 'novels'), { recursive: true });
51
+ }
52
+ async function tryClaudeCli(name, workspace) {
53
+ const args = [
54
+ 'mcp',
55
+ 'add',
56
+ '-s',
57
+ 'user',
58
+ '-e',
59
+ `NOVELFORGE_WORKSPACE=${workspace}`,
60
+ name,
61
+ '--',
62
+ 'npx',
63
+ '-y',
64
+ MCP_BIN,
65
+ ];
66
+ try {
67
+ const { stdout, stderr } = await execFileAsync('claude', args);
68
+ return { ok: true, output: `${stdout}\n${stderr}`.trim() };
69
+ }
70
+ catch (error) {
71
+ const err = error;
72
+ return { ok: false, output: '', error: err.message };
73
+ }
74
+ }
75
+ async function applyClaudeCode(name, workspace, printOnly) {
76
+ const snippet = claudeJsonSnippet(name, workspace);
77
+ const verificationHint = 'In Claude Code, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
78
+ if (printOnly) {
79
+ return {
80
+ host: 'claude-code',
81
+ name,
82
+ workspace,
83
+ applied: false,
84
+ method: 'print-only',
85
+ message: 'Print-only mode. Run the snippet below or invoke without --print-only to apply automatically.',
86
+ manualSnippet: snippet,
87
+ verificationHint,
88
+ };
89
+ }
90
+ const cli = await tryClaudeCli(name, workspace);
91
+ if (cli.ok) {
92
+ return {
93
+ host: 'claude-code',
94
+ name,
95
+ workspace,
96
+ applied: true,
97
+ method: 'cli',
98
+ message: `Registered via \`claude mcp add\`.\n${cli.output}`,
99
+ verificationHint,
100
+ };
101
+ }
102
+ return {
103
+ host: 'claude-code',
104
+ name,
105
+ workspace,
106
+ applied: false,
107
+ method: 'print-only',
108
+ message: `\`claude\` CLI not available (${cli.error ?? 'unknown error'}). Paste the snippet below into ~/.claude.json under "mcpServers", or run \`claude mcp add -s user -e NOVELFORGE_WORKSPACE=${workspace} ${name} -- npx -y ${MCP_BIN}\` after installing Claude Code.`,
109
+ manualSnippet: snippet,
110
+ verificationHint,
111
+ };
112
+ }
113
+ async function applyCodex(name, workspace, printOnly) {
114
+ const snippet = codexTomlSnippet(name, workspace);
115
+ const configPath = join(homedir(), '.codex', 'config.toml');
116
+ const verificationHint = 'In Codex CLI, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
117
+ if (printOnly) {
118
+ return {
119
+ host: 'codex',
120
+ name,
121
+ workspace,
122
+ applied: false,
123
+ method: 'print-only',
124
+ message: `Print-only mode. Append the snippet below to ${configPath}.`,
125
+ manualSnippet: snippet,
126
+ verificationHint,
127
+ };
128
+ }
129
+ try {
130
+ await mkdir(dirname(configPath), { recursive: true });
131
+ let existing = '';
132
+ try {
133
+ existing = await readFile(configPath, 'utf8');
134
+ }
135
+ catch {
136
+ existing = '';
137
+ }
138
+ if (existing.includes(`[mcp_servers.${name}]`)) {
139
+ return {
140
+ host: 'codex',
141
+ name,
142
+ workspace,
143
+ applied: false,
144
+ method: 'config-edit',
145
+ message: `Section [mcp_servers.${name}] already exists in ${configPath}. Edit it manually if you want to change settings.`,
146
+ manualSnippet: snippet,
147
+ verificationHint,
148
+ };
149
+ }
150
+ const next = existing.endsWith('\n') || existing === '' ? existing : `${existing}\n`;
151
+ await writeFile(configPath, `${next}\n${snippet}`, 'utf8');
152
+ return {
153
+ host: 'codex',
154
+ name,
155
+ workspace,
156
+ applied: true,
157
+ method: 'config-edit',
158
+ message: `Appended [mcp_servers.${name}] to ${configPath}.`,
159
+ verificationHint,
160
+ };
161
+ }
162
+ catch (error) {
163
+ return {
164
+ host: 'codex',
165
+ name,
166
+ workspace,
167
+ applied: false,
168
+ method: 'print-only',
169
+ message: `Could not write ${configPath}: ${error.message}. Append the snippet manually.`,
170
+ manualSnippet: snippet,
171
+ verificationHint,
172
+ };
173
+ }
174
+ }
175
+ async function applyCursor(name, workspace, printOnly) {
176
+ const snippet = cursorSnippet(name, workspace);
177
+ return {
178
+ host: 'cursor',
179
+ name,
180
+ workspace,
181
+ applied: false,
182
+ method: 'print-only',
183
+ message: 'Cursor: open Settings → Tools & Integrations → MCP, click "Add new MCP server", and paste the snippet below.',
184
+ manualSnippet: snippet,
185
+ verificationHint: 'In Cursor, the agent should be able to call the list_projects tool from the novelforge MCP server.',
186
+ };
187
+ }
188
+ export async function runInstall(options) {
189
+ const host = options.host ?? 'claude-code';
190
+ const workspace = resolve(options.workspace ?? defaultWorkspace());
191
+ const name = options.name ?? defaultName();
192
+ const printOnly = options.printOnly ?? false;
193
+ if (!printOnly) {
194
+ await ensureWorkspaceDir(workspace);
195
+ }
196
+ switch (host) {
197
+ case 'claude-code':
198
+ return applyClaudeCode(name, workspace, printOnly);
199
+ case 'codex':
200
+ return applyCodex(name, workspace, printOnly);
201
+ case 'cursor':
202
+ return applyCursor(name, workspace, printOnly);
203
+ default:
204
+ throw new Error(`Unknown host: ${host}. Use claude-code | codex | cursor.`);
205
+ }
206
+ }
207
+ export function formatInstallResult(result) {
208
+ const parts = [];
209
+ parts.push(`Host: ${result.host}`);
210
+ parts.push(`MCP name: ${result.name}`);
211
+ parts.push(`Workspace: ${result.workspace}`);
212
+ parts.push(`Applied: ${result.applied ? 'yes' : 'no'} (${result.method})`);
213
+ parts.push('');
214
+ parts.push(result.message);
215
+ if (result.manualSnippet) {
216
+ parts.push('');
217
+ parts.push('--- Snippet ---');
218
+ parts.push(result.manualSnippet);
219
+ parts.push('---------------');
220
+ }
221
+ parts.push('');
222
+ parts.push(`Verify: ${result.verificationHint}`);
223
+ return parts.join('\n');
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novelforge-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or CLI. State machine + zod schemas + BM25 retrieval + persistent project state. No LLM dependency.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  retrieve,
11
11
  submitStepResult,
12
12
  } from '../core/index.js';
13
+ import { formatInstallResult, runInstall, InstallHost } from './install.js';
13
14
 
14
15
  function valueAfter(args: string[], name: string): string | undefined {
15
16
  const index = args.indexOf(name);
@@ -21,9 +22,28 @@ function parseLanguage(value: string): 'zh-CN' | 'en-US' {
21
22
  throw new Error('Invalid --language. Use zh-CN or en-US');
22
23
  }
23
24
 
25
+ function parseHost(value: string | undefined): InstallHost {
26
+ const v = (value || 'claude-code').toLowerCase();
27
+ if (v === 'claude-code' || v === 'claude') return 'claude-code';
28
+ if (v === 'codex' || v === 'codex-cli') return 'codex';
29
+ if (v === 'cursor') return 'cursor';
30
+ throw new Error(`Unknown --host: ${value}. Use claude-code | codex | cursor.`);
31
+ }
32
+
24
33
  export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
25
34
  const [command, projectPath] = argv;
26
35
 
36
+ if (command === 'install') {
37
+ const result = await runInstall({
38
+ host: parseHost(valueAfter(argv, '--host')),
39
+ workspace: valueAfter(argv, '--workspace'),
40
+ name: valueAfter(argv, '--name'),
41
+ printOnly: argv.includes('--print-only'),
42
+ });
43
+ console.log(formatInstallResult(result));
44
+ return;
45
+ }
46
+
27
47
  if (command === 'start') {
28
48
  const prompt = valueAfter(argv, '--prompt') || '';
29
49
  if (!prompt.trim()) throw new Error('Missing --prompt');
@@ -136,7 +156,7 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()):
136
156
  return;
137
157
  }
138
158
 
139
- throw new Error('Usage: novelforge-agent start|list|status|next|submit|context|review|revise|cross-review|retrieve');
159
+ throw new Error('Usage: novelforge-agent install|start|list|status|next|submit|context|review|revise|cross-review|retrieve');
140
160
  }
141
161
 
142
162
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -0,0 +1,275 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export type InstallHost = 'claude-code' | 'codex' | 'cursor';
10
+
11
+ export interface InstallOptions {
12
+ host?: InstallHost;
13
+ workspace?: string;
14
+ name?: string;
15
+ printOnly?: boolean;
16
+ }
17
+
18
+ export interface InstallResult {
19
+ host: InstallHost;
20
+ name: string;
21
+ workspace: string;
22
+ applied: boolean;
23
+ method: 'cli' | 'config-edit' | 'print-only';
24
+ message: string;
25
+ manualSnippet?: string;
26
+ verificationHint: string;
27
+ }
28
+
29
+ const PACKAGE_NAME = 'novelforge-agent';
30
+ const MCP_BIN = 'novelforge-agent-mcp';
31
+
32
+ function defaultWorkspace(): string {
33
+ return join(homedir(), 'novelforge');
34
+ }
35
+
36
+ function defaultName(): string {
37
+ return 'novelforge';
38
+ }
39
+
40
+ function claudeJsonSnippet(name: string, workspace: string): string {
41
+ return JSON.stringify(
42
+ {
43
+ mcpServers: {
44
+ [name]: {
45
+ command: 'npx',
46
+ args: ['-y', MCP_BIN],
47
+ env: { NOVELFORGE_WORKSPACE: workspace },
48
+ },
49
+ },
50
+ },
51
+ null,
52
+ 2
53
+ );
54
+ }
55
+
56
+ function codexTomlSnippet(name: string, workspace: string): string {
57
+ return [
58
+ `[mcp_servers.${name}]`,
59
+ `command = "npx"`,
60
+ `args = ["-y", "${MCP_BIN}"]`,
61
+ ``,
62
+ `[mcp_servers.${name}.env]`,
63
+ `NOVELFORGE_WORKSPACE = "${workspace}"`,
64
+ ``,
65
+ ].join('\n');
66
+ }
67
+
68
+ function cursorSnippet(name: string, workspace: string): string {
69
+ return JSON.stringify(
70
+ {
71
+ mcpServers: {
72
+ [name]: {
73
+ command: 'npx',
74
+ args: ['-y', MCP_BIN],
75
+ env: { NOVELFORGE_WORKSPACE: workspace },
76
+ },
77
+ },
78
+ },
79
+ null,
80
+ 2
81
+ );
82
+ }
83
+
84
+ async function ensureWorkspaceDir(workspace: string): Promise<void> {
85
+ await mkdir(workspace, { recursive: true });
86
+ await mkdir(join(workspace, 'novels'), { recursive: true });
87
+ }
88
+
89
+ async function tryClaudeCli(name: string, workspace: string): Promise<{ ok: boolean; output: string; error?: string }> {
90
+ const args = [
91
+ 'mcp',
92
+ 'add',
93
+ '-s',
94
+ 'user',
95
+ '-e',
96
+ `NOVELFORGE_WORKSPACE=${workspace}`,
97
+ name,
98
+ '--',
99
+ 'npx',
100
+ '-y',
101
+ MCP_BIN,
102
+ ];
103
+ try {
104
+ const { stdout, stderr } = await execFileAsync('claude', args);
105
+ return { ok: true, output: `${stdout}\n${stderr}`.trim() };
106
+ } catch (error) {
107
+ const err = error as Error & { code?: string };
108
+ return { ok: false, output: '', error: err.message };
109
+ }
110
+ }
111
+
112
+ async function applyClaudeCode(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
113
+ const snippet = claudeJsonSnippet(name, workspace);
114
+ const verificationHint =
115
+ 'In Claude Code, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
116
+
117
+ if (printOnly) {
118
+ return {
119
+ host: 'claude-code',
120
+ name,
121
+ workspace,
122
+ applied: false,
123
+ method: 'print-only',
124
+ message:
125
+ 'Print-only mode. Run the snippet below or invoke without --print-only to apply automatically.',
126
+ manualSnippet: snippet,
127
+ verificationHint,
128
+ };
129
+ }
130
+
131
+ const cli = await tryClaudeCli(name, workspace);
132
+ if (cli.ok) {
133
+ return {
134
+ host: 'claude-code',
135
+ name,
136
+ workspace,
137
+ applied: true,
138
+ method: 'cli',
139
+ message: `Registered via \`claude mcp add\`.\n${cli.output}`,
140
+ verificationHint,
141
+ };
142
+ }
143
+ return {
144
+ host: 'claude-code',
145
+ name,
146
+ workspace,
147
+ applied: false,
148
+ method: 'print-only',
149
+ message:
150
+ `\`claude\` CLI not available (${cli.error ?? 'unknown error'}). Paste the snippet below into ~/.claude.json under "mcpServers", or run \`claude mcp add -s user -e NOVELFORGE_WORKSPACE=${workspace} ${name} -- npx -y ${MCP_BIN}\` after installing Claude Code.`,
151
+ manualSnippet: snippet,
152
+ verificationHint,
153
+ };
154
+ }
155
+
156
+ async function applyCodex(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
157
+ const snippet = codexTomlSnippet(name, workspace);
158
+ const configPath = join(homedir(), '.codex', 'config.toml');
159
+ const verificationHint =
160
+ 'In Codex CLI, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
161
+
162
+ if (printOnly) {
163
+ return {
164
+ host: 'codex',
165
+ name,
166
+ workspace,
167
+ applied: false,
168
+ method: 'print-only',
169
+ message: `Print-only mode. Append the snippet below to ${configPath}.`,
170
+ manualSnippet: snippet,
171
+ verificationHint,
172
+ };
173
+ }
174
+
175
+ try {
176
+ await mkdir(dirname(configPath), { recursive: true });
177
+ let existing = '';
178
+ try {
179
+ existing = await readFile(configPath, 'utf8');
180
+ } catch {
181
+ existing = '';
182
+ }
183
+ if (existing.includes(`[mcp_servers.${name}]`)) {
184
+ return {
185
+ host: 'codex',
186
+ name,
187
+ workspace,
188
+ applied: false,
189
+ method: 'config-edit',
190
+ message: `Section [mcp_servers.${name}] already exists in ${configPath}. Edit it manually if you want to change settings.`,
191
+ manualSnippet: snippet,
192
+ verificationHint,
193
+ };
194
+ }
195
+ const next = existing.endsWith('\n') || existing === '' ? existing : `${existing}\n`;
196
+ await writeFile(configPath, `${next}\n${snippet}`, 'utf8');
197
+ return {
198
+ host: 'codex',
199
+ name,
200
+ workspace,
201
+ applied: true,
202
+ method: 'config-edit',
203
+ message: `Appended [mcp_servers.${name}] to ${configPath}.`,
204
+ verificationHint,
205
+ };
206
+ } catch (error) {
207
+ return {
208
+ host: 'codex',
209
+ name,
210
+ workspace,
211
+ applied: false,
212
+ method: 'print-only',
213
+ message: `Could not write ${configPath}: ${(error as Error).message}. Append the snippet manually.`,
214
+ manualSnippet: snippet,
215
+ verificationHint,
216
+ };
217
+ }
218
+ }
219
+
220
+ async function applyCursor(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
221
+ const snippet = cursorSnippet(name, workspace);
222
+ return {
223
+ host: 'cursor',
224
+ name,
225
+ workspace,
226
+ applied: false,
227
+ method: 'print-only',
228
+ message:
229
+ 'Cursor: open Settings → Tools & Integrations → MCP, click "Add new MCP server", and paste the snippet below.',
230
+ manualSnippet: snippet,
231
+ verificationHint:
232
+ 'In Cursor, the agent should be able to call the list_projects tool from the novelforge MCP server.',
233
+ };
234
+ }
235
+
236
+ export async function runInstall(options: InstallOptions): Promise<InstallResult> {
237
+ const host: InstallHost = options.host ?? 'claude-code';
238
+ const workspace = resolve(options.workspace ?? defaultWorkspace());
239
+ const name = options.name ?? defaultName();
240
+ const printOnly = options.printOnly ?? false;
241
+
242
+ if (!printOnly) {
243
+ await ensureWorkspaceDir(workspace);
244
+ }
245
+
246
+ switch (host) {
247
+ case 'claude-code':
248
+ return applyClaudeCode(name, workspace, printOnly);
249
+ case 'codex':
250
+ return applyCodex(name, workspace, printOnly);
251
+ case 'cursor':
252
+ return applyCursor(name, workspace, printOnly);
253
+ default:
254
+ throw new Error(`Unknown host: ${host}. Use claude-code | codex | cursor.`);
255
+ }
256
+ }
257
+
258
+ export function formatInstallResult(result: InstallResult): string {
259
+ const parts: string[] = [];
260
+ parts.push(`Host: ${result.host}`);
261
+ parts.push(`MCP name: ${result.name}`);
262
+ parts.push(`Workspace: ${result.workspace}`);
263
+ parts.push(`Applied: ${result.applied ? 'yes' : 'no'} (${result.method})`);
264
+ parts.push('');
265
+ parts.push(result.message);
266
+ if (result.manualSnippet) {
267
+ parts.push('');
268
+ parts.push('--- Snippet ---');
269
+ parts.push(result.manualSnippet);
270
+ parts.push('---------------');
271
+ }
272
+ parts.push('');
273
+ parts.push(`Verify: ${result.verificationHint}`);
274
+ return parts.join('\n');
275
+ }