@aetheros/cli 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/LICENSE +21 -0
- package/bin/aether.js +124 -0
- package/package.json +37 -4
- package/src/machine.js +41 -0
- package/src/setup.js +100 -0
- package/src/sprint.js +31 -0
- package/src/start.js +176 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hariprasath Thanarajah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/aether.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, basename } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { checkProject, installKit, KITS, gitHooksStatus, installGitHooks, uninstallGitHooks, GROUPS, addIdea, addOwnerItem, completeOwnerItem, decideIdea, ownerSummary, planIdea, readOwner } from '@aetheros/workflow';
|
|
6
|
+
import { installClaudeCodeTemplates } from '@aetheros/provider-claude';
|
|
7
|
+
import { installOpenAITemplates } from '@aetheros/provider-openai';
|
|
8
|
+
import { approveSpec, checkSpecRecords, evaluateApprovedSpecGate, linkSpec, listSpecs, specStatus } from '@aetheros/spec-kit';
|
|
9
|
+
const flag = (args, name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
10
|
+
const describe = s => `${s.spec}: ${s.approved ? 'approved by ' + s.approvedBy : s.stale ? 'changed since approval (' + s.changed.join(', ') + ')' : s.ready ? 'ready, not approved' : 'missing ' + s.missing.join(', ')}${s.feature ? ' | ' + s.feature : ''}${s.issue ? ' | #' + s.issue : ''}`;
|
|
11
|
+
import { checkMachine, setup } from '../src/setup.js';
|
|
12
|
+
import { runSprint } from '../src/sprint.js';
|
|
13
|
+
import { setupMachine } from '../src/machine.js';
|
|
14
|
+
import { createInterface } from 'node:readline/promises';
|
|
15
|
+
import { installMcpTemplates } from '@aetheros/provider-claude';
|
|
16
|
+
// Run only when started directly. Compare real paths: npm's bin folder and folders such as macOS /var are symlinks,
|
|
17
|
+
// while import.meta.url is always the real file.
|
|
18
|
+
const isMain = (() => { try { return !!process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
|
|
19
|
+
const INSTALLERS = { ...Object.fromEntries(Object.keys(KITS).map(k => [k, (root, o) => installKit(k, root, o)])), githooks: (root, o) => installGitHooks(root, o), claude: installClaudeCodeTemplates, openai: installOpenAITemplates, mcp: installMcpTemplates };
|
|
20
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
21
|
+
const CONFIG = 'aether.config.json';
|
|
22
|
+
export function run(args, cwd = process.cwd(), log = console.log) {
|
|
23
|
+
const [command] = args;
|
|
24
|
+
const file = resolve(cwd, CONFIG);
|
|
25
|
+
if (command === 'init') {
|
|
26
|
+
if (existsSync(file)) throw new Error('Configuration already exists; refusing to overwrite');
|
|
27
|
+
const config = {schemaVersion:1,project:{name:basename(cwd)},providers:['claude','openai'],approval:'writes'};
|
|
28
|
+
writeFileSync(file, JSON.stringify(config,null,2)+'\n', {flag:'wx'});
|
|
29
|
+
log('Created '+file);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command === 'version' || command === '--version') { log(VERSION); return; }
|
|
33
|
+
if (command === 'doctor' && args[1] === 'machine') {
|
|
34
|
+
const { ok, results } = checkMachine();
|
|
35
|
+
for (const r of results) log(`${r.ok ? 'ok ' : r.required ? 'MISSING ' : 'optional'} ${r.name}${r.version ? ' ' + r.version : ''}${r.hint && !r.ok ? ' — ' + r.hint : ''}`);
|
|
36
|
+
if (!ok) throw new Error('Required tools are missing');
|
|
37
|
+
log('This computer is ready.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (command === 'doctor' || command === 'status') {
|
|
41
|
+
if (!existsSync(file)) throw new Error('Missing aether.config.json; run aether init');
|
|
42
|
+
const config = JSON.parse(readFileSync(file,'utf8'));
|
|
43
|
+
if (config.schemaVersion !== 1 || !config.project || typeof config.project.name !== 'string' || !Array.isArray(config.providers) || !['always','writes','never'].includes(config.approval) || config.providers.some(p => !['claude','openai'].includes(p))) throw new Error('Invalid AetherOS configuration');
|
|
44
|
+
log(command === 'doctor' ? 'Configuration valid: '+config.project.name : JSON.stringify({project:config.project.name,providers:config.providers,approval:config.approval},null,2));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (command === 'install') {
|
|
48
|
+
const kits = args.slice(1).filter(a => !a.startsWith('--'));
|
|
49
|
+
const dryRun = args.includes('--dry-run');
|
|
50
|
+
if (!kits.length || kits.some(k => !INSTALLERS[k])) throw new Error('Usage: aether install <' + Object.keys(INSTALLERS).join('|') + '>... [--dry-run]');
|
|
51
|
+
for (const kit of kits) {
|
|
52
|
+
const { created, skipped } = INSTALLERS[kit](cwd, { dryRun });
|
|
53
|
+
for (const f of created) log((dryRun ? 'would create ' : 'created ') + f);
|
|
54
|
+
for (const f of skipped) log('kept existing ' + f);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (command === 'githooks') {
|
|
59
|
+
const sub = args[1] || 'status';
|
|
60
|
+
if (sub === 'install') { const r = installGitHooks(cwd, { force: args.includes('--force'), dryRun: args.includes('--dry-run') }); for (const f of r.created) log((r.dryRun ? 'would create ' : 'created ') + f); log((r.dryRun ? 'would set' : 'set') + ' core.hooksPath to ' + r.hooksPath); return; }
|
|
61
|
+
if (sub === 'uninstall') { log(uninstallGitHooks(cwd).removed ? 'AetherOS git hooks turned off (files kept)' : 'AetherOS git hooks were not active'); return; }
|
|
62
|
+
if (sub === 'status') { const s = gitHooksStatus(cwd); log(s.installed ? 'AetherOS git hooks are active' : 'AetherOS git hooks are not active' + (s.hooksPath ? ` (core.hooksPath is ${s.hooksPath})` : '')); return; }
|
|
63
|
+
throw new Error('Usage: aether githooks <install [--force] [--dry-run]|uninstall|status>');
|
|
64
|
+
}
|
|
65
|
+
if (command === 'owner') {
|
|
66
|
+
const [, sub, ...rest] = args;
|
|
67
|
+
const words = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--')));
|
|
68
|
+
if (sub === 'add') { const item = addOwnerItem(cwd, { group: words[0], task: words.slice(1).join(' '), detail: flag(args, '--detail'), link: flag(args, '--link'), source: flag(args, '--source') }); log(`added ${item.id}: [${item.group}] ${item.task}`); return; }
|
|
69
|
+
if (sub === 'done') { const item = completeOwnerItem(cwd, words[0], { note: flag(args, '--note') }); log(`done ${item.id}: ${item.task}`); return; }
|
|
70
|
+
if (sub === 'list' || !sub) {
|
|
71
|
+
if (args.includes('--all')) { for (const i of readOwner(cwd).items) log(`${i.id} ${i.status.padEnd(4)} [${i.group}] ${i.task}`); return; }
|
|
72
|
+
const s = ownerSummary(cwd);
|
|
73
|
+
if (!s.openCount) log('Nothing is waiting on the owner.');
|
|
74
|
+
for (const g of s.open) { log(g.group + ':'); for (const i of g.items) log(` ${i.id} ${i.task}${i.link ? ' — ' + i.link : ''}`); }
|
|
75
|
+
if (s.toDecide.length) log(`Ideas to decide: ${s.toDecide.length} (aether idea list)`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Usage: aether owner <list [--all]|add <' + GROUPS.join('|') + '> <task> [--detail ..] [--link ..]|done <id> [--note ..]>');
|
|
79
|
+
}
|
|
80
|
+
if (command === 'idea') {
|
|
81
|
+
const [, sub, ...rest] = args;
|
|
82
|
+
const words = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--')));
|
|
83
|
+
if (sub === 'add') { const idea = addIdea(cwd, { title: words.join(' '), why: flag(args, '--why') }); log(`added idea ${idea.id}: ${idea.title} (waiting for the owner's decision)`); return; }
|
|
84
|
+
if (sub === 'approve' || sub === 'reject') { const idea = decideIdea(cwd, words[0], sub === 'approve' ? 'Approved' : 'Rejected', { note: flag(args, '--note') }); log(`${idea.status.toLowerCase()}: ${idea.title}`); return; }
|
|
85
|
+
if (sub === 'plan') { const idea = planIdea(cwd, words[0], { issue: Number(flag(args, '--issue')) }); log(`planned: ${idea.title} → #${idea.issue}`); return; }
|
|
86
|
+
if (sub === 'list' || !sub) { const { ideas } = readOwner(cwd); if (!ideas.length) log('No ideas yet.'); for (const i of ideas) log(`${i.id} ${i.status.padEnd(9)} ${i.title}${i.issue ? ' #' + i.issue : ''}`); return; }
|
|
87
|
+
throw new Error('Usage: aether idea <list|add <title> [--why ..]|approve <id>|reject <id>|plan <id> --issue <n>>');
|
|
88
|
+
}
|
|
89
|
+
if (command === 'spec') {
|
|
90
|
+
const [, sub, path] = args;
|
|
91
|
+
if (sub === 'list') { const all = listSpecs(cwd); if (!all.length) log('no specs/ directories'); for (const s of all) log(describe(s)); return; }
|
|
92
|
+
if (!path || !['status', 'link', 'approve', 'gate'].includes(sub)) throw new Error('Usage: aether spec <list|status <path>|link <path> [--feature <dir>] [--issue <n>]|approve <path> --by <name>|gate <path>>');
|
|
93
|
+
if (sub === 'status') { log(describe(specStatus(cwd, path))); return; }
|
|
94
|
+
if (sub === 'link') { const issue = flag(args, '--issue'); linkSpec(cwd, path, { feature: flag(args, '--feature'), issue: issue === undefined ? undefined : Number(issue) }); log(describe(specStatus(cwd, path))); return; }
|
|
95
|
+
if (sub === 'approve') { approveSpec(cwd, path, { approver: flag(args, '--by') }); log(describe(specStatus(cwd, path))); return; }
|
|
96
|
+
const { ok, reasons } = evaluateApprovedSpecGate(cwd, path);
|
|
97
|
+
if (!ok) throw new Error('Spec gate closed for ' + path + ': ' + reasons.join('; '));
|
|
98
|
+
log('spec gate open: ' + path);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (command === 'check') {
|
|
102
|
+
const specProblems = checkSpecRecords(cwd).problems;
|
|
103
|
+
for (const p of specProblems) log('problem: ' + p);
|
|
104
|
+
const { ok, problems } = checkProject(cwd);
|
|
105
|
+
for (const p of problems) log('problem: ' + p);
|
|
106
|
+
if (!ok || specProblems.length) throw new Error(problems.length + specProblems.length + ' workflow problem(s)');
|
|
107
|
+
log('workflow docs ok');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
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
|
+
}
|
|
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') {
|
|
115
|
+
runSprint(process.argv.slice(3), process.cwd(), console.log).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
116
|
+
} else if (isMain && process.argv[2] === 'setup') {
|
|
117
|
+
(process.argv[3] === 'machine'
|
|
118
|
+
? (async () => { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { await setupMachine({ install: process.argv.includes('--install'), yes: process.argv.includes('--yes'), ask: q => rl.question(q), log: console.log }); } finally { rl.close(); } })()
|
|
119
|
+
: setup(process.argv.slice(3), process.cwd(), console.log, { installers: INSTALLERS })).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
120
|
+
} else if (isMain && ['run', 'room', 'voice', 'workspace', 'models'].includes(process.argv[2])) {
|
|
121
|
+
import({ run: '@aetheros/runner/cli', room: '@aetheros/control-room/cli', voice: '@aetheros/voice/cli', workspace: '@aetheros/workspace/cli', models: '@aetheros/models/cli' }[process.argv[2]]).then(m => m.run(process.argv.slice(3))).then(code => { process.exitCode = code; }, error => { console.error(error.message); process.exitCode = 1; });
|
|
122
|
+
} else if (isMain) {
|
|
123
|
+
try { run(process.argv.slice(2)); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aetheros/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Abigshana/AetherOS.git",
|
|
8
|
+
"directory": "packages/cli"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"aether": "./bin/aether.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
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
|
+
"test": "node --test test/*.test.mjs"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
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
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"dist",
|
|
34
|
+
"bin",
|
|
35
|
+
"templates",
|
|
36
|
+
"kits",
|
|
37
|
+
"README.md"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/machine.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {checkMachine} from './setup.js';
|
|
3
|
+
|
|
4
|
+
/** Install commands per platform. Linux is listed only (package managers and sudo differ), never run automatically. */
|
|
5
|
+
const PLANS = {
|
|
6
|
+
win32: {git: ['winget', ['install', '-e', '--id', 'Git.Git']], gh: ['winget', ['install', '-e', '--id', 'GitHub.cli']], code: ['winget', ['install', '-e', '--id', 'Microsoft.VisualStudioCode']], node: ['winget', ['install', '-e', '--id', 'OpenJS.NodeJS.LTS']]},
|
|
7
|
+
darwin: {git: ['brew', ['install', 'git']], gh: ['brew', ['install', 'gh']], code: ['brew', ['install', '--cask', 'visual-studio-code']], node: ['brew', ['install', 'node']]},
|
|
8
|
+
linux: {git: ['sudo', ['apt-get', 'install', '-y', 'git']], gh: ['sudo', ['apt-get', 'install', '-y', 'gh']], code: ['sudo', ['snap', 'install', 'code', '--classic']], node: ['sudo', ['apt-get', 'install', '-y', 'nodejs']]}
|
|
9
|
+
};
|
|
10
|
+
const CLAUDE = ['npm', ['install', '-g', '@anthropic-ai/claude-code']];
|
|
11
|
+
const EXTENSION = ['code', ['--install-extension', 'anthropic.claude-code']];
|
|
12
|
+
|
|
13
|
+
export function machinePlan({platform = process.platform, check = checkMachine()} = {}) {
|
|
14
|
+
const table = PLANS[platform] ?? {};
|
|
15
|
+
const steps = [];
|
|
16
|
+
for (const r of check.results) {
|
|
17
|
+
if (r.ok || r.name === 'npm') continue;
|
|
18
|
+
const cmd = r.name === 'claude' ? CLAUDE : table[r.name];
|
|
19
|
+
if (cmd) steps.push({tool: r.name, command: cmd[0], args: cmd[1], required: r.required});
|
|
20
|
+
}
|
|
21
|
+
const codeReady = check.results.find(r => r.name === 'code')?.ok || steps.some(s => s.tool === 'code');
|
|
22
|
+
if (codeReady) steps.push({tool: 'claude-code-extension', command: EXTENSION[0], args: EXTENSION[1], required: false});
|
|
23
|
+
return {platform, automatic: platform === 'win32' || platform === 'darwin', steps};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Show the plan; with install: true run each step after asking (or all with yes: true). Never runs on Linux. */
|
|
27
|
+
export async function setupMachine({install = false, yes = false, ask, log, platform, check, run = (cmd, args) => execFileSync(cmd, args, {stdio: 'inherit', shell: process.platform === 'win32'})} = {}) {
|
|
28
|
+
const plan = machinePlan({platform, check});
|
|
29
|
+
if (!plan.steps.length) { log('Nothing to install: this computer has every tool AetherOS uses.'); return {plan, ran: []}; }
|
|
30
|
+
for (const s of plan.steps) log(`${s.required ? 'needed ' : 'optional'} ${s.tool}: ${s.command} ${s.args.join(' ')}`);
|
|
31
|
+
if (!install) { log('Preview only. Run `aether setup machine --install` to install (you will be asked for each).'); return {plan, ran: []}; }
|
|
32
|
+
if (!plan.automatic) { log('Automatic install is only offered on Windows and macOS; run the commands above yourself.'); return {plan, ran: []}; }
|
|
33
|
+
const ran = [];
|
|
34
|
+
for (const s of plan.steps) {
|
|
35
|
+
if (!yes && !String(await ask(`Install ${s.tool}? [y/N] `)).trim().toLowerCase().startsWith('y')) continue;
|
|
36
|
+
run(s.command, s.args);
|
|
37
|
+
ran.push(s.tool);
|
|
38
|
+
}
|
|
39
|
+
log(ran.length ? `Installed: ${ran.join(', ')}. Open a new terminal, then run \`aether doctor machine\`.` : 'Nothing installed.');
|
|
40
|
+
return {plan, ran};
|
|
41
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {readBrand, writeBrand} from '@aetheros/workflow';
|
|
4
|
+
import {basename, resolve} from 'node:path';
|
|
5
|
+
import {createInterface} from 'node:readline/promises';
|
|
6
|
+
|
|
7
|
+
const KIT_ORDER = ['github', 'memory', 'claude', 'openai', 'githooks', 'mcp'];
|
|
8
|
+
const ABOUT = {
|
|
9
|
+
github: 'GitHub issue and pull request templates',
|
|
10
|
+
memory: 'Handover, roadmap, feature folders, workflow settings',
|
|
11
|
+
claude: 'Claude Code agents, skills and guard hooks',
|
|
12
|
+
openai: 'AGENTS.md for OpenAI tooling',
|
|
13
|
+
githooks: 'Git hooks guard rails for every tool and person',
|
|
14
|
+
mcp: 'Claude Code MCP servers (Playwright browser)'
|
|
15
|
+
};
|
|
16
|
+
const isGitRepo = root => { try { execFileSync('git', ['rev-parse', '--git-dir'], {cwd: root, stdio: 'ignore'}); return true; } catch { return false; } };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Guided project setup. Non-interactive with --yes (defaults) or --kits a,b,c; otherwise asks one question per kit.
|
|
20
|
+
* installers: {kit: (root, {dryRun}) => {created, skipped}}. Never overwrites existing files.
|
|
21
|
+
*/
|
|
22
|
+
export async function setup(args, cwd, log, {installers, ask, interactive = Boolean(process.stdin.isTTY)} = {}) {
|
|
23
|
+
const root = resolve(cwd);
|
|
24
|
+
const dryRun = args.includes('--dry-run');
|
|
25
|
+
const git = isGitRepo(root);
|
|
26
|
+
const defaults = KIT_ORDER.filter(k => k !== 'mcp' && (k !== 'githooks' || git));
|
|
27
|
+
const listed = args.includes('--kits') ? (args[args.indexOf('--kits') + 1] || '').split(',').map(s => s.trim()).filter(Boolean) : null;
|
|
28
|
+
if (listed && listed.some(k => !installers[k])) throw new Error('Unknown kit in --kits; available: ' + KIT_ORDER.join(', '));
|
|
29
|
+
if (listed?.includes('githooks') && !git) throw new Error('githooks needs a git repository (run git init first)');
|
|
30
|
+
let kits = listed ?? defaults;
|
|
31
|
+
if (!listed && !args.includes('--yes') && interactive) {
|
|
32
|
+
const rl = ask ? null : createInterface({input: process.stdin, output: process.stdout});
|
|
33
|
+
const question = ask ?? (q => rl.question(q));
|
|
34
|
+
kits = [];
|
|
35
|
+
try {
|
|
36
|
+
for (const kit of KIT_ORDER) {
|
|
37
|
+
if (kit === 'githooks' && !git) continue;
|
|
38
|
+
const def = defaults.includes(kit);
|
|
39
|
+
const answer = String(await question(`Install ${kit} (${ABOUT[kit]})? ${def ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase();
|
|
40
|
+
if (answer ? answer.startsWith('y') : def) kits.push(kit);
|
|
41
|
+
}
|
|
42
|
+
} finally { rl?.close(); }
|
|
43
|
+
}
|
|
44
|
+
// Brand: the product's OS name, assistant name and accent colour (e.g. "Nova OS", "Nova").
|
|
45
|
+
const flag = name => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
46
|
+
let brand = readBrand(root);
|
|
47
|
+
let osName = flag('--os-name'), assistantName = flag('--assistant'), accent = flag('--accent');
|
|
48
|
+
if (!brand && !osName && !args.includes('--yes') && interactive) {
|
|
49
|
+
const rl = ask ? null : createInterface({input: process.stdin, output: process.stdout});
|
|
50
|
+
const question = ask ?? (q => rl.question(q));
|
|
51
|
+
try {
|
|
52
|
+
osName = String(await question(`What is your OS called? [${basename(root)} OS] `)).trim() || `${basename(root)} OS`;
|
|
53
|
+
assistantName = String(await question('What is your assistant called? [Aevron] ')).trim() || 'Aevron';
|
|
54
|
+
accent = String(await question('Accent colour (#rrggbb, empty for default)? ')).trim() || undefined;
|
|
55
|
+
} finally { rl?.close(); }
|
|
56
|
+
}
|
|
57
|
+
if (osName || assistantName) {
|
|
58
|
+
const next = {osName: osName || brand?.osName || `${basename(root)} OS`, assistantName: assistantName || brand?.assistantName || 'Aevron', theme: {...(brand?.theme ?? {}), ...(accent && {accent})}};
|
|
59
|
+
if (!dryRun) brand = writeBrand(root, next);
|
|
60
|
+
log(`${dryRun ? 'would brand' : 'branded'}: ${next.osName} · assistant ${next.assistantName}`);
|
|
61
|
+
}
|
|
62
|
+
const hadClaudeMd = existsSync(resolve(root, 'CLAUDE.md'));
|
|
63
|
+
const config = resolve(root, 'aether.config.json');
|
|
64
|
+
if (!existsSync(config)) {
|
|
65
|
+
if (!dryRun) writeFileSync(config, JSON.stringify({schemaVersion: 1, project: {name: basename(root)}, providers: ['claude', 'openai'], approval: 'writes'}, null, 2) + '\n', {flag: 'wx'});
|
|
66
|
+
log((dryRun ? 'would create ' : 'created ') + 'aether.config.json');
|
|
67
|
+
}
|
|
68
|
+
for (const kit of kits) {
|
|
69
|
+
const {created, skipped} = installers[kit](root, {dryRun});
|
|
70
|
+
log(`${kit}: ${created.length} ${dryRun ? 'to create' : 'created'}, ${skipped.length} kept`);
|
|
71
|
+
}
|
|
72
|
+
if (brand && !dryRun && !hadClaudeMd && existsSync(resolve(root, 'CLAUDE.md'))) {
|
|
73
|
+
const file = resolve(root, 'CLAUDE.md');
|
|
74
|
+
writeFileSync(file, readFileSync(file, 'utf8').replace(/^# Project instructions/m, `# ${brand.osName} — project instructions\n\nThe AI assistant for this project is called ${brand.assistantName}.`));
|
|
75
|
+
}
|
|
76
|
+
log('Next: `aether doctor machine` (tools on this computer), `aether check`, `aether room`, `aether run init packs/<app>`.');
|
|
77
|
+
return kits;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const TOOLS = [
|
|
81
|
+
{name: 'node', args: ['--version'], required: true, min: 20, hint: 'https://nodejs.org (version 20 or newer)'},
|
|
82
|
+
{name: 'git', args: ['--version'], required: true, hint: 'https://git-scm.com'},
|
|
83
|
+
{name: 'npm', args: ['--version'], required: true, hint: 'comes with Node.js'},
|
|
84
|
+
{name: 'gh', args: ['--version'], hint: 'https://cli.github.com (GitHub from the terminal)'},
|
|
85
|
+
{name: 'claude', args: ['--version'], hint: 'https://claude.com/claude-code (Claude Code CLI)'},
|
|
86
|
+
{name: 'code', args: ['--version'], hint: 'https://code.visualstudio.com (for the Control Room panel)'}
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** Check the tools AetherOS uses on this computer. Never installs anything. */
|
|
90
|
+
export function checkMachine({run = (cmd, args) => execFileSync(cmd, args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, shell: process.platform === 'win32'}), nodeVersion = process.versions.node} = {}) {
|
|
91
|
+
const results = TOOLS.map(tool => {
|
|
92
|
+
try {
|
|
93
|
+
const out = tool.name === 'node' ? 'v' + nodeVersion : run(tool.name, tool.args);
|
|
94
|
+
const version = (/\d+\.\d+(\.\d+)?/.exec(out) || [''])[0];
|
|
95
|
+
const tooOld = tool.min && Number(version.split('.')[0]) < tool.min;
|
|
96
|
+
return {name: tool.name, ok: !tooOld, required: Boolean(tool.required), version, hint: tooOld ? tool.hint : undefined};
|
|
97
|
+
} catch { return {name: tool.name, ok: false, required: Boolean(tool.required), hint: tool.hint}; }
|
|
98
|
+
});
|
|
99
|
+
return {ok: results.every(r => r.ok || !r.required), results};
|
|
100
|
+
}
|
package/src/sprint.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {closeSprint, githubClient, planSprint, repoFromGit, sprintStatus} from '@aetheros/workflow';
|
|
2
|
+
|
|
3
|
+
const value = (args, name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
4
|
+
|
|
5
|
+
/** aether sprint status | plan --title "Sprint N — goal" --issues 1,2 [--due YYYY-MM-DD] | close [--next <milestone>] */
|
|
6
|
+
export async function runSprint(args, cwd, log, {token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN, fetchImpl} = {}) {
|
|
7
|
+
const sub = args[0] || 'status';
|
|
8
|
+
const repo = value(args, '--repo') || repoFromGit(cwd);
|
|
9
|
+
if (!repo) throw new Error('Could not find the GitHub repository; pass --repo owner/name');
|
|
10
|
+
const client = githubClient({token, fetchImpl});
|
|
11
|
+
if (sub === 'status') {
|
|
12
|
+
const s = await sprintStatus(client, repo);
|
|
13
|
+
if (!s) { log('No open sprint. Plan one with: aether sprint plan --title "Sprint 1 — goal" --issues 1,2,3'); return; }
|
|
14
|
+
log(`${s.title}${s.dueOn ? ' (due ' + s.dueOn.slice(0, 10) + ')' : ''}: ${s.points.done} points done, ${s.points.open} open`);
|
|
15
|
+
for (const i of s.open) log(` #${i.number} ${i.title}${i.points ? ` [${i.points}]` : ''}${i.blocked ? ' — waiting on the owner' : ''}`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (sub === 'plan') {
|
|
19
|
+
const issues = (value(args, '--issues') || '').split(',').map(n => Number(n.trim().replace(/^#/, ''))).filter(Boolean);
|
|
20
|
+
const r = await planSprint(client, repo, {title: value(args, '--title'), dueOn: value(args, '--due'), issues});
|
|
21
|
+
log(`created ${r.title} (milestone ${r.number}) with ${r.issues.length} issue(s), ${r.points} points`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (sub === 'close') {
|
|
25
|
+
const next = value(args, '--next');
|
|
26
|
+
const r = await closeSprint(client, repo, {next: next ? Number(next) : undefined});
|
|
27
|
+
log(`closed ${r.title}: ${r.points.done} points done; moved ${r.moved.length} open issue(s) to ${r.movedTo === 'backlog' ? 'the backlog' : 'milestone ' + r.movedTo}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw new Error('Usage: aether sprint <status|plan --title "Sprint N — goal" --issues 1,2 [--due YYYY-MM-DD]|close [--next <milestone number>]> [--repo owner/name]');
|
|
31
|
+
}
|
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; }
|
package/README.md
DELETED