cortexterm 0.0.0-stage → 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,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # cortexterm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```sh
4
+ npx cortexterm
5
+ ```
6
+
7
+ Starts [CortexTerm](https://github.com/agentbusiness/CortexTerm), an AI terminal with a local model. The first
8
+ run downloads the app for this machine (Apple Silicon Macs; Linux x64 and arm64) from the official
9
+ releases, checks it against the checksum in the release catalog and unpacks it into `~/.cortexterm/app/`.
10
+ Later runs start the installed app at once; the app updates its frame and plugins itself. The app
11
+ runs on its own: you can close the terminal (its start output goes to `~/.cortexterm/logs/start.log`);
12
+ running the command again while it runs only says so.
13
+
14
+ Arguments go to the app: `npx cortexterm --headless`, `npx cortexterm --safe`.
15
+
16
+ ## A Mac nobody sits at: the runner
17
+
18
+ ```sh
19
+ npx cortexterm runner
20
+ npx cortexterm runner join <invitation>
21
+ ```
22
+
23
+ `runner` keeps CortexTerm running on this Mac without a window, now and at every login (a LaunchAgent,
24
+ `~/Library/LaunchAgents/com.cortexterm.runner.plist`; its output goes to `~/.cortexterm/logs/runner.log`).
25
+ `runner join` joins it to your network with an invitation made in Settings > Mesh on another computer.
26
+ The other computers then list it under COMPUTERS and open shells on it. `npx cortexterm runner off`
27
+ stops it. On Linux, start it with `npx cortexterm --headless` for now.
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ // `npx cortexterm`: start CortexTerm. The first run downloads the app for this machine from the
3
+ // official releases (the catalog), checks its checksum and unpacks it into ~/.cortexterm/app/<version>/;
4
+ // later runs start the installed app at once, without looking at the catalog: the app updates its
5
+ // frame and plugins itself, so a newer app release is only for machines that have none. The app runs
6
+ // on its own: closing the terminal does not quit it (its start output goes to ~/.cortexterm/logs/start.log).
7
+ // Arguments go to the app (`--headless`, `--safe`). `npx cortexterm runner` keeps it running without a
8
+ // window at every login (a LaunchAgent), for a Mac nobody sits at; `runner join <invitation>` joins
9
+ // that Mac to the network, `runner off` stops it.
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { createHash } from 'node:crypto';
13
+ import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
14
+ import http from 'node:http';
15
+ import https from 'node:https';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { pipeline } from 'node:stream/promises';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const REPO = process.env.CORTEXTERM_REPO || 'agentbusiness/CortexTerm';
22
+ // CORTEXTERM_CATALOG: another catalog (a mirror, or a release under test).
23
+ const CATALOG_URL = process.env.CORTEXTERM_CATALOG || `https://github.com/${REPO}/releases/download/catalog/catalog.json`;
24
+ const HOME = process.env.CORTEX_HOME || join(homedir(), '.cortexterm');
25
+ const APPS = join(HOME, 'app');
26
+ const READY = '.ready';
27
+ const START_LOG = join(HOME, 'logs', 'start.log');
28
+ const KEEP = 2;
29
+
30
+ const say = (text) => process.stderr.write(`cortexterm: ${text}\n`);
31
+
32
+ function platform() {
33
+ const key = `${process.platform}-${process.arch}`;
34
+ const known = { 'darwin-arm64': 'darwin-arm64', 'linux-x64': 'linux-x64', 'linux-arm64': 'linux-arm64' };
35
+ if (!known[key]) throw new Error(`CortexTerm runs on Apple Silicon Macs and on Linux (x64, arm64), not on ${key}`);
36
+ return known[key];
37
+ }
38
+
39
+ /** `0.3.7` as a comparable triple, or null. */
40
+ function version(text) {
41
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(\+.*)?$/.exec(String(text ?? '').trim());
42
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
43
+ }
44
+
45
+ function compare(a, b) {
46
+ for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] - b[i];
47
+ return 0;
48
+ }
49
+
50
+ /** The response to a GET of `url`, after redirects (GitHub sends release files from another host). */
51
+ function get(url, redirects = 5) {
52
+ return new Promise((resolve, reject) => {
53
+ const client = url.startsWith('https:') ? https : http;
54
+ const request = client.get(url, { headers: { 'user-agent': 'cortexterm-npx' } }, (response) => {
55
+ const status = response.statusCode ?? 0;
56
+ if (status >= 300 && status < 400 && response.headers.location && redirects > 0) {
57
+ response.resume();
58
+ resolve(get(new URL(response.headers.location, url).toString(), redirects - 1));
59
+ return;
60
+ }
61
+ if (status !== 200) {
62
+ response.resume();
63
+ reject(new Error(`${url} answered ${status}`));
64
+ return;
65
+ }
66
+ resolve(response);
67
+ });
68
+ request.on('error', reject);
69
+ });
70
+ }
71
+
72
+ /** The release catalog: the current version of the app and every part, with their files. */
73
+ async function catalog() {
74
+ const response = await fetch(CATALOG_URL, { headers: { 'user-agent': 'cortexterm-npx' } });
75
+ if (!response.ok) throw new Error(`${CATALOG_URL} answered ${response.status}`);
76
+ const parsed = await response.json();
77
+ if (parsed.format !== 'cortexterm-catalog/1') throw new Error(`unknown catalog format ${parsed.format}`);
78
+ return parsed;
79
+ }
80
+
81
+ /** Installed app versions, newest first. */
82
+ function installed() {
83
+ if (!existsSync(APPS)) return [];
84
+ return readdirSync(APPS)
85
+ .filter((name) => version(name) && existsSync(join(APPS, name, READY)))
86
+ .sort((a, b) => compare(version(b), version(a)));
87
+ }
88
+
89
+ function executable(dir) {
90
+ return process.platform === 'darwin' ? join(dir, 'CortexTerm.app', 'Contents', 'MacOS', 'CortexTerm') : join(dir, 'cortexterm', 'CortexTerm');
91
+ }
92
+
93
+ /** Download the app archive, check size and SHA-256, unpack it beside the others. */
94
+ async function install(app, asset) {
95
+ mkdirSync(APPS, { recursive: true });
96
+ const archive = join(APPS, `.download-${app.version}.tar.gz`);
97
+ const staging = join(APPS, `.staging-${app.version}`);
98
+ say(`downloading CortexTerm ${app.version} (${Math.round(asset.size / 1e6)} MB)`);
99
+ // node:http rather than fetch: fetch's stream fails when a server closes the connection while the
100
+ // file write is behind.
101
+ const body = await get(asset.url);
102
+ const hash = createHash('sha256');
103
+ let received = 0;
104
+ let reported = 0;
105
+ body.on('data', (chunk) => {
106
+ hash.update(chunk);
107
+ received += chunk.length;
108
+ const percent = asset.size ? Math.floor((received * 100) / asset.size) : 0;
109
+ if (percent >= reported + 10) {
110
+ reported = percent;
111
+ process.stderr.write(`\rcortexterm: ${percent}%`);
112
+ }
113
+ });
114
+ await pipeline(body, createWriteStream(archive));
115
+ process.stderr.write('\n');
116
+ if (received !== asset.size || hash.digest('hex') !== String(asset.sha256).toLowerCase()) {
117
+ rmSync(archive, { force: true });
118
+ throw new Error(`${asset.name} does not match the catalog (size or checksum); it was not used`);
119
+ }
120
+ rmSync(staging, { recursive: true, force: true });
121
+ mkdirSync(staging, { recursive: true });
122
+ await run('tar', ['-xzf', archive, '-C', staging]);
123
+ rmSync(archive, { force: true });
124
+ const target = join(APPS, app.version);
125
+ rmSync(target, { recursive: true, force: true });
126
+ renameSync(staging, target);
127
+ writeFileSync(join(target, READY), '');
128
+ say(`CortexTerm ${app.version} is installed in ${target}`);
129
+ }
130
+
131
+ function run(command, args) {
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'inherit'] });
134
+ child.on('error', reject);
135
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${command} failed (${code})`))));
136
+ });
137
+ }
138
+
139
+ /** The pid of a CortexTerm running for this home, if any (the frame writes run/frame.json). */
140
+ function running() {
141
+ try {
142
+ const { pid } = JSON.parse(readFileSync(join(HOME, 'run', 'frame.json'), 'utf8'));
143
+ process.kill(pid, 0);
144
+ return pid;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Keep the newest versions (and the one starting now). */
151
+ function prune(keep) {
152
+ installed()
153
+ .slice(KEEP)
154
+ .filter((name) => name !== keep)
155
+ .forEach((name) => rmSync(join(APPS, name), { recursive: true, force: true }));
156
+ }
157
+
158
+ /** The installed app to start (downloaded first on a machine that has none), or null. */
159
+ async function ensureApp() {
160
+ const key = platform();
161
+ // The installed app updates itself (frame and plugins): only a machine without one downloads.
162
+ if (installed().length === 0) {
163
+ try {
164
+ const listed = await catalog();
165
+ const files = listed.app?.files?.[key];
166
+ if (listed.app && files?.archive) await install(listed.app, files.archive);
167
+ else say(`no CortexTerm app is released for ${key} yet`);
168
+ } catch (error) {
169
+ say(`cannot download CortexTerm (${error.message})`);
170
+ }
171
+ }
172
+ const chosen = installed()[0];
173
+ if (!chosen) {
174
+ say('CortexTerm is not installed and could not be downloaded');
175
+ return null;
176
+ }
177
+ prune(chosen);
178
+ return chosen;
179
+ }
180
+
181
+ // --- the runner: CortexTerm with no window, kept running on a Mac nobody sits at -------------------
182
+
183
+ const LABEL = 'com.cortexterm.runner';
184
+ const AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
185
+ const RUNNER = join(HOME, 'runner', 'cortexterm.mjs');
186
+
187
+ const xml = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
188
+
189
+ /** A LaunchAgent that starts this launcher (a copy kept in the home: npx's own is temporary) in
190
+ * the foreground with `--headless` at every login, and again when the app ends with an error. */
191
+ function agentPlist() {
192
+ const env = process.env.CORTEX_HOME ? `<key>EnvironmentVariables</key><dict><key>CORTEX_HOME</key><string>${xml(HOME)}</string></dict>` : '';
193
+ const log = xml(join(HOME, 'logs', 'runner.log'));
194
+ return `<?xml version="1.0" encoding="UTF-8"?>
195
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
196
+ <plist version="1.0"><dict>
197
+ <key>Label</key><string>${LABEL}</string>
198
+ <key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(RUNNER)}</string><string>--headless</string><string>--foreground</string></array>
199
+ <key>RunAtLoad</key><true/>
200
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
201
+ <key>StandardOutPath</key><string>${log}</string>
202
+ <key>StandardErrorPath</key><string>${log}</string>
203
+ ${env}
204
+ </dict></plist>
205
+ `;
206
+ }
207
+
208
+ function launchctl(args) {
209
+ return new Promise((resolve) => {
210
+ const child = spawn('/bin/launchctl', args, { stdio: 'ignore' });
211
+ child.on('error', () => resolve(false));
212
+ child.on('exit', (code) => resolve(code === 0));
213
+ });
214
+ }
215
+
216
+ /** The frame's API token: in the Keychain on macOS, in `run/frame.json` elsewhere. */
217
+ function frameToken(info) {
218
+ if (info.token) return Promise.resolve(info.token);
219
+ return new Promise((resolve, reject) => {
220
+ let out = '';
221
+ const child = spawn('/usr/bin/security', ['find-generic-password', '-s', 'CortexTerm frame token', '-a', HOME, '-w'], { stdio: ['ignore', 'pipe', 'ignore'] });
222
+ child.stdout.on('data', (chunk) => { out += chunk; });
223
+ child.on('error', reject);
224
+ child.on('exit', (code) => (code === 0 ? resolve(out.trim()) : reject(new Error('the frame token is not in the Keychain'))));
225
+ });
226
+ }
227
+
228
+ /** One call on the running frame (`POST /api/rpc`), as scripts make it. */
229
+ async function frameCall(method, params) {
230
+ let info;
231
+ try {
232
+ info = JSON.parse(readFileSync(join(HOME, 'run', 'frame.json'), 'utf8'));
233
+ process.kill(info.pid, 0);
234
+ } catch {
235
+ throw new Error('CortexTerm is not running here: start it first (npx cortexterm runner)');
236
+ }
237
+ const token = await frameToken(info);
238
+ const response = await fetch(`http://127.0.0.1:${info.port}/api/rpc?token=${encodeURIComponent(token)}`, {
239
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method, params }),
240
+ });
241
+ const answer = await response.json();
242
+ if (!answer.ok) throw new Error(answer.error?.message ?? JSON.stringify(answer.error));
243
+ return answer.result;
244
+ }
245
+
246
+ async function runner(args) {
247
+ const [what, ...rest] = args;
248
+ if (what === 'join') {
249
+ const code = rest.join(' ').trim();
250
+ if (!code) {
251
+ say('npx cortexterm runner join <invitation>: the invitation from Settings > Mesh on a computer of your network');
252
+ return 1;
253
+ }
254
+ const member = await frameCall('mesh.pair.join', { code });
255
+ say(`joined the network as ${member.name}`);
256
+ return 0;
257
+ }
258
+ if (process.platform !== 'darwin') {
259
+ say('the runner is for Macs for now; elsewhere start CortexTerm with `npx cortexterm --headless`');
260
+ return 1;
261
+ }
262
+ const domain = `gui/${process.getuid()}`;
263
+ if (what === 'off') {
264
+ await launchctl(['bootout', `${domain}/${LABEL}`]);
265
+ rmSync(AGENT, { force: true });
266
+ say('the runner is off: CortexTerm no longer starts at login, and the runner\'s CortexTerm has stopped');
267
+ return 0;
268
+ }
269
+ if (what) {
270
+ say(`unknown runner command \`${what}\` (runner, runner join <invitation>, runner off)`);
271
+ return 1;
272
+ }
273
+ if (!(await ensureApp())) return 1;
274
+ mkdirSync(join(HOME, 'runner'), { recursive: true });
275
+ mkdirSync(join(HOME, 'logs'), { recursive: true });
276
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
277
+ writeFileSync(RUNNER, readFileSync(fileURLToPath(import.meta.url)));
278
+ writeFileSync(AGENT, agentPlist());
279
+ await launchctl(['bootout', `${domain}/${LABEL}`]);
280
+ if (!(await launchctl(['bootstrap', domain, AGENT]))) {
281
+ say(`cannot start the runner (launchctl bootstrap ${domain} ${AGENT} failed)`);
282
+ return 1;
283
+ }
284
+ const pid = running();
285
+ say(pid
286
+ ? `CortexTerm is already running here (pid ${pid}); the runner keeps it running without a window from the next login`
287
+ : 'the runner is on: CortexTerm runs here without a window, now and at every login');
288
+ say('to join your network: npx cortexterm runner join <invitation>');
289
+ return 0;
290
+ }
291
+
292
+ async function main() {
293
+ const args = process.argv.slice(2);
294
+ if (args.includes('--help') || args.includes('-h')) {
295
+ process.stdout.write([
296
+ 'npx cortexterm [--headless] [--safe] start CortexTerm; the first run downloads it into ~/.cortexterm/app',
297
+ 'npx cortexterm runner keep CortexTerm running here without a window, at every login (macOS)',
298
+ 'npx cortexterm runner join <invitation> join this computer to your network (the invitation from Settings > Mesh)',
299
+ 'npx cortexterm runner off stop the runner',
300
+ '',
301
+ ].join('\n'));
302
+ return 0;
303
+ }
304
+ if (args[0] === 'runner') return runner(args.slice(1));
305
+ const pid = running();
306
+ if (pid) {
307
+ say(`CortexTerm is already running (pid ${pid})`);
308
+ return 0;
309
+ }
310
+ const chosen = await ensureApp();
311
+ if (!chosen) return 1;
312
+ // The runner's LaunchAgent: the app in the foreground, so launchd sees it end.
313
+ if (args.includes('--foreground')) {
314
+ const child = spawn(executable(join(APPS, chosen)), args.filter((arg) => arg !== '--foreground'), { stdio: 'inherit' });
315
+ return await new Promise((resolve) => {
316
+ child.on('error', (error) => {
317
+ say(`cannot start CortexTerm: ${error.message}`);
318
+ resolve(1);
319
+ });
320
+ child.on('exit', (code) => resolve(code ?? 1));
321
+ });
322
+ }
323
+ // Its own session, detached from this terminal: closing the terminal does not quit it.
324
+ mkdirSync(join(HOME, 'logs'), { recursive: true });
325
+ const log = openSync(START_LOG, 'a');
326
+ const child = spawn(executable(join(APPS, chosen)), args, { detached: true, stdio: ['ignore', log, log] });
327
+ closeSync(log);
328
+ return await new Promise((resolve) => {
329
+ child.on('error', (error) => {
330
+ say(`cannot start CortexTerm: ${error.message}`);
331
+ resolve(1);
332
+ });
333
+ child.on('spawn', () => {
334
+ child.unref();
335
+ say(`CortexTerm ${chosen} is starting; you can close this terminal (start output: ${START_LOG})`);
336
+ resolve(0);
337
+ });
338
+ });
339
+ }
340
+
341
+ main().then(
342
+ (code) => process.exit(code),
343
+ (error) => {
344
+ say(error.message);
345
+ process.exit(1);
346
+ },
347
+ );
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "cortexterm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "CortexTerm, an AI terminal with a local model: npx cortexterm downloads the app for this machine from the official releases and starts it.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cortexterm": "bin/cortexterm.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.17"
15
+ },
16
+ "os": [
17
+ "darwin",
18
+ "linux"
19
+ ],
20
+ "cpu": [
21
+ "arm64",
22
+ "x64"
23
+ ],
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/agentbusiness/CortexTerm.git"
28
+ },
29
+ "homepage": "https://github.com/agentbusiness/CortexTerm"
30
+ }