@sov3rain/nota 0.2.0 → 0.2.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/bin/nota.mjs CHANGED
@@ -1,145 +1,150 @@
1
- #!/usr/bin/env node
2
-
3
- import { createServer } from 'node:http';
4
- import { randomBytes } from 'node:crypto';
5
- import { createReadStream } from 'node:fs';
6
- import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
7
- import { dirname, extname, join, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { spawn } from 'node:child_process';
10
- import { getAgentInstructionBlock } from './agentInstructions.mjs';
11
-
1
+ #!/usr/bin/env node
2
+
3
+ import { createServer } from 'node:http';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { createReadStream } from 'node:fs';
6
+ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
7
+ import { dirname, extname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { spawn } from 'node:child_process';
10
+ import { getAgentInstructionBlock } from './agentInstructions.mjs';
11
+
12
12
  const MAX_BODY_BYTES = 5 * 1024 * 1024;
13
+ const HEARTBEAT_TIMEOUT_MS = 15_000;
14
+ const HEARTBEAT_CHECK_MS = 5_000;
13
15
  const MIME_TYPES = {
14
- '.css': 'text/css; charset=utf-8',
15
- '.html': 'text/html; charset=utf-8',
16
- '.js': 'text/javascript; charset=utf-8',
17
- '.json': 'application/json; charset=utf-8',
18
- '.svg': 'image/svg+xml',
19
- };
20
- const AGENT_TARGETS = ['codex', 'claude', 'cursor'];
21
- const AGENT_LABELS = {
22
- codex: 'Codex',
23
- claude: 'Claude',
24
- cursor: 'Cursor',
25
- };
26
-
27
- const args = process.argv.slice(2);
28
- const command = args[0];
29
- const wantsHelp = args.includes('--help') || args.includes('-h');
30
- const shouldOpenBrowser = !args.includes('--no-open');
31
- const targetFile = args.find((arg) => !arg.startsWith('-'));
32
-
33
- if (command === 'init') {
34
- if (wantsHelp) {
35
- printUsage();
36
- process.exit(0);
37
- }
38
-
39
- const targets = args[1] ? getAgentTargets(args[1]) : await promptForAgentTargets();
40
-
41
- if (!targets) {
42
- console.error('Unknown agent target. Expected codex, claude, cursor, or all.');
43
- process.exit(1);
44
- }
45
-
46
- if (targets.length === 0) {
47
- console.log('No agents selected. Nothing to install.');
48
- process.exit(0);
49
- }
50
-
51
- await initAgentInstructions(targets);
52
- process.exit(0);
53
- }
54
-
55
- if (wantsHelp || !targetFile) {
56
- printUsage();
57
- process.exit(wantsHelp ? 0 : 1);
58
- }
59
-
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.html': 'text/html; charset=utf-8',
18
+ '.js': 'text/javascript; charset=utf-8',
19
+ '.json': 'application/json; charset=utf-8',
20
+ '.svg': 'image/svg+xml',
21
+ };
22
+ const AGENT_TARGETS = ['codex', 'claude', 'cursor'];
23
+ const AGENT_LABELS = {
24
+ codex: 'Codex',
25
+ claude: 'Claude',
26
+ cursor: 'Cursor',
27
+ };
28
+
29
+ const args = process.argv.slice(2);
30
+ const command = args[0];
31
+ const wantsHelp = args.includes('--help') || args.includes('-h');
32
+ const shouldOpenBrowser = !args.includes('--no-open');
33
+ const targetFile = args.find((arg) => !arg.startsWith('-'));
34
+
35
+ if (command === 'init') {
36
+ if (wantsHelp) {
37
+ printUsage();
38
+ process.exit(0);
39
+ }
40
+
41
+ const targets = args[1] ? getAgentTargets(args[1]) : await promptForAgentTargets();
42
+
43
+ if (!targets) {
44
+ console.error('Unknown agent target. Expected codex, claude, cursor, or all.');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (targets.length === 0) {
49
+ console.log('No agents selected. Nothing to install.');
50
+ process.exit(0);
51
+ }
52
+
53
+ await initAgentInstructions(targets);
54
+ process.exit(0);
55
+ }
56
+
57
+ if (wantsHelp || !targetFile) {
58
+ printUsage();
59
+ process.exit(wantsHelp ? 0 : 1);
60
+ }
61
+
60
62
  const documentPath = resolve(process.cwd(), targetFile);
61
63
  const distDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
62
64
  const session = randomBytes(24).toString('hex');
63
-
64
- await ensureReadableFile(documentPath);
65
- await ensureBuildExists(distDir);
66
-
65
+ let lastHeartbeatAt = null;
66
+
67
+ await ensureReadableFile(documentPath);
68
+ await ensureBuildExists(distDir);
69
+
67
70
  const server = createServer((request, response) => {
68
71
  handleRequest(request, response).catch((error) => {
69
- console.error(error);
70
- sendJson(response, 500, { error: 'Internal server error' });
72
+ console.error(error);
73
+ sendJson(response, 500, { error: 'Internal server error' });
71
74
  });
72
75
  });
73
-
74
- server.listen(0, '127.0.0.1', () => {
75
- const address = server.address();
76
- const port = typeof address === 'object' && address ? address.port : 0;
77
- const url = `http://127.0.0.1:${port}/?session=${session}`;
78
-
79
- console.log(`Plan Annotation: ${documentPath}`);
80
- console.log(`Open: ${url}`);
81
- if (shouldOpenBrowser) {
82
- openBrowser(url);
83
- }
84
- });
85
-
76
+ const heartbeatTimeout = setInterval(closeInactiveServer, HEARTBEAT_CHECK_MS);
77
+ heartbeatTimeout.unref();
78
+
79
+ server.listen(0, '127.0.0.1', () => {
80
+ const address = server.address();
81
+ const port = typeof address === 'object' && address ? address.port : 0;
82
+ const url = `http://127.0.0.1:${port}/?session=${session}`;
83
+
84
+ console.log(`Plan Annotation: ${documentPath}`);
85
+ console.log(`Open: ${url}`);
86
+ if (shouldOpenBrowser) {
87
+ openBrowser(url);
88
+ }
89
+ });
90
+
86
91
  process.on('SIGINT', () => {
87
92
  server.close(() => process.exit(0));
88
93
  });
89
-
90
- async function handleRequest(request, response) {
91
- const url = new URL(request.url ?? '/', 'http://127.0.0.1');
92
- const apiHandler = getApiHandler(url.pathname, request.method);
93
-
94
- if (apiHandler) {
95
- await apiHandler(request, response, url);
96
- return;
97
- }
98
-
99
- await serveStaticFile(url.pathname, response);
100
- }
101
-
102
- async function handleDocumentApi(request, response, url) {
103
- if (!isAuthorized(request, url)) {
104
- sendJson(response, 403, { error: 'Forbidden' });
105
- return;
106
- }
107
-
108
- if (request.method === 'GET') {
109
- await sendDocument(response);
110
- return;
111
- }
112
-
113
- if (request.method === 'PUT') {
114
- await saveDocument(request, response);
115
- return;
116
- }
117
-
118
- response.writeHead(405, { Allow: 'GET, PUT' });
119
- response.end();
120
- }
121
-
122
- async function serveStaticFile(pathname, response) {
123
- const candidate = resolveStaticPath(pathname);
124
-
125
- if (!candidate) {
126
- sendForbiddenStaticFile(response);
127
- return;
128
- }
129
-
130
- const fileStat = await getStaticFileStat(candidate);
131
-
132
- if (!fileStat) {
133
- sendNotFound(response);
134
- return;
135
- }
136
-
137
- response.writeHead(200, {
138
- 'Content-Type': MIME_TYPES[extname(candidate)] ?? 'application/octet-stream',
139
- });
140
- createReadStream(candidate).pipe(response);
141
- }
142
-
94
+
95
+ async function handleRequest(request, response) {
96
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
97
+ const apiHandler = getApiHandler(url.pathname, request.method);
98
+
99
+ if (apiHandler) {
100
+ await apiHandler(request, response, url);
101
+ return;
102
+ }
103
+
104
+ await serveStaticFile(url.pathname, response);
105
+ }
106
+
107
+ async function handleDocumentApi(request, response, url) {
108
+ if (!isAuthorized(request, url)) {
109
+ sendJson(response, 403, { error: 'Forbidden' });
110
+ return;
111
+ }
112
+
113
+ if (request.method === 'GET') {
114
+ await sendDocument(response);
115
+ return;
116
+ }
117
+
118
+ if (request.method === 'PUT') {
119
+ await saveDocument(request, response);
120
+ return;
121
+ }
122
+
123
+ response.writeHead(405, { Allow: 'GET, PUT' });
124
+ response.end();
125
+ }
126
+
127
+ async function serveStaticFile(pathname, response) {
128
+ const candidate = resolveStaticPath(pathname);
129
+
130
+ if (!candidate) {
131
+ sendForbiddenStaticFile(response);
132
+ return;
133
+ }
134
+
135
+ const fileStat = await getStaticFileStat(candidate);
136
+
137
+ if (!fileStat) {
138
+ sendNotFound(response);
139
+ return;
140
+ }
141
+
142
+ response.writeHead(200, {
143
+ 'Content-Type': MIME_TYPES[extname(candidate)] ?? 'application/octet-stream',
144
+ });
145
+ createReadStream(candidate).pipe(response);
146
+ }
147
+
143
148
  function getApiHandler(pathname, method) {
144
149
  const route = `${method} ${pathname}`;
145
150
 
@@ -147,230 +152,249 @@ function getApiHandler(pathname, method) {
147
152
  {
148
153
  'GET /api/document': handleDocumentApi,
149
154
  'PUT /api/document': handleDocumentApi,
155
+ 'POST /api/heartbeat': handleHeartbeatApi,
150
156
  'POST /api/shutdown': handleShutdownApi,
151
157
  }[route] ?? null
152
158
  );
153
159
  }
154
160
 
155
- function handleShutdownApi(request, response, url) {
161
+ function handleHeartbeatApi(request, response, url) {
156
162
  if (!isAuthorized(request, url)) {
157
163
  sendJson(response, 403, { error: 'Forbidden' });
158
164
  return;
159
165
  }
160
166
 
161
- sendJson(response, 200, { ok: true });
162
- setTimeout(() => process.exit(0), 100);
163
- }
164
-
165
- async function sendDocument(response) {
166
- sendJson(response, 200, {
167
- filename: documentPath.split(/[\\/]/).pop() ?? 'document.md',
168
- markdown: await readFile(documentPath, 'utf8'),
169
- });
170
- }
171
-
172
- async function saveDocument(request, response) {
173
- const body = await readRequestBody(request);
174
- const parsed = JSON.parse(body);
175
-
176
- if (!parsed || typeof parsed.markdown !== 'string') {
177
- sendJson(response, 400, { error: 'Expected markdown string' });
178
- return;
179
- }
180
-
181
- await writeFile(documentPath, parsed.markdown, 'utf8');
167
+ lastHeartbeatAt = Date.now();
182
168
  sendJson(response, 200, { ok: true });
183
169
  }
184
170
 
185
- function resolveStaticPath(pathname) {
186
- const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
187
- const candidate = resolve(distDir, relativePath);
188
-
189
- return isInsideDirectory(candidate, distDir) ? candidate : null;
190
- }
191
-
192
- function isInsideDirectory(candidate, directory) {
193
- return (
194
- candidate === directory ||
195
- candidate.startsWith(`${directory}\\`) ||
196
- candidate.startsWith(`${directory}/`)
197
- );
198
- }
199
-
200
- async function getStaticFileStat(filePath) {
201
- try {
202
- const fileStat = await stat(filePath);
203
- return fileStat.isFile() ? fileStat : null;
204
- } catch {
205
- return null;
206
- }
207
- }
208
-
209
- function sendForbiddenStaticFile(response) {
210
- response.writeHead(403);
211
- response.end();
212
- }
213
-
214
- function sendNotFound(response) {
215
- response.writeHead(404);
216
- response.end('Not found');
217
- }
218
-
219
- function isAuthorized(request, url) {
220
- return (
221
- request.headers['x-nota-session'] === session || url.searchParams.get('session') === session
222
- );
223
- }
224
-
225
- function readRequestBody(request) {
226
- return new Promise((resolveBody, rejectBody) => {
227
- let body = '';
228
-
229
- request.setEncoding('utf8');
230
- request.on('data', (chunk) => {
231
- body += chunk;
232
-
233
- if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
234
- rejectBody(new Error('Request body too large'));
235
- request.destroy();
236
- }
237
- });
238
- request.on('end', () => resolveBody(body));
239
- request.on('error', rejectBody);
240
- });
241
- }
242
-
243
- function sendJson(response, statusCode, payload) {
244
- response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
245
- response.end(JSON.stringify(payload));
246
- }
247
-
248
- async function ensureReadableFile(filePath) {
249
- try {
250
- const fileStat = await stat(filePath);
251
- if (!fileStat.isFile()) throw new Error('Not a file');
252
- await access(filePath);
253
- } catch {
254
- console.error(`Cannot read markdown file: ${filePath}`);
255
- process.exit(1);
256
- }
257
- }
258
-
259
- async function ensureBuildExists(buildDir) {
260
- try {
261
- await access(join(buildDir, 'index.html'));
262
- } catch {
263
- console.error('Missing dist/index.html. Run `npm run build` before using the CLI from source.');
264
- process.exit(1);
265
- }
266
- }
267
-
268
- function openBrowser(url) {
269
- const platform = process.platform;
270
- const command = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
271
- const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
272
- const child = spawn(command, args, {
273
- detached: true,
274
- stdio: 'ignore',
275
- windowsHide: true,
276
- });
277
-
278
- child.unref();
279
- }
280
-
281
- function printUsage() {
282
- console.log(`Usage:
283
- nota [--no-open] <path/to/plan.md>
284
- nota init
285
- nota init [codex|claude|cursor|all]`);
286
- }
287
-
288
- async function initAgentInstructions(targets) {
289
- await mkdir(resolve(process.cwd(), '.agent-plans'), { recursive: true });
290
- await writeFile(resolve(process.cwd(), '.agent-plans', '.gitkeep'), '', { flag: 'a' });
291
-
292
- for (const agent of targets) {
293
- await writeAgentInstructions(agent);
294
- }
295
-
296
- console.log(`Plan Annotation instructions installed for: ${targets.join(', ')}`);
297
- console.log('Plans directory ready: .agent-plans/');
298
- }
299
-
300
- function getAgentTargets(target) {
301
- if (target === 'all') return AGENT_TARGETS;
302
- if (AGENT_TARGETS.includes(target)) return [target];
303
-
304
- return null;
305
- }
306
-
307
- async function promptForAgentTargets() {
308
- if (!process.stdin.isTTY) {
309
- console.error('Interactive init requires a TTY. Use `nota init all` or `nota init <agent>`.');
310
- process.exit(1);
311
- }
312
-
313
- try {
314
- const { checkbox } = await import('@inquirer/prompts');
315
-
316
- return await checkbox({
317
- message: 'Select AI agents to support',
318
- choices: AGENT_TARGETS.map((agent) => ({
319
- name: AGENT_LABELS[agent],
320
- value: agent,
321
- })),
322
- });
323
- } catch (error) {
324
- if (error?.name === 'ExitPromptError') {
325
- console.log('\nInitialization cancelled.');
326
- process.exit(1);
327
- }
328
-
329
- throw error;
330
- }
331
- }
332
-
333
- async function writeAgentInstructions(agent) {
334
- const filePath = resolve(process.cwd(), getAgentInstructionPath(agent));
335
- const block = getAgentInstructionBlock(agent);
336
-
337
- await mkdir(dirname(filePath), { recursive: true });
338
- await upsertMarkedBlock(filePath, block);
339
- console.log(`Updated ${filePath}`);
340
- }
341
-
342
- function getAgentInstructionPath(agent) {
343
- if (agent === 'codex') return 'AGENTS.md';
344
- if (agent === 'claude') return 'CLAUDE.md';
345
-
346
- return join('.cursor', 'rules', 'nota.mdc');
347
- }
348
-
349
- async function upsertMarkedBlock(filePath, block) {
350
- const startMarker = '<!-- nota:start -->';
351
- const endMarker = '<!-- nota:end -->';
352
- const existing = await readOptionalFile(filePath);
353
- const start = existing.indexOf(startMarker);
354
- const end = existing.indexOf(endMarker);
355
-
356
- if (start !== -1 && end !== -1 && end > start) {
357
- const before = existing.slice(0, start).trimEnd();
358
- const after = existing.slice(end + endMarker.length).trimStart();
359
- await writeFile(filePath, joinTextBlocks(before, block.trimEnd(), after), 'utf8');
360
- return;
361
- }
362
-
363
- await writeFile(filePath, joinTextBlocks(existing.trimEnd(), block.trimEnd()), 'utf8');
171
+ function handleShutdownApi(request, response, url) {
172
+ if (!isAuthorized(request, url)) {
173
+ sendJson(response, 403, { error: 'Forbidden' });
174
+ return;
175
+ }
176
+
177
+ sendJson(response, 200, { ok: true });
178
+ setTimeout(() => process.exit(0), 100);
364
179
  }
365
180
 
366
- async function readOptionalFile(filePath) {
367
- try {
368
- return await readFile(filePath, 'utf8');
369
- } catch {
370
- return '';
371
- }
372
- }
181
+ function closeInactiveServer() {
182
+ if (!lastHeartbeatAt) return;
183
+ if (Date.now() - lastHeartbeatAt < HEARTBEAT_TIMEOUT_MS) return;
373
184
 
374
- function joinTextBlocks(...blocks) {
375
- return `${blocks.filter(Boolean).join('\n\n')}\n`;
185
+ console.log('No active annotation tab detected. Closing nota server.');
186
+ server.close(() => process.exit(0));
376
187
  }
188
+
189
+ async function sendDocument(response) {
190
+ sendJson(response, 200, {
191
+ filename: documentPath.split(/[\\/]/).pop() ?? 'document.md',
192
+ markdown: await readFile(documentPath, 'utf8'),
193
+ });
194
+ }
195
+
196
+ async function saveDocument(request, response) {
197
+ const body = await readRequestBody(request);
198
+ const parsed = JSON.parse(body);
199
+
200
+ if (!parsed || typeof parsed.markdown !== 'string') {
201
+ sendJson(response, 400, { error: 'Expected markdown string' });
202
+ return;
203
+ }
204
+
205
+ await writeFile(documentPath, parsed.markdown, 'utf8');
206
+ sendJson(response, 200, { ok: true });
207
+ }
208
+
209
+ function resolveStaticPath(pathname) {
210
+ const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
211
+ const candidate = resolve(distDir, relativePath);
212
+
213
+ return isInsideDirectory(candidate, distDir) ? candidate : null;
214
+ }
215
+
216
+ function isInsideDirectory(candidate, directory) {
217
+ return (
218
+ candidate === directory ||
219
+ candidate.startsWith(`${directory}\\`) ||
220
+ candidate.startsWith(`${directory}/`)
221
+ );
222
+ }
223
+
224
+ async function getStaticFileStat(filePath) {
225
+ try {
226
+ const fileStat = await stat(filePath);
227
+ return fileStat.isFile() ? fileStat : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ function sendForbiddenStaticFile(response) {
234
+ response.writeHead(403);
235
+ response.end();
236
+ }
237
+
238
+ function sendNotFound(response) {
239
+ response.writeHead(404);
240
+ response.end('Not found');
241
+ }
242
+
243
+ function isAuthorized(request, url) {
244
+ return (
245
+ request.headers['x-nota-session'] === session || url.searchParams.get('session') === session
246
+ );
247
+ }
248
+
249
+ function readRequestBody(request) {
250
+ return new Promise((resolveBody, rejectBody) => {
251
+ let body = '';
252
+
253
+ request.setEncoding('utf8');
254
+ request.on('data', (chunk) => {
255
+ body += chunk;
256
+
257
+ if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
258
+ rejectBody(new Error('Request body too large'));
259
+ request.destroy();
260
+ }
261
+ });
262
+ request.on('end', () => resolveBody(body));
263
+ request.on('error', rejectBody);
264
+ });
265
+ }
266
+
267
+ function sendJson(response, statusCode, payload) {
268
+ response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
269
+ response.end(JSON.stringify(payload));
270
+ }
271
+
272
+ async function ensureReadableFile(filePath) {
273
+ try {
274
+ const fileStat = await stat(filePath);
275
+ if (!fileStat.isFile()) throw new Error('Not a file');
276
+ await access(filePath);
277
+ } catch {
278
+ console.error(`Cannot read markdown file: ${filePath}`);
279
+ process.exit(1);
280
+ }
281
+ }
282
+
283
+ async function ensureBuildExists(buildDir) {
284
+ try {
285
+ await access(join(buildDir, 'index.html'));
286
+ } catch {
287
+ console.error('Missing dist/index.html. Run `npm run build` before using the CLI from source.');
288
+ process.exit(1);
289
+ }
290
+ }
291
+
292
+ function openBrowser(url) {
293
+ const platform = process.platform;
294
+ const command = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
295
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
296
+ const child = spawn(command, args, {
297
+ detached: true,
298
+ stdio: 'ignore',
299
+ windowsHide: true,
300
+ });
301
+
302
+ child.unref();
303
+ }
304
+
305
+ function printUsage() {
306
+ console.log(`Usage:
307
+ nota [--no-open] <path/to/plan.md>
308
+ nota init
309
+ nota init [codex|claude|cursor|all]`);
310
+ }
311
+
312
+ async function initAgentInstructions(targets) {
313
+ await mkdir(resolve(process.cwd(), '.agent-plans'), { recursive: true });
314
+ await writeFile(resolve(process.cwd(), '.agent-plans', '.gitkeep'), '', { flag: 'a' });
315
+
316
+ for (const agent of targets) {
317
+ await writeAgentInstructions(agent);
318
+ }
319
+
320
+ console.log(`Plan Annotation instructions installed for: ${targets.join(', ')}`);
321
+ console.log('Plans directory ready: .agent-plans/');
322
+ }
323
+
324
+ function getAgentTargets(target) {
325
+ if (target === 'all') return AGENT_TARGETS;
326
+ if (AGENT_TARGETS.includes(target)) return [target];
327
+
328
+ return null;
329
+ }
330
+
331
+ async function promptForAgentTargets() {
332
+ if (!process.stdin.isTTY) {
333
+ console.error('Interactive init requires a TTY. Use `nota init all` or `nota init <agent>`.');
334
+ process.exit(1);
335
+ }
336
+
337
+ try {
338
+ const { checkbox } = await import('@inquirer/prompts');
339
+
340
+ return await checkbox({
341
+ message: 'Select AI agents to support',
342
+ choices: AGENT_TARGETS.map((agent) => ({
343
+ name: AGENT_LABELS[agent],
344
+ value: agent,
345
+ })),
346
+ });
347
+ } catch (error) {
348
+ if (error?.name === 'ExitPromptError') {
349
+ console.log('\nInitialization cancelled.');
350
+ process.exit(1);
351
+ }
352
+
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ async function writeAgentInstructions(agent) {
358
+ const filePath = resolve(process.cwd(), getAgentInstructionPath(agent));
359
+ const block = getAgentInstructionBlock(agent);
360
+
361
+ await mkdir(dirname(filePath), { recursive: true });
362
+ await upsertMarkedBlock(filePath, block);
363
+ console.log(`Updated ${filePath}`);
364
+ }
365
+
366
+ function getAgentInstructionPath(agent) {
367
+ if (agent === 'codex') return 'AGENTS.md';
368
+ if (agent === 'claude') return 'CLAUDE.md';
369
+
370
+ return join('.cursor', 'rules', 'nota.mdc');
371
+ }
372
+
373
+ async function upsertMarkedBlock(filePath, block) {
374
+ const startMarker = '<!-- nota:start -->';
375
+ const endMarker = '<!-- nota:end -->';
376
+ const existing = await readOptionalFile(filePath);
377
+ const start = existing.indexOf(startMarker);
378
+ const end = existing.indexOf(endMarker);
379
+
380
+ if (start !== -1 && end !== -1 && end > start) {
381
+ const before = existing.slice(0, start).trimEnd();
382
+ const after = existing.slice(end + endMarker.length).trimStart();
383
+ await writeFile(filePath, joinTextBlocks(before, block.trimEnd(), after), 'utf8');
384
+ return;
385
+ }
386
+
387
+ await writeFile(filePath, joinTextBlocks(existing.trimEnd(), block.trimEnd()), 'utf8');
388
+ }
389
+
390
+ async function readOptionalFile(filePath) {
391
+ try {
392
+ return await readFile(filePath, 'utf8');
393
+ } catch {
394
+ return '';
395
+ }
396
+ }
397
+
398
+ function joinTextBlocks(...blocks) {
399
+ return `${blocks.filter(Boolean).join('\n\n')}\n`;
400
+ }