@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/README.md +55 -55
- package/bin/agentInstructions.mjs +52 -48
- package/bin/agentInstructions.test.mjs +14 -13
- package/bin/nota.mjs +364 -340
- package/dist/assets/{index-CrSNpEPT.js → index-Ch1jViQV.js} +40 -40
- package/dist/assets/index-DdXUmUCC.css +1 -0
- package/dist/index.html +14 -14
- package/package.json +60 -59
- package/dist/assets/index-CUPUlyC5.css +0 -1
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
|
-
|
|
65
|
-
await
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
+
}
|