@aetheros/cli 0.1.0 → 0.1.2
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/bin/aether.js +32 -4
- package/package.json +13 -13
- package/src/adopt.js +144 -0
- package/src/setup.js +6 -3
- package/src/start.js +176 -0
package/bin/aether.js
CHANGED
|
@@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { checkProject, installKit, KITS, gitHooksStatus, installGitHooks, uninstallGitHooks, GROUPS, addIdea, addOwnerItem, completeOwnerItem, decideIdea, ownerSummary, planIdea, readOwner } from '@aetheros/workflow';
|
|
6
6
|
import { installClaudeCodeTemplates } from '@aetheros/provider-claude';
|
|
7
7
|
import { installOpenAITemplates } from '@aetheros/provider-openai';
|
|
8
|
-
import { approveSpec, checkSpecRecords, evaluateApprovedSpecGate, linkSpec, listSpecs, specStatus } from '@aetheros/
|
|
8
|
+
import { approveSpec, checkSpecRecords, createSpec, evaluateApprovedSpecGate, linkSpec, listSpecs, specStatus } from '@aetheros/specs';
|
|
9
9
|
const flag = (args, name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
10
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
11
|
import { checkMachine, setup } from '../src/setup.js';
|
|
@@ -89,7 +89,8 @@ export function run(args, cwd = process.cwd(), log = console.log) {
|
|
|
89
89
|
if (command === 'spec') {
|
|
90
90
|
const [, sub, path] = args;
|
|
91
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 (
|
|
92
|
+
if (sub === 'new') { log('created ' + createSpec(cwd, args.slice(2).filter(a => !a.startsWith('--')).join(' '))); return; }
|
|
93
|
+
if (!path || !['status', 'link', 'approve', 'gate'].includes(sub)) throw new Error('Usage: aether spec <list|new <name>|status <path>|link <path> [--feature <dir>] [--issue <n>]|approve <path> --by <name>|gate <path>>');
|
|
93
94
|
if (sub === 'status') { log(describe(specStatus(cwd, path))); return; }
|
|
94
95
|
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
96
|
if (sub === 'approve') { approveSpec(cwd, path, { approver: flag(args, '--by') }); log(describe(specStatus(cwd, path))); return; }
|
|
@@ -107,9 +108,36 @@ export function run(args, cwd = process.cwd(), log = console.log) {
|
|
|
107
108
|
log('workflow docs ok');
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
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]]>');
|
|
111
|
+
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 ...|step <id|reset> [note]|notice <text>|adopt [--dry-run] [--departments <file>]|autonomy [status|defaults|on|off]|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
|
}
|
|
112
|
-
if (isMain && process.argv[2] === '
|
|
113
|
+
if (isMain && process.argv[2] === 'start') {
|
|
114
|
+
import('../src/start.js').then(m => m.start(process.argv.slice(3), process.cwd())).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
115
|
+
} else if (isMain && process.argv[2] === 'step') {
|
|
116
|
+
// aether step build "started the search page" | aether step reset (the command centre's build track)
|
|
117
|
+
const [id, ...note] = process.argv.slice(3);
|
|
118
|
+
import('@aetheros/workspace').then(m => { if (!id) throw new Error('Usage: aether step <step id|reset> [short note]'); m.recordStep(process.cwd(), { step: id, note: note.join(' '), session: 'cli' }); console.log(id === 'reset' ? 'build track reset' : `step reached: ${id}`); }).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
119
|
+
} else if (isMain && process.argv[2] === 'autonomy') {
|
|
120
|
+
// aether autonomy [status|defaults|on|off]: standing approvals (.aether/autonomy.json). Without the file, everything waits in Review.
|
|
121
|
+
import('@aetheros/workspace').then(m => {
|
|
122
|
+
const sub = process.argv[3] || 'status', root = process.cwd();
|
|
123
|
+
if (sub === 'defaults') { m.setAutonomy(root, { defaults: true }); console.log('wrote .aether/autonomy.json with the recommended standing approvals (handover and docs, owner list, notes, issues)'); return; }
|
|
124
|
+
if (sub === 'on' || sub === 'off') { m.setAutonomy(root, { enabled: sub === 'on' }); console.log('standing approvals are ' + sub); return; }
|
|
125
|
+
if (sub !== 'status') throw new Error('Usage: aether autonomy <status|defaults|on|off>');
|
|
126
|
+
const s = m.createAutonomy(root).status();
|
|
127
|
+
if (s.error) throw new Error('autonomy.json: ' + s.error);
|
|
128
|
+
if (!s.present) { console.log('Nothing is automatic. Everything waits in Review. (aether autonomy defaults writes the recommended starting point.)'); return; }
|
|
129
|
+
console.log('standing approvals are ' + (s.enabled ? 'on' : 'off'));
|
|
130
|
+
for (const r of s.rules) console.log(' ' + (r.enabled ? 'on ' : 'off') + ' ' + r.id + ' (' + r.tool + (r.paths ? ': ' + r.paths.join(', ') : '') + ') today ' + r.usedToday + ' of ' + r.maxPerDay);
|
|
131
|
+
console.log('always asks you: ' + s.neverAutomatic.join(', '));
|
|
132
|
+
}).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
133
|
+
} else if (isMain && process.argv[2] === 'adopt') {
|
|
134
|
+
// aether adopt [--dry-run] [--departments <file>]: map an existing .claude/agents setup onto the command centre (writes only new files under .aether/)
|
|
135
|
+
import('../src/adopt.js').then(m => m.adopt(process.argv.slice(3), process.cwd(), console.log)).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
136
|
+
} else if (isMain && process.argv[2] === 'notice') {
|
|
137
|
+
// aether notice "New 1-star review on the reviews page" (shown as "Just happened" in the command centre for 15 minutes)
|
|
138
|
+
const text = process.argv.slice(3).join(' ');
|
|
139
|
+
import('@aetheros/workspace').then(m => { m.recordNotice(process.cwd(), text, { session: 'cli' }); console.log('notice recorded'); }).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
140
|
+
} else if (isMain && process.argv[2] === 'sprint') {
|
|
113
141
|
runSprint(process.argv.slice(3), process.cwd(), console.log).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
114
142
|
} else if (isMain && process.argv[2] === 'setup') {
|
|
115
143
|
(process.argv[3] === 'machine'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aetheros/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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.
|
|
20
|
-
"@aetheros/provider-claude": "0.1.
|
|
21
|
-
"@aetheros/provider-openai": "0.1.
|
|
22
|
-
"@aetheros/
|
|
23
|
-
"@aetheros/runner": "0.1.
|
|
24
|
-
"@aetheros/control-room": "0.1.
|
|
25
|
-
"@aetheros/voice": "0.1.
|
|
26
|
-
"@aetheros/workspace": "0.1.
|
|
27
|
-
"@aetheros/models": "0.1.
|
|
28
|
-
"@aetheros/provider-google": "0.1.
|
|
29
|
-
"@aetheros/provider-openai-compatible": "0.1.
|
|
19
|
+
"@aetheros/workflow": "0.1.2",
|
|
20
|
+
"@aetheros/provider-claude": "0.1.2",
|
|
21
|
+
"@aetheros/provider-openai": "0.1.2",
|
|
22
|
+
"@aetheros/specs": "0.1.2",
|
|
23
|
+
"@aetheros/runner": "0.1.2",
|
|
24
|
+
"@aetheros/control-room": "0.1.2",
|
|
25
|
+
"@aetheros/voice": "0.1.2",
|
|
26
|
+
"@aetheros/workspace": "0.1.2",
|
|
27
|
+
"@aetheros/models": "0.1.2",
|
|
28
|
+
"@aetheros/provider-google": "0.1.2",
|
|
29
|
+
"@aetheros/provider-openai-compatible": "0.1.2"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"src",
|
package/src/adopt.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {basename, dirname, isAbsolute, join, relative, resolve} from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {nextSteps, readRules, skillsMentioning, validateAgent} from '@aetheros/workspace';
|
|
5
|
+
|
|
6
|
+
const DEPARTMENT_FILES = ['departments.json', 'docs/departments.json', 'config/departments.json', '.claude/departments.json'];
|
|
7
|
+
const READ_TOOLS = ['project.handover', 'project.read_file', 'project.list_files', 'project.search', 'git.status'];
|
|
8
|
+
const TOOL_MAP = {Read: ['project.read_file'], Grep: ['project.search'], Glob: ['project.list_files'], LS: ['project.list_files'], Write: ['project.write_file'], Edit: ['project.write_file'], MultiEdit: ['project.write_file'], NotebookEdit: ['project.write_file']};
|
|
9
|
+
const TIERS = {opus: 'strong', sonnet: 'balanced', haiku: 'cheap'};
|
|
10
|
+
const COLORS = ['#4a5bd8', '#0f8a7a', '#b25e09', '#8a3fb5', '#c2410c', '#0369a1', '#4d7c0f', '#be185d'];
|
|
11
|
+
const DEFAULT_GROUPS = [['plan', 'Plan', ['planner', 'architect']], ['build', 'Build', ['implementer', 'devops']], ['check', 'Check', ['reviewer', 'security-reviewer', 'screens']], ['ship', 'Ship', ['release', 'docs-writer']]];
|
|
12
|
+
|
|
13
|
+
const slug = s => { const t = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); return /^[a-z]/.test(t) ? t : (t ? 'a-' + t : ''); };
|
|
14
|
+
const hash = file => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
15
|
+
const inside = (root, file) => { const rel = relative(root, resolve(root, file)); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); };
|
|
16
|
+
|
|
17
|
+
/** `---` frontmatter with one `key: value` per line (what Claude Code agent files use), and the text after it. */
|
|
18
|
+
export function parseAgentFile(text) {
|
|
19
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
20
|
+
const meta = {};
|
|
21
|
+
if (m) for (const line of m[1].split(/\r?\n/)) { const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line); if (kv) meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, ''); }
|
|
22
|
+
return {meta, body: (m ? m[2] : text).trim()};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A Claude Code agent as a workspace agent: its tools are mapped to the workspace's, writes still wait in Review, and nothing is granted by silence. */
|
|
26
|
+
export function toWorkspaceAgent(file, text) {
|
|
27
|
+
const {meta, body} = parseAgentFile(text);
|
|
28
|
+
const name = String(meta.name || basename(file, '.md')).replace(/[<>]/g, '').trim().slice(0, 40);
|
|
29
|
+
const id = slug(name);
|
|
30
|
+
if (!id) throw new Error('no usable name');
|
|
31
|
+
const requested = meta.tools ? meta.tools.split(',').map(t => t.trim()).filter(Boolean) : null;
|
|
32
|
+
const tools = requested ? [...new Set(requested.flatMap(t => TOOL_MAP[t] ?? []))] : [...READ_TOOLS];
|
|
33
|
+
if (requested && !tools.includes('project.handover')) tools.unshift('project.handover');
|
|
34
|
+
const unmapped = requested ? requested.filter(t => !TOOL_MAP[t]) : [];
|
|
35
|
+
const description = (meta.description || '').slice(0, 200);
|
|
36
|
+
const instructions = (body || description || 'Help with this project.').slice(0, 8000);
|
|
37
|
+
const tier = TIERS[String(meta.model || '').toLowerCase()];
|
|
38
|
+
const agent = validateAgent({schemaVersion: 1, id, name, ...(description && {description}), instructions, provider: 'claude', ...(tier && {tier}), tools, color: COLORS[parseInt(createHash('sha256').update(id).digest('hex').slice(0, 4), 16) % COLORS.length]});
|
|
39
|
+
return {agent, unmapped, truncated: body.length > 8000};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read a department file of the shape {departments: [{id, name, agents: [...]}], brains?: {...}} and keep only plain values. */
|
|
43
|
+
export function readDepartmentFile(file) {
|
|
44
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
45
|
+
if (!Array.isArray(value?.departments)) throw new Error('needs a "departments" list');
|
|
46
|
+
const text = (v, n) => typeof v === 'string' && v.trim() && v.length <= n && !/[<>]/.test(v);
|
|
47
|
+
const departments = value.departments.slice(0, 20).filter(d => d && text(d.name, 40) && Array.isArray(d.agents)).map(d => ({id: text(d.id, 40) ? d.id : slug(d.name), name: d.name.trim(), agents: d.agents.filter(a => text(a, 40)).slice(0, 30)}));
|
|
48
|
+
if (!departments.length) throw new Error('no usable departments');
|
|
49
|
+
const brains = value.brains && typeof value.brains === 'object' && !Array.isArray(value.brains) ? Object.fromEntries(Object.entries(value.brains).filter(([k, v]) => text(k, 80) && /^[\w .-]{1,20}$/.test(String(v))).slice(0, 30)) : undefined;
|
|
50
|
+
return {departments, ...(brains && Object.keys(brains).length && {brains})};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `aether adopt`: for a project that already has its own agents, skills, department file and hooks. Reads them and writes the matching
|
|
55
|
+
* `.aether/` files for the command centre. Never changes the project's own files and never overwrites an `.aether/` file that exists.
|
|
56
|
+
* Options: --dry-run (change nothing), --departments <file> (a department file anywhere inside the project).
|
|
57
|
+
*/
|
|
58
|
+
export async function adopt(args, cwd, log) {
|
|
59
|
+
const root = resolve(cwd);
|
|
60
|
+
const dryRun = args.includes('--dry-run');
|
|
61
|
+
const flag = name => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
62
|
+
const wrote = [], kept = [], missing = [], notes = [];
|
|
63
|
+
const before = new Map();
|
|
64
|
+
const watch = rel => { const f = join(root, rel); if (existsSync(f) && statSync(f).isFile()) before.set(rel, hash(f)); };
|
|
65
|
+
const put = (rel, value) => {
|
|
66
|
+
const file = join(root, rel);
|
|
67
|
+
if (existsSync(file)) { kept.push(rel); return false; }
|
|
68
|
+
if (!dryRun) { mkdirSync(dirname(file), {recursive: true}); writeFileSync(file, JSON.stringify(value, null, 2) + '\n', {flag: 'wx'}); }
|
|
69
|
+
wrote.push(rel);
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
log(`${dryRun ? 'Looking at' : 'Adopting'} ${basename(root)} (your own files are only read; new files go under .aether/${dryRun ? ', nothing is written in this dry run' : ''})`);
|
|
73
|
+
|
|
74
|
+
// 1. Agents
|
|
75
|
+
const agentsDir = join(root, '.claude/agents');
|
|
76
|
+
const found = [];
|
|
77
|
+
let total = 0;
|
|
78
|
+
if (existsSync(agentsDir)) {
|
|
79
|
+
for (const name of readdirSync(agentsDir).filter(n => n.endsWith('.md')).sort()) {
|
|
80
|
+
total++;
|
|
81
|
+
watch(`.claude/agents/${name}`);
|
|
82
|
+
try { found.push({file: name, ...toWorkspaceAgent(name, readFileSync(join(agentsDir, name), 'utf8'))}); }
|
|
83
|
+
catch (error) { notes.push(`skipped .claude/agents/${name}: ${String(error.message).slice(0, 120)}`); }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let agentsWritten = 0;
|
|
87
|
+
for (const a of found) {
|
|
88
|
+
if (put(`.aether/agents/${a.agent.id}.json`, {schemaVersion: 1, ...a.agent})) agentsWritten++;
|
|
89
|
+
if (a.unmapped.length) notes.push(`${a.agent.name}: tools with no workspace equivalent left out (${a.unmapped.join(', ')})`);
|
|
90
|
+
if (a.truncated) notes.push(`${a.agent.name}: instructions cut to 8000 characters`);
|
|
91
|
+
}
|
|
92
|
+
log(found.length ? `agents: ${total} in .claude/agents${total !== found.length ? ` (${total - found.length} skipped, see notes)` : ''} → ${agentsWritten} ${dryRun ? 'to write' : 'written'} as .aether/agents/*.json, ${found.length - agentsWritten} already there and kept (writes by these agents wait in Review; no tool is granted that the agent file did not list)` : 'agents: none found in .claude/agents');
|
|
93
|
+
if (!found.length) missing.push('No agents: run `aether install claude` to add the starter agents, or `aether workspace init`.');
|
|
94
|
+
|
|
95
|
+
// 2. Departments
|
|
96
|
+
let source = flag('--departments');
|
|
97
|
+
if (source && !inside(root, source)) throw new Error('--departments must be a file inside the project');
|
|
98
|
+
if (source) source = relative(root, resolve(root, source)).split('\\').join('/');
|
|
99
|
+
source ??= DEPARTMENT_FILES.find(f => existsSync(join(root, f)));
|
|
100
|
+
let departments = null;
|
|
101
|
+
if (existsSync(join(root, '.aether/departments.json'))) { kept.push('.aether/departments.json'); log('departments: .aether/departments.json already exists and is kept'); }
|
|
102
|
+
else if (source) {
|
|
103
|
+
try {
|
|
104
|
+
watch(source);
|
|
105
|
+
departments = readDepartmentFile(join(root, source));
|
|
106
|
+
put('.aether/departments.json', departments);
|
|
107
|
+
log(`departments: ${departments.departments.length} from ${source} → .aether/departments.json${departments.brains ? ` (model words for ${Object.keys(departments.brains).length})` : ''}`);
|
|
108
|
+
} catch (error) { notes.push(`${source} could not be used: ${String(error.message).slice(0, 120)}`); }
|
|
109
|
+
} else if (found.length) {
|
|
110
|
+
const names = new Set(found.map(a => a.agent.name));
|
|
111
|
+
const inferred = DEFAULT_GROUPS.map(([id, name, list]) => ({id, name, agents: list.filter(n => names.has(n))})).filter(d => d.agents.length);
|
|
112
|
+
if (inferred.length) { departments = {departments: inferred}; put('.aether/departments.json', departments); log(`departments: no department file found; grouped ${inferred.flatMap(d => d.agents).length} known agent names into ${inferred.map(d => d.name).join(', ')} (agents with other names show under "Other")`); }
|
|
113
|
+
else { log('departments: no department file found; every agent will show in one "Team" group'); missing.push('Departments: add a department file (`{"departments": [{"id": "build", "name": "Build", "agents": ["builder"]}]}`) and run `aether adopt --departments <file>`.'); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Skills, hooks, handover, rules (read only)
|
|
117
|
+
const skillsDir = join(root, '.claude/skills');
|
|
118
|
+
const skills = existsSync(skillsDir) ? readdirSync(skillsDir, {withFileTypes: true}).filter(e => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md'))).length : 0;
|
|
119
|
+
const naming = found.filter(a => skillsMentioning(root, a.agent).length).length;
|
|
120
|
+
log(skills ? `skills: ${skills} in .claude/skills; ${naming} of your agents are named by a skill (shown on the agent card)` : 'skills: none found in .claude/skills');
|
|
121
|
+
let hooks = false;
|
|
122
|
+
try { hooks = /status-event/.test(JSON.stringify(JSON.parse(readFileSync(join(root, '.claude/settings.json'), 'utf8')).hooks ?? {})); } catch {}
|
|
123
|
+
log(hooks ? 'hooks: status-event found in .claude/settings.json, so Claude Code sessions show as working now' : 'hooks: no status-event hook; the workspace still shows its own agents as working now');
|
|
124
|
+
if (!hooks) missing.push('Working now from Claude Code sessions: run `aether install claude` to add the status hook (it only records agent names and times).');
|
|
125
|
+
const next = nextSteps(root);
|
|
126
|
+
log(next.length ? `handover: "Up next" found; the status strip will show "Next: ${next[0].slice(0, 60)}"` : 'handover: no "Up next" section in HANDOVER.md');
|
|
127
|
+
if (!next.length) missing.push('Next step: add an "## Up next" list to HANDOVER.md to see it in the status strip.');
|
|
128
|
+
const rules = readRules(root);
|
|
129
|
+
log(rules.rules.length ? `rules: ${rules.rules.length} under "${rules.section}" in ${rules.file} (Project › Rules)` : 'rules: no "Working rules" section found');
|
|
130
|
+
if (!rules.rules.length) missing.push('Rules: add a "## Working rules" section to CLAUDE.md or HANDOVER.md, or name one in `.aether/rules.json`.');
|
|
131
|
+
if (!existsSync(join(root, '.aether/brand.json'))) missing.push('Names and look: run `aether setup --os-name "..." --assistant "..." [--style night-sky]` (no brand file yet; the defaults are AetherOS and Aevron).');
|
|
132
|
+
if (!existsSync(join(root, '.aether/status.json'))) missing.push('Site checks: add `.aether/status.json` to watch your site and (with GITHUB_TOKEN) the sprint, pull requests and issues.');
|
|
133
|
+
if (!existsSync(join(root, '.aether/pipeline.json'))) notes.push('build track: using the default steps (Plan, Build, Check, Pull request, Your test, Merged); list your own in .aether/pipeline.json');
|
|
134
|
+
|
|
135
|
+
// 4. Prove your own files were not touched
|
|
136
|
+
for (const [rel, digest] of before) if (!existsSync(join(root, rel)) || hash(join(root, rel)) !== digest) throw new Error(`Internal error: ${rel} changed; adopt must never change your files`);
|
|
137
|
+
|
|
138
|
+
for (const n of notes) log('note: ' + n);
|
|
139
|
+
log(`\n${dryRun ? 'Would write' : 'Wrote'} ${wrote.length} file${wrote.length === 1 ? '' : 's'}${kept.length ? `, kept ${kept.length} that already existed` : ''}.`);
|
|
140
|
+
log(`The command centre will show: ${found.length} agent${found.length === 1 ? '' : 's'}${departments ? ` in ${departments.departments.length} department${departments.departments.length === 1 ? '' : 's'}` : ''}, ${skills} skill${skills === 1 ? '' : 's'}, ${rules.rules.length} rule${rules.rules.length === 1 ? '' : 's'}${next.length ? ', the next step' : ''}.`);
|
|
141
|
+
if (missing.length) { log('Missing:'); for (const m of missing) log(' - ' + m); }
|
|
142
|
+
log('Next: `node .aether/aether.mjs start` (or `aether workspace`) to see it.');
|
|
143
|
+
return {wrote, kept, missing, notes, agents: found.length};
|
|
144
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function setup(args, cwd, log, {installers, ask, interactive = Bool
|
|
|
24
24
|
const dryRun = args.includes('--dry-run');
|
|
25
25
|
const git = isGitRepo(root);
|
|
26
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(
|
|
27
|
+
const listed = args.includes('--kits') ? (args[args.indexOf('--kits') + 1] || '').split(',').map(s => s.trim()).filter(k => k && k !== 'none') : null; // --kits "" or --kits none: no kits (for a project that already has its own setup; then run aether adopt)
|
|
28
28
|
if (listed && listed.some(k => !installers[k])) throw new Error('Unknown kit in --kits; available: ' + KIT_ORDER.join(', '));
|
|
29
29
|
if (listed?.includes('githooks') && !git) throw new Error('githooks needs a git repository (run git init first)');
|
|
30
30
|
let kits = listed ?? defaults;
|
|
@@ -54,8 +54,11 @@ export async function setup(args, cwd, log, {installers, ask, interactive = Bool
|
|
|
54
54
|
accent = String(await question('Accent colour (#rrggbb, empty for default)? ')).trim() || undefined;
|
|
55
55
|
} finally { rl?.close(); }
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// Command-centre look: --style night-sky|daylight|graphite|terminal|high-contrast, --layout assistant-centre|conversation|board, --corners round|square.
|
|
58
|
+
const look = {style: flag('--style'), layout: flag('--layout'), corners: flag('--corners')};
|
|
59
|
+
if (osName || assistantName || look.style || look.layout || look.corners) {
|
|
60
|
+
const kept = Object.fromEntries(['style', 'layout', 'corners', 'allowUserStyle'].filter(k => brand?.[k] !== undefined).map(k => [k, brand[k]]));
|
|
61
|
+
const next = {osName: osName || brand?.osName || `${basename(root)} OS`, assistantName: assistantName || brand?.assistantName || 'Aevron', theme: {...(brand?.theme ?? {}), ...(accent && {accent})}, ...kept, ...Object.fromEntries(Object.entries(look).filter(([, v]) => v))};
|
|
59
62
|
if (!dryRun) brand = writeBrand(root, next);
|
|
60
63
|
log(`${dryRun ? 'would brand' : 'branded'}: ${next.osName} · assistant ${next.assistantName}`);
|
|
61
64
|
}
|
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; }
|