@aetheros/cli 0.1.1 → 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 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/spec-kit';
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 (!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>>');
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,10 +108,35 @@ 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 <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
+ 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
113
  if (isMain && process.argv[2] === 'start') {
113
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; });
114
140
  } else if (isMain && process.argv[2] === 'sprint') {
115
141
  runSprint(process.argv.slice(3), process.cwd(), console.log).catch(error => { console.error(error.message); process.exitCode = 1; });
116
142
  } else if (isMain && process.argv[2] === 'setup') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aetheros/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,17 +16,17 @@
16
16
  "test": "node --test test/*.test.mjs"
17
17
  },
18
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"
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(Boolean) : null;
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
- if (osName || assistantName) {
58
- const next = {osName: osName || brand?.osName || `${basename(root)} OS`, assistantName: assistantName || brand?.assistantName || 'Aevron', theme: {...(brand?.theme ?? {}), ...(accent && {accent})}};
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
  }