@sov3rain/nota 0.1.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/README.md +55 -52
- package/bin/agentInstructions.mjs +60 -0
- package/bin/agentInstructions.test.mjs +21 -0
- package/bin/nota.mjs +364 -358
- package/dist/assets/{index-DxtyGuqN.js → index-Ch1jViQV.js} +31 -29
- package/dist/assets/index-DdXUmUCC.css +1 -0
- package/dist/index.html +14 -14
- package/package.json +60 -56
- package/dist/assets/index-47nROFLI.css +0 -1
package/bin/nota.mjs
CHANGED
|
@@ -1,126 +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
|
-
|
|
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
|
+
|
|
11
12
|
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
const HEARTBEAT_TIMEOUT_MS = 15_000;
|
|
14
|
+
const HEARTBEAT_CHECK_MS = 5_000;
|
|
12
15
|
const MIME_TYPES = {
|
|
13
|
-
'.css': 'text/css; charset=utf-8',
|
|
14
|
-
'.html': 'text/html; charset=utf-8',
|
|
15
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
16
|
-
'.json': 'application/json; charset=utf-8',
|
|
17
|
-
'.svg': 'image/svg+xml',
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
|
|
41
62
|
const documentPath = resolve(process.cwd(), targetFile);
|
|
42
63
|
const distDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
|
|
43
64
|
const session = randomBytes(24).toString('hex');
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await
|
|
47
|
-
|
|
65
|
+
let lastHeartbeatAt = null;
|
|
66
|
+
|
|
67
|
+
await ensureReadableFile(documentPath);
|
|
68
|
+
await ensureBuildExists(distDir);
|
|
69
|
+
|
|
48
70
|
const server = createServer((request, response) => {
|
|
49
71
|
handleRequest(request, response).catch((error) => {
|
|
50
|
-
console.error(error);
|
|
51
|
-
sendJson(response, 500, { error: 'Internal server error' });
|
|
72
|
+
console.error(error);
|
|
73
|
+
sendJson(response, 500, { error: 'Internal server error' });
|
|
52
74
|
});
|
|
53
75
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
|
|
67
91
|
process.on('SIGINT', () => {
|
|
68
92
|
server.close(() => process.exit(0));
|
|
69
93
|
});
|
|
70
|
-
|
|
71
|
-
async function handleRequest(request, response) {
|
|
72
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
73
|
-
const apiHandler = getApiHandler(url.pathname, request.method);
|
|
74
|
-
|
|
75
|
-
if (apiHandler) {
|
|
76
|
-
await apiHandler(request, response, url);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
await serveStaticFile(url.pathname, response);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function handleDocumentApi(request, response, url) {
|
|
84
|
-
if (!isAuthorized(request, url)) {
|
|
85
|
-
sendJson(response, 403, { error: 'Forbidden' });
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (request.method === 'GET') {
|
|
90
|
-
await sendDocument(response);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (request.method === 'PUT') {
|
|
95
|
-
await saveDocument(request, response);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
response.writeHead(405, { Allow: 'GET, PUT' });
|
|
100
|
-
response.end();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function serveStaticFile(pathname, response) {
|
|
104
|
-
const candidate = resolveStaticPath(pathname);
|
|
105
|
-
|
|
106
|
-
if (!candidate) {
|
|
107
|
-
sendForbiddenStaticFile(response);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const fileStat = await getStaticFileStat(candidate);
|
|
112
|
-
|
|
113
|
-
if (!fileStat) {
|
|
114
|
-
sendNotFound(response);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
response.writeHead(200, {
|
|
119
|
-
'Content-Type': MIME_TYPES[extname(candidate)] ?? 'application/octet-stream',
|
|
120
|
-
});
|
|
121
|
-
createReadStream(candidate).pipe(response);
|
|
122
|
-
}
|
|
123
|
-
|
|
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
|
+
|
|
124
148
|
function getApiHandler(pathname, method) {
|
|
125
149
|
const route = `${method} ${pathname}`;
|
|
126
150
|
|
|
@@ -128,267 +152,249 @@ function getApiHandler(pathname, method) {
|
|
|
128
152
|
{
|
|
129
153
|
'GET /api/document': handleDocumentApi,
|
|
130
154
|
'PUT /api/document': handleDocumentApi,
|
|
155
|
+
'POST /api/heartbeat': handleHeartbeatApi,
|
|
131
156
|
'POST /api/shutdown': handleShutdownApi,
|
|
132
157
|
}[route] ?? null
|
|
133
158
|
);
|
|
134
159
|
}
|
|
135
160
|
|
|
136
|
-
function
|
|
161
|
+
function handleHeartbeatApi(request, response, url) {
|
|
137
162
|
if (!isAuthorized(request, url)) {
|
|
138
163
|
sendJson(response, 403, { error: 'Forbidden' });
|
|
139
164
|
return;
|
|
140
165
|
}
|
|
141
166
|
|
|
167
|
+
lastHeartbeatAt = Date.now();
|
|
142
168
|
sendJson(response, 200, { ok: true });
|
|
143
|
-
setTimeout(() => process.exit(0), 100);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function sendDocument(response) {
|
|
147
|
-
sendJson(response, 200, {
|
|
148
|
-
filename: documentPath.split(/[\\/]/).pop() ?? 'document.md',
|
|
149
|
-
markdown: await readFile(documentPath, 'utf8'),
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function saveDocument(request, response) {
|
|
154
|
-
const body = await readRequestBody(request);
|
|
155
|
-
const parsed = JSON.parse(body);
|
|
156
|
-
|
|
157
|
-
if (!parsed || typeof parsed.markdown !== 'string') {
|
|
158
|
-
sendJson(response, 400, { error: 'Expected markdown string' });
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
await writeFile(documentPath, parsed.markdown, 'utf8');
|
|
163
|
-
sendJson(response, 200, { ok: true });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function resolveStaticPath(pathname) {
|
|
167
|
-
const relativePath = pathname === '/' ? 'index.html' : decodeURIComponent(pathname.slice(1));
|
|
168
|
-
const candidate = resolve(distDir, relativePath);
|
|
169
|
-
|
|
170
|
-
return isInsideDirectory(candidate, distDir) ? candidate : null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function isInsideDirectory(candidate, directory) {
|
|
174
|
-
return (
|
|
175
|
-
candidate === directory ||
|
|
176
|
-
candidate.startsWith(`${directory}\\`) ||
|
|
177
|
-
candidate.startsWith(`${directory}/`)
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function getStaticFileStat(filePath) {
|
|
182
|
-
try {
|
|
183
|
-
const fileStat = await stat(filePath);
|
|
184
|
-
return fileStat.isFile() ? fileStat : null;
|
|
185
|
-
} catch {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function sendForbiddenStaticFile(response) {
|
|
191
|
-
response.writeHead(403);
|
|
192
|
-
response.end();
|
|
193
169
|
}
|
|
194
170
|
|
|
195
|
-
function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function readRequestBody(request) {
|
|
207
|
-
return new Promise((resolveBody, rejectBody) => {
|
|
208
|
-
let body = '';
|
|
209
|
-
|
|
210
|
-
request.setEncoding('utf8');
|
|
211
|
-
request.on('data', (chunk) => {
|
|
212
|
-
body += chunk;
|
|
213
|
-
|
|
214
|
-
if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
|
|
215
|
-
rejectBody(new Error('Request body too large'));
|
|
216
|
-
request.destroy();
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
request.on('end', () => resolveBody(body));
|
|
220
|
-
request.on('error', rejectBody);
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function sendJson(response, statusCode, payload) {
|
|
225
|
-
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
226
|
-
response.end(JSON.stringify(payload));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function ensureReadableFile(filePath) {
|
|
230
|
-
try {
|
|
231
|
-
const fileStat = await stat(filePath);
|
|
232
|
-
if (!fileStat.isFile()) throw new Error('Not a file');
|
|
233
|
-
await access(filePath);
|
|
234
|
-
} catch {
|
|
235
|
-
console.error(`Cannot read markdown file: ${filePath}`);
|
|
236
|
-
process.exit(1);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function ensureBuildExists(buildDir) {
|
|
241
|
-
try {
|
|
242
|
-
await access(join(buildDir, 'index.html'));
|
|
243
|
-
} catch {
|
|
244
|
-
console.error('Missing dist/index.html. Run `npm run build` before using the CLI from source.');
|
|
245
|
-
process.exit(1);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function openBrowser(url) {
|
|
250
|
-
const platform = process.platform;
|
|
251
|
-
const command = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
252
|
-
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
253
|
-
const child = spawn(command, args, {
|
|
254
|
-
detached: true,
|
|
255
|
-
stdio: 'ignore',
|
|
256
|
-
windowsHide: true,
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
child.unref();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function printUsage() {
|
|
263
|
-
console.log(`Usage:
|
|
264
|
-
nota [--no-open] <path/to/plan.md>
|
|
265
|
-
nota init [codex|claude|cursor|all]`);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function initAgentInstructions(target) {
|
|
269
|
-
const targets = getAgentTargets(target);
|
|
270
|
-
|
|
271
|
-
if (!targets) {
|
|
272
|
-
console.error('Unknown agent target. Expected codex, claude, cursor, or all.');
|
|
273
|
-
process.exit(1);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
await mkdir(resolve(process.cwd(), '.agent-plans'), { recursive: true });
|
|
277
|
-
await writeFile(resolve(process.cwd(), '.agent-plans', '.gitkeep'), '', { flag: 'a' });
|
|
278
|
-
|
|
279
|
-
for (const agent of targets) {
|
|
280
|
-
await writeAgentInstructions(agent);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
console.log(`Plan Annotation instructions installed for: ${targets.join(', ')}`);
|
|
284
|
-
console.log('Plans directory ready: .agent-plans/');
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function getAgentTargets(target) {
|
|
288
|
-
const allTargets = ['codex', 'claude', 'cursor'];
|
|
289
|
-
|
|
290
|
-
if (target === 'all') return allTargets;
|
|
291
|
-
if (allTargets.includes(target)) return [target];
|
|
292
|
-
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function writeAgentInstructions(agent) {
|
|
297
|
-
const filePath = resolve(process.cwd(), getAgentInstructionPath(agent));
|
|
298
|
-
const block = getAgentInstructionBlock(agent);
|
|
299
|
-
|
|
300
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
301
|
-
await upsertMarkedBlock(filePath, block);
|
|
302
|
-
console.log(`Updated ${filePath}`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function getAgentInstructionPath(agent) {
|
|
306
|
-
if (agent === 'codex') return 'AGENTS.md';
|
|
307
|
-
if (agent === 'claude') return 'CLAUDE.md';
|
|
308
|
-
|
|
309
|
-
return join('.cursor', 'rules', 'nota.mdc');
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function getAgentInstructionBlock() {
|
|
313
|
-
return `<!-- nota:start -->
|
|
314
|
-
## Plan Annotation Workflow
|
|
315
|
-
|
|
316
|
-
When the user asks for an implementation plan, create a Markdown file in:
|
|
317
|
-
|
|
318
|
-
\`\`\`text
|
|
319
|
-
.agent-plans/<date>-<topic>.md
|
|
320
|
-
\`\`\`
|
|
321
|
-
|
|
322
|
-
Then launch the local annotation tool.
|
|
323
|
-
|
|
324
|
-
On Windows/PowerShell, use the npm \`.cmd\` shim explicitly:
|
|
325
|
-
|
|
326
|
-
\`\`\`powershell
|
|
327
|
-
nota.cmd .agent-plans/<file>.md
|
|
328
|
-
\`\`\`
|
|
329
|
-
|
|
330
|
-
Do not use \`Start-Process -FilePath nota\` on Windows/PowerShell; it can resolve to the extensionless npm shim and trigger the Windows "open with" dialog instead of running the CLI.
|
|
331
|
-
|
|
332
|
-
On macOS/Linux:
|
|
333
|
-
|
|
334
|
-
\`\`\`bash
|
|
335
|
-
nota .agent-plans/<file>.md
|
|
336
|
-
\`\`\`
|
|
337
|
-
|
|
338
|
-
Tell the user that the plan is open for annotation. Do not continue with implementation until the user says they are done annotating.
|
|
339
|
-
|
|
340
|
-
After the user is done, read the same Markdown file again. User feedback is stored as CriticMarkup:
|
|
341
|
-
|
|
342
|
-
\`\`\`md
|
|
343
|
-
{==text being discussed==}{>>user comment<<}
|
|
344
|
-
\`\`\`
|
|
345
|
-
|
|
346
|
-
Treat those comments as direct user feedback. Update the plan or implementation accordingly.
|
|
347
|
-
|
|
348
|
-
Once all annotations have been processed, remove every CriticMarkup annotation from the file, as well as the global annotations block if present:
|
|
349
|
-
|
|
350
|
-
\`\`\`md
|
|
351
|
-
<!-- nota:globals
|
|
352
|
-
[...]
|
|
353
|
-
-->
|
|
354
|
-
\`\`\`
|
|
355
|
-
|
|
356
|
-
Leave only the plain Markdown content.
|
|
357
|
-
|
|
358
|
-
If the \`nota\` command is unavailable, tell the user to install it globally:
|
|
359
|
-
|
|
360
|
-
\`\`\`bash
|
|
361
|
-
npm install -g nota
|
|
362
|
-
\`\`\`
|
|
363
|
-
<!-- nota:end -->
|
|
364
|
-
`;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function upsertMarkedBlock(filePath, block) {
|
|
368
|
-
const startMarker = '<!-- nota:start -->';
|
|
369
|
-
const endMarker = '<!-- nota:end -->';
|
|
370
|
-
const existing = await readOptionalFile(filePath);
|
|
371
|
-
const start = existing.indexOf(startMarker);
|
|
372
|
-
const end = existing.indexOf(endMarker);
|
|
373
|
-
|
|
374
|
-
if (start !== -1 && end !== -1 && end > start) {
|
|
375
|
-
const before = existing.slice(0, start).trimEnd();
|
|
376
|
-
const after = existing.slice(end + endMarker.length).trimStart();
|
|
377
|
-
await writeFile(filePath, joinTextBlocks(before, block.trimEnd(), after), 'utf8');
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
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);
|
|
382
179
|
}
|
|
383
180
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
} catch {
|
|
388
|
-
return '';
|
|
389
|
-
}
|
|
390
|
-
}
|
|
181
|
+
function closeInactiveServer() {
|
|
182
|
+
if (!lastHeartbeatAt) return;
|
|
183
|
+
if (Date.now() - lastHeartbeatAt < HEARTBEAT_TIMEOUT_MS) return;
|
|
391
184
|
|
|
392
|
-
|
|
393
|
-
|
|
185
|
+
console.log('No active annotation tab detected. Closing nota server.');
|
|
186
|
+
server.close(() => process.exit(0));
|
|
394
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
|
+
}
|