@aetheros/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/aether.js +4 -2
  2. package/package.json +13 -13
  3. package/src/start.js +176 -0
package/bin/aether.js CHANGED
@@ -107,9 +107,11 @@ export function run(args, cwd = process.cwd(), log = console.log) {
107
107
  log('workflow docs ok');
108
108
  return;
109
109
  }
110
- throw new Error('Usage: aether <init|doctor|status|install <kit>...|check|spec ...|run <pack>|run init <dir>|room [serve|snapshot|html <file>|install-vscode]|owner ...|idea ...|githooks <install|uninstall|status>|setup [--yes|--kits a,b] [--os-name ..] [--assistant ..] [--accent #hex] [--dry-run]|setup machine [--install [--yes]]|doctor machine|version|sprint <status|plan|close>|voice [--ai provider] [--model id|--tier t] [--agents [--agent id]] [--pack dir]|workspace [init] [--no-memory] [--no-price-refresh]|models [list|pick --tier cheap|balanced|strong|init|prices [update]]>');
110
+ throw new Error('Usage: aether <start [--reset|--where|--no-open|--no-voice|--launchers]|init|doctor|status|install <kit>...|check|spec ...|run <pack>|run init <dir>|room [serve|snapshot|html <file>|install-vscode]|owner ...|idea ...|githooks <install|uninstall|status>|setup [--yes|--kits a,b] [--os-name ..] [--assistant ..] [--accent #hex] [--dry-run]|setup machine [--install [--yes]]|doctor machine|version|sprint <status|plan|close>|voice [--ai provider] [--model id|--tier t] [--agents [--agent id]] [--pack dir]|workspace [init] [--no-memory] [--no-price-refresh]|models [list|pick --tier cheap|balanced|strong|init|prices [update]]>');
111
111
  }
112
- if (isMain && process.argv[2] === 'sprint') {
112
+ if (isMain && process.argv[2] === 'start') {
113
+ import('../src/start.js').then(m => m.start(process.argv.slice(3), process.cwd())).catch(error => { console.error(error.message); process.exitCode = 1; });
114
+ } else if (isMain && process.argv[2] === 'sprint') {
113
115
  runSprint(process.argv.slice(3), process.cwd(), console.log).catch(error => { console.error(error.message); process.exitCode = 1; });
114
116
  } else if (isMain && process.argv[2] === 'setup') {
115
117
  (process.argv[3] === 'machine'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aetheros/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,21 +12,21 @@
12
12
  "aether": "./bin/aether.js"
13
13
  },
14
14
  "scripts": {
15
- "check": "node --check bin/aether.js && node --check src/setup.js && node --check src/sprint.js && node --check src/machine.js",
15
+ "check": "node --check bin/aether.js && node --check src/setup.js && node --check src/sprint.js && node --check src/machine.js && node --check src/start.js",
16
16
  "test": "node --test test/*.test.mjs"
17
17
  },
18
18
  "dependencies": {
19
- "@aetheros/workflow": "0.1.0",
20
- "@aetheros/provider-claude": "0.1.0",
21
- "@aetheros/provider-openai": "0.1.0",
22
- "@aetheros/spec-kit": "0.1.0",
23
- "@aetheros/runner": "0.1.0",
24
- "@aetheros/control-room": "0.1.0",
25
- "@aetheros/voice": "0.1.0",
26
- "@aetheros/workspace": "0.1.0",
27
- "@aetheros/models": "0.1.0",
28
- "@aetheros/provider-google": "0.1.0",
29
- "@aetheros/provider-openai-compatible": "0.1.0"
19
+ "@aetheros/workflow": "0.1.1",
20
+ "@aetheros/provider-claude": "0.1.1",
21
+ "@aetheros/provider-openai": "0.1.1",
22
+ "@aetheros/spec-kit": "0.1.1",
23
+ "@aetheros/runner": "0.1.1",
24
+ "@aetheros/control-room": "0.1.1",
25
+ "@aetheros/voice": "0.1.1",
26
+ "@aetheros/workspace": "0.1.1",
27
+ "@aetheros/models": "0.1.1",
28
+ "@aetheros/provider-google": "0.1.1",
29
+ "@aetheros/provider-openai-compatible": "0.1.1"
30
30
  },
31
31
  "files": [
32
32
  "src",
package/src/start.js ADDED
@@ -0,0 +1,176 @@
1
+ // `aether start`: one command (or a double-click on start-aetheros.cmd) that starts everything and opens it.
2
+ // First run: asks for an AI provider key and saves it outside the project (in the home folder, owner-only).
3
+ // Every run: starts the workspace and the voice console, then opens both in the browser.
4
+ // Already running: opens the pages again and exits.
5
+ import {spawn} from 'node:child_process';
6
+ import {createHash} from 'node:crypto';
7
+ import {chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync} from 'node:fs';
8
+ import {homedir} from 'node:os';
9
+ import {basename, join} from 'node:path';
10
+ import {createInterface} from 'node:readline';
11
+ import {fileURLToPath} from 'node:url';
12
+
13
+ export const PROVIDER_KEYS = [
14
+ {env: 'ANTHROPIC_API_KEY', name: 'Anthropic (Claude)', hint: 'sk-ant-…, from console.anthropic.com (create it inside a workspace)'},
15
+ {env: 'OPENAI_API_KEY', name: 'OpenAI', hint: 'sk-…, from platform.openai.com'},
16
+ {env: 'GEMINI_API_KEY', name: 'Google Gemini', hint: 'from aistudio.google.com'},
17
+ ];
18
+ const ALL_KEYS = [...PROVIDER_KEYS.map(p => p.env), 'GOOGLE_API_KEY', 'XAI_API_KEY', 'MISTRAL_API_KEY'];
19
+ const STATE = join('.aether', 'local', 'start.json');
20
+
21
+ export const LAUNCHERS = {
22
+ 'start-aetheros.cmd': '@echo off\r\nREM Starts AetherOS for this project and opens it in the browser. Double-click or run: start-aetheros\r\ncd /d "%~dp0"\r\nnode .aether\\aether.mjs start %*\r\nif errorlevel 1 pause\r\n',
23
+ 'start-aetheros.sh': '#!/bin/sh\n# Starts AetherOS for this project and opens it in the browser. Run: ./start-aetheros.sh\ncd "$(dirname "$0")" && exec node .aether/aether.mjs start "$@"\n',
24
+ };
25
+
26
+ /** Where this project's settings live: the home folder, never the project (keys must not reach git). */
27
+ export function settingsFile(root, home = homedir()) {
28
+ let real = root;
29
+ try { real = realpathSync(root); } catch {}
30
+ const id = createHash('sha256').update(real.toLowerCase()).digest('hex').slice(0, 10);
31
+ return join(home, '.aether', 'projects', `${basename(real).replace(/[^\w.-]/g, '_')}-${id}.json`);
32
+ }
33
+
34
+ export function loadSettings(file) {
35
+ try { const s = JSON.parse(readFileSync(file, 'utf8')); return s && typeof s.env === 'object' ? s : {env: {}}; } catch { return {env: {}}; }
36
+ }
37
+
38
+ export function saveSettings(file, settings) {
39
+ mkdirSync(join(file, '..'), {recursive: true, mode: 0o700});
40
+ writeFileSync(file, JSON.stringify({schemaVersion: 1, ...settings}, null, 2) + '\n', {mode: 0o600});
41
+ try { chmodSync(file, 0o600); } catch {}
42
+ }
43
+
44
+ /** Writes the double-click launchers into the project when they're missing. Returns the names written. */
45
+ export function writeLaunchers(root) {
46
+ const written = [];
47
+ for (const [name, text] of Object.entries(LAUNCHERS)) {
48
+ const file = join(root, name);
49
+ if (existsSync(file)) continue;
50
+ writeFileSync(file, text, {mode: 0o755});
51
+ written.push(name);
52
+ }
53
+ return written;
54
+ }
55
+
56
+ /** The keys to start with: the terminal's own variables win, then the saved ones. */
57
+ export function resolveKeys(env, settings) {
58
+ const keys = {};
59
+ for (const name of ALL_KEYS) {
60
+ if (env[name]) keys[name] = env[name];
61
+ else if (settings.env?.[name]) keys[name] = settings.env[name];
62
+ }
63
+ return keys;
64
+ }
65
+
66
+ /** Asks for one key, Anthropic first; the typed key is not shown on screen. */
67
+ export async function askForKey(ask, log) {
68
+ log('\nAetherOS needs one AI provider key so your agents can answer. It is saved in your home folder, never in the project.');
69
+ for (const p of PROVIDER_KEYS) {
70
+ const key = String(await ask(`Paste your ${p.name} API key (${p.hint}), or press Enter to skip: `, {secret: true})).trim();
71
+ if (key) return {[p.env]: key};
72
+ }
73
+ return {};
74
+ }
75
+
76
+ function terminalAsk() {
77
+ return (question, {secret} = {}) => new Promise(resolveAnswer => {
78
+ const rl = createInterface({input: process.stdin, output: process.stdout, terminal: true});
79
+ if (secret) {
80
+ const write = rl._writeToOutput?.bind(rl);
81
+ rl._writeToOutput = text => { if (text.startsWith(question)) write?.(text); else if (!/^\r?\n$/.test(text)) write?.('*'); else write?.(text); };
82
+ }
83
+ rl.question(question, answer => { rl.close(); if (secret) process.stdout.write('\n'); resolveAnswer(answer); });
84
+ });
85
+ }
86
+
87
+ export function openInBrowser(url, {spawnImpl = spawn, platform = process.platform} = {}) {
88
+ const [cmd, args] = platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
89
+ try { spawnImpl(cmd, args, {stdio: 'ignore', detached: true}).unref?.(); return true; } catch { return false; }
90
+ }
91
+
92
+ async function answers(url, fetchImpl) {
93
+ try { return (await fetchImpl(url, {signal: AbortSignal.timeout(1500)})).status < 500; } catch { return false; }
94
+ }
95
+
96
+ /** Starts a child `aether <args>` and resolves with the first http URL it prints after `label`. */
97
+ function startChild(cliPath, args, {root, env, label, log, spawnImpl}) {
98
+ return new Promise((resolveUrl, reject) => {
99
+ const child = spawnImpl(process.execPath, [cliPath, ...args], {cwd: root, env, stdio: ['ignore', 'pipe', 'pipe']});
100
+ let found = false, buffer = '';
101
+ const onData = chunk => {
102
+ buffer += chunk;
103
+ const lines = buffer.split(/\r?\n/);
104
+ buffer = lines.pop();
105
+ for (const line of lines) {
106
+ if (!line.trim()) continue;
107
+ if (!found) {
108
+ const match = line.includes(label) && line.match(/https?:\/\/127\.0\.0\.1:\d+\/\S*/);
109
+ if (match) { found = true; resolveUrl({child, url: match[0]}); continue; }
110
+ }
111
+ log(` [${args[0]}] ${line}`);
112
+ }
113
+ };
114
+ child.stdout.on('data', onData);
115
+ child.stderr.on('data', onData);
116
+ child.on('exit', code => { if (!found) reject(new Error(`aether ${args[0]} stopped (exit ${code}) before it was ready`)); });
117
+ });
118
+ }
119
+
120
+ export async function start(args, root, {log = console.log, env = process.env, home = homedir(), ask, interactive = Boolean(process.stdin.isTTY), cliPath, open = openInBrowser, spawnImpl = spawn, fetchImpl = fetch, wait = true} = {}) {
121
+ if (!existsSync(join(root, '.aether')) && !existsSync(join(root, 'aether.config.json'))) {
122
+ throw new Error('AetherOS is not set up in this folder yet. Run: npx @aetheros/create');
123
+ }
124
+ for (const name of writeLaunchers(root)) log(`created ${name} (double-click it, or run it, to start AetherOS next time)`);
125
+ if (args.includes('--launchers')) return {launchersOnly: true};
126
+
127
+ const file = settingsFile(root, home);
128
+ const settings = loadSettings(file);
129
+ if (args.includes('--where')) { log(file); return {file}; }
130
+ let keys = resolveKeys(env, args.includes('--reset') ? {env: {}} : settings);
131
+ if (!Object.keys(keys).length || args.includes('--reset')) {
132
+ if (interactive) {
133
+ const typed = await askForKey(ask ?? terminalAsk(), log);
134
+ if (Object.keys(typed).length) {
135
+ saveSettings(file, {...settings, env: {...(args.includes('--reset') ? {} : settings.env), ...typed}});
136
+ log(`Saved in ${file} (only your user can read it; delete the file to forget the key).`);
137
+ keys = {...keys, ...typed};
138
+ }
139
+ }
140
+ if (!Object.keys(keys).length) log('No AI provider key: the pages open, but agents can only answer once you add one. Run node .aether/aether.mjs start --reset to add it.');
141
+ }
142
+
143
+ const noOpen = args.includes('--no-open') || env.AETHER_NO_OPEN === '1';
144
+ const statePath = join(root, STATE);
145
+ let state = {};
146
+ try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}
147
+ if (state.workspace && await answers(state.workspace, fetchImpl)) {
148
+ log(`AetherOS is already running.\n Workspace: ${state.workspace}\n Voice console: ${state.voice ?? '(not running)'}`);
149
+ if (!noOpen) { open(state.workspace); if (state.voice) open(state.voice); }
150
+ return {alreadyRunning: true, ...state};
151
+ }
152
+
153
+ const childEnv = {...env, ...keys};
154
+ const cli = cliPath ?? fileURLToPath(new URL('../bin/aether.js', import.meta.url));
155
+ log('Starting AetherOS…');
156
+ const workspace = await startChild(cli, ['workspace', '--port', String(Number(value(args, '--port') ?? 4850))], {root, env: childEnv, label: 'Workspace:', log, spawnImpl});
157
+ let voice;
158
+ if (!args.includes('--no-voice')) {
159
+ try { voice = await startChild(cli, ['voice', '--agents', '--port', String(Number(value(args, '--voice-port') ?? 4747))], {root, env: childEnv, label: 'Voice console:', log, spawnImpl}); }
160
+ catch (error) { log(`Voice console not started: ${error.message}`); }
161
+ }
162
+ mkdirSync(join(root, '.aether', 'local'), {recursive: true});
163
+ writeFileSync(statePath, JSON.stringify({workspace: workspace.url, ...(voice && {voice: voice.url}), startedAt: new Date().toISOString()}, null, 2) + '\n');
164
+ log(`\nAetherOS is running.\n Workspace: ${workspace.url}\n Voice console: ${voice?.url ?? '(not started)'} (Chrome or Edge, allow the microphone)\nPress Ctrl+C here to stop.`);
165
+ if (!noOpen) { open(workspace.url); if (voice) open(voice.url); }
166
+ const children = [workspace.child, voice?.child].filter(Boolean);
167
+ const stop = () => { for (const c of children) try { c.kill(); } catch {} };
168
+ if (!wait) return {workspace: workspace.url, voice: voice?.url, stop};
169
+ process.on('SIGINT', () => { stop(); process.exit(0); });
170
+ process.on('SIGTERM', () => { stop(); process.exit(0); });
171
+ await new Promise(resolveDone => workspace.child.on('exit', resolveDone));
172
+ stop();
173
+ return {workspace: workspace.url, voice: voice?.url};
174
+ }
175
+
176
+ function value(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }