@bamptee/aia-code 0.10.0 → 1.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamptee/aia-code",
3
- "version": "0.10.0",
3
+ "version": "1.0.0",
4
4
  "description": "AI Architecture Assistant - orchestrate AI-assisted development workflows via CLI tools (Claude, Codex, Gemini)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { registerStatusCommand } from './commands/status.js';
7
7
  import { registerResetCommand } from './commands/reset.js';
8
8
  import { registerNextCommand } from './commands/next.js';
9
9
  import { registerQuickCommand } from './commands/quick.js';
10
+ import { registerUiCommand } from './commands/ui.js';
10
11
 
11
12
  export function createCli() {
12
13
  const program = new Command();
@@ -24,6 +25,7 @@ export function createCli() {
24
25
  registerRepoCommand(program);
25
26
  registerStatusCommand(program);
26
27
  registerResetCommand(program);
28
+ registerUiCommand(program);
27
29
 
28
30
  return program;
29
31
  }
@@ -0,0 +1,19 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { startServer } from '../ui/server.js';
3
+
4
+ function openBrowser(url) {
5
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
6
+ try { execSync(`${cmd} ${url}`, { stdio: 'ignore' }); } catch {}
7
+ }
8
+
9
+ export function registerUiCommand(program) {
10
+ program
11
+ .command('ui')
12
+ .description('Launch the local web UI to manage features and config')
13
+ .option('-p, --port <port>', 'Port to listen on (scans from 3100 if not specified)')
14
+ .action(async (opts) => {
15
+ const port = opts.port ? parseInt(opts.port, 10) : undefined;
16
+ const { port: actualPort } = await startServer(port);
17
+ openBrowser(`http://127.0.0.1:${actualPort}`);
18
+ });
19
+ }
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR } from '../../constants.js';
5
+ import { json, error } from '../router.js';
6
+
7
+ export function registerConfigRoutes(router) {
8
+ // Get config
9
+ router.get('/api/config', async (req, res, { root }) => {
10
+ const configPath = path.join(root, AIA_DIR, 'config.yaml');
11
+ if (!(await fs.pathExists(configPath))) {
12
+ return error(res, 'config.yaml not found', 404);
13
+ }
14
+ const content = await fs.readFile(configPath, 'utf-8');
15
+ json(res, { content, parsed: yaml.parse(content) });
16
+ });
17
+
18
+ // Save config
19
+ router.put('/api/config', async (req, res, { root, parseBody }) => {
20
+ const body = await parseBody();
21
+ const configPath = path.join(root, AIA_DIR, 'config.yaml');
22
+ await fs.writeFile(configPath, body.content, 'utf-8');
23
+ json(res, { ok: true });
24
+ });
25
+
26
+ // List context files
27
+ router.get('/api/context', async (req, res, { root }) => {
28
+ const dir = path.join(root, AIA_DIR, 'context');
29
+ if (!(await fs.pathExists(dir))) return json(res, []);
30
+ const files = await fs.readdir(dir);
31
+ json(res, files.filter(f => f.endsWith('.md')));
32
+ });
33
+
34
+ // Read context file
35
+ router.get('/api/context/:filename', async (req, res, { params, root }) => {
36
+ const filePath = path.join(root, AIA_DIR, 'context', params.filename);
37
+ if (!(await fs.pathExists(filePath))) return error(res, 'Not found', 404);
38
+ const content = await fs.readFile(filePath, 'utf-8');
39
+ json(res, { filename: params.filename, content });
40
+ });
41
+
42
+ // Save context file
43
+ router.put('/api/context/:filename', async (req, res, { params, root, parseBody }) => {
44
+ const body = await parseBody();
45
+ const filePath = path.join(root, AIA_DIR, 'context', params.filename);
46
+ await fs.writeFile(filePath, body.content, 'utf-8');
47
+ json(res, { ok: true });
48
+ });
49
+
50
+ // List knowledge categories
51
+ router.get('/api/knowledge', async (req, res, { root }) => {
52
+ const dir = path.join(root, AIA_DIR, 'knowledge');
53
+ if (!(await fs.pathExists(dir))) return json(res, []);
54
+ const entries = await fs.readdir(dir, { withFileTypes: true });
55
+ const categories = [];
56
+ for (const entry of entries) {
57
+ if (entry.isDirectory()) {
58
+ const files = await fs.readdir(path.join(dir, entry.name));
59
+ categories.push({ name: entry.name, files: files.filter(f => f.endsWith('.md')) });
60
+ }
61
+ }
62
+ json(res, categories);
63
+ });
64
+
65
+ // Read knowledge file
66
+ router.get('/api/knowledge/:category/:filename', async (req, res, { params, root }) => {
67
+ const filePath = path.join(root, AIA_DIR, 'knowledge', params.category, params.filename);
68
+ if (!(await fs.pathExists(filePath))) return error(res, 'Not found', 404);
69
+ const content = await fs.readFile(filePath, 'utf-8');
70
+ json(res, { filename: params.filename, category: params.category, content });
71
+ });
72
+
73
+ // Save knowledge file
74
+ router.put('/api/knowledge/:category/:filename', async (req, res, { params, root, parseBody }) => {
75
+ const body = await parseBody();
76
+ const filePath = path.join(root, AIA_DIR, 'knowledge', params.category, params.filename);
77
+ await fs.ensureDir(path.dirname(filePath));
78
+ await fs.writeFile(filePath, body.content, 'utf-8');
79
+ json(res, { ok: true });
80
+ });
81
+ }
@@ -0,0 +1,8 @@
1
+ import { FEATURE_STEPS, STEP_STATUS, QUICK_STEPS } from '../../constants.js';
2
+ import { json } from '../router.js';
3
+
4
+ export function registerConstantsRoutes(router) {
5
+ router.get('/api/constants', (req, res) => {
6
+ json(res, { FEATURE_STEPS, STEP_STATUS, QUICK_STEPS });
7
+ });
8
+ }
@@ -0,0 +1,98 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR } from '../../constants.js';
4
+ import { loadStatus, updateStepStatus, resetStep } from '../../services/status.js';
5
+ import { createFeature, validateFeatureName } from '../../services/feature.js';
6
+ import { runStep } from '../../services/runner.js';
7
+ import { json, error } from '../router.js';
8
+
9
+ export function registerFeatureRoutes(router) {
10
+ // List all features
11
+ router.get('/api/features', async (req, res, { root }) => {
12
+ const featuresDir = path.join(root, AIA_DIR, 'features');
13
+ if (!(await fs.pathExists(featuresDir))) {
14
+ return json(res, []);
15
+ }
16
+ const entries = await fs.readdir(featuresDir, { withFileTypes: true });
17
+ const features = [];
18
+ for (const entry of entries) {
19
+ if (entry.isDirectory()) {
20
+ try {
21
+ const status = await loadStatus(entry.name, root);
22
+ features.push({ name: entry.name, ...status });
23
+ } catch {
24
+ features.push({ name: entry.name, error: true });
25
+ }
26
+ }
27
+ }
28
+ json(res, features);
29
+ });
30
+
31
+ // Get a single feature
32
+ router.get('/api/features/:name', async (req, res, { params, root }) => {
33
+ try {
34
+ const status = await loadStatus(params.name, root);
35
+ const featureDir = path.join(root, AIA_DIR, 'features', params.name);
36
+ const files = await fs.readdir(featureDir);
37
+ json(res, { name: params.name, ...status, files });
38
+ } catch (err) {
39
+ error(res, err.message, 404);
40
+ }
41
+ });
42
+
43
+ // Read a feature file
44
+ router.get('/api/features/:name/files/:filename', async (req, res, { params, root }) => {
45
+ const filePath = path.join(root, AIA_DIR, 'features', params.name, params.filename);
46
+ if (!(await fs.pathExists(filePath))) {
47
+ return error(res, 'File not found', 404);
48
+ }
49
+ const content = await fs.readFile(filePath, 'utf-8');
50
+ json(res, { filename: params.filename, content });
51
+ });
52
+
53
+ // Save a feature file
54
+ router.put('/api/features/:name/files/:filename', async (req, res, { params, root, parseBody }) => {
55
+ const body = await parseBody();
56
+ const filePath = path.join(root, AIA_DIR, 'features', params.name, params.filename);
57
+ await fs.writeFile(filePath, body.content, 'utf-8');
58
+ json(res, { ok: true });
59
+ });
60
+
61
+ // Create a new feature
62
+ router.post('/api/features', async (req, res, { root, parseBody }) => {
63
+ const body = await parseBody();
64
+ try {
65
+ validateFeatureName(body.name);
66
+ await createFeature(body.name, root);
67
+ json(res, { ok: true, name: body.name }, 201);
68
+ } catch (err) {
69
+ error(res, err.message, 400);
70
+ }
71
+ });
72
+
73
+ // Run a step (blocking - returns when done)
74
+ router.post('/api/features/:name/run/:step', async (req, res, { params, root, parseBody }) => {
75
+ const body = await parseBody();
76
+ try {
77
+ const output = await runStep(params.step, params.name, {
78
+ description: body.description,
79
+ verbose: false,
80
+ apply: body.apply || false,
81
+ root,
82
+ });
83
+ json(res, { ok: true, output });
84
+ } catch (err) {
85
+ error(res, err.message, 400);
86
+ }
87
+ });
88
+
89
+ // Reset a step
90
+ router.post('/api/features/:name/reset/:step', async (req, res, { params, root }) => {
91
+ try {
92
+ await resetStep(params.name, params.step, root);
93
+ json(res, { ok: true });
94
+ } catch (err) {
95
+ error(res, err.message, 400);
96
+ }
97
+ });
98
+ }
@@ -0,0 +1,11 @@
1
+ import { registerFeatureRoutes } from './features.js';
2
+ import { registerConfigRoutes } from './config.js';
3
+ import { registerLogRoutes } from './logs.js';
4
+ import { registerConstantsRoutes } from './constants.js';
5
+
6
+ export function registerApiRoutes(router, root) {
7
+ registerFeatureRoutes(router);
8
+ registerConfigRoutes(router);
9
+ registerLogRoutes(router);
10
+ registerConstantsRoutes(router);
11
+ }
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { AIA_DIR } from '../../constants.js';
4
+ import { json } from '../router.js';
5
+
6
+ export function registerLogRoutes(router) {
7
+ router.get('/api/logs', async (req, res, { root }) => {
8
+ const logPath = path.join(root, AIA_DIR, 'logs', 'execution.log');
9
+ if (!(await fs.pathExists(logPath))) {
10
+ return json(res, { content: '' });
11
+ }
12
+ const content = await fs.readFile(logPath, 'utf-8');
13
+ json(res, { content });
14
+ });
15
+ }
@@ -0,0 +1,160 @@
1
+ import React from 'react';
2
+ import { api } from '/main.js';
3
+
4
+ function YamlEditor({ title, loadFn, saveFn }) {
5
+ const [content, setContent] = React.useState('');
6
+ const [loading, setLoading] = React.useState(true);
7
+ const [saving, setSaving] = React.useState(false);
8
+ const [dirty, setDirty] = React.useState(false);
9
+ const [msg, setMsg] = React.useState(null);
10
+
11
+ React.useEffect(() => {
12
+ loadFn().then(c => setContent(c)).catch(() => setContent('')).finally(() => setLoading(false));
13
+ }, []);
14
+
15
+ async function save() {
16
+ setSaving(true);
17
+ setMsg(null);
18
+ try {
19
+ await saveFn(content);
20
+ setDirty(false);
21
+ setMsg({ type: 'ok', text: 'Saved.' });
22
+ } catch (e) {
23
+ setMsg({ type: 'err', text: e.message });
24
+ }
25
+ setSaving(false);
26
+ }
27
+
28
+ if (loading) return React.createElement('p', { className: 'text-slate-500 text-sm' }, 'Loading...');
29
+
30
+ return React.createElement('div', { className: 'space-y-2' },
31
+ React.createElement('div', { className: 'flex items-center justify-between' },
32
+ React.createElement('h3', { className: 'text-sm font-semibold text-slate-300' }, title),
33
+ React.createElement('div', { className: 'flex gap-2 items-center' },
34
+ dirty && React.createElement('span', { className: 'text-xs text-amber-400' }, 'unsaved'),
35
+ msg && React.createElement('span', { className: `text-xs ${msg.type === 'ok' ? 'text-emerald-400' : 'text-red-400'}` }, msg.text),
36
+ React.createElement('button', {
37
+ onClick: save,
38
+ disabled: saving || !dirty,
39
+ className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-3 py-1 text-xs hover:bg-aia-accent/30 disabled:opacity-40',
40
+ }, saving ? '...' : 'Save'),
41
+ ),
42
+ ),
43
+ React.createElement('textarea', {
44
+ value: content,
45
+ onChange: e => { setContent(e.target.value); setDirty(true); },
46
+ spellCheck: false,
47
+ className: 'w-full h-72 bg-slate-900 border border-aia-border rounded p-3 text-sm text-slate-300 font-mono resize-y focus:border-aia-accent focus:outline-none',
48
+ })
49
+ );
50
+ }
51
+
52
+ function FileList({ title, files, selectedFile, onSelect }) {
53
+ if (!files.length) return null;
54
+ return React.createElement('div', { className: 'space-y-1' },
55
+ React.createElement('h4', { className: 'text-xs font-semibold text-slate-400 uppercase tracking-wider' }, title),
56
+ ...files.map(f =>
57
+ React.createElement('button', {
58
+ key: f,
59
+ onClick: () => onSelect(f),
60
+ className: `block w-full text-left px-2 py-1 text-sm rounded ${selectedFile === f ? 'bg-aia-accent/20 text-aia-accent' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`,
61
+ }, f)
62
+ )
63
+ );
64
+ }
65
+
66
+ export function ConfigView() {
67
+ const [contextFiles, setContextFiles] = React.useState([]);
68
+ const [knowledgeCategories, setKnowledgeCategories] = React.useState([]);
69
+ const [selectedFile, setSelectedFile] = React.useState(null);
70
+ const [selectedType, setSelectedType] = React.useState(null); // 'context' | 'knowledge'
71
+ const [selectedCategory, setSelectedCategory] = React.useState(null);
72
+ const [logs, setLogs] = React.useState('');
73
+ const [showLogs, setShowLogs] = React.useState(false);
74
+
75
+ React.useEffect(() => {
76
+ api.get('/context').then(setContextFiles).catch(() => {});
77
+ api.get('/knowledge').then(setKnowledgeCategories).catch(() => {});
78
+ }, []);
79
+
80
+ function selectContext(f) {
81
+ setSelectedFile(f);
82
+ setSelectedType('context');
83
+ setSelectedCategory(null);
84
+ }
85
+
86
+ function selectKnowledge(cat, f) {
87
+ setSelectedFile(f);
88
+ setSelectedType('knowledge');
89
+ setSelectedCategory(cat);
90
+ }
91
+
92
+ async function loadLogs() {
93
+ try {
94
+ const data = await api.get('/logs');
95
+ setLogs(data.content || '(empty)');
96
+ setShowLogs(true);
97
+ } catch {}
98
+ }
99
+
100
+ return React.createElement('div', { className: 'space-y-6' },
101
+ React.createElement('h1', { className: 'text-xl font-bold text-slate-100' }, 'Configuration'),
102
+
103
+ // config.yaml
104
+ React.createElement(YamlEditor, {
105
+ title: 'config.yaml',
106
+ loadFn: async () => (await api.get('/config')).content,
107
+ saveFn: async (content) => api.put('/config', { content }),
108
+ }),
109
+
110
+ // Sidebar + editor for context/knowledge
111
+ React.createElement('div', { className: 'grid grid-cols-4 gap-4' },
112
+ // Sidebar
113
+ React.createElement('div', { className: 'col-span-1 space-y-4' },
114
+ React.createElement(FileList, {
115
+ title: 'Context',
116
+ files: contextFiles,
117
+ selectedFile: selectedType === 'context' ? selectedFile : null,
118
+ onSelect: selectContext,
119
+ }),
120
+ ...knowledgeCategories.map(cat =>
121
+ React.createElement(FileList, {
122
+ key: cat.name,
123
+ title: `Knowledge / ${cat.name}`,
124
+ files: cat.files,
125
+ selectedFile: selectedType === 'knowledge' && selectedCategory === cat.name ? selectedFile : null,
126
+ onSelect: f => selectKnowledge(cat.name, f),
127
+ })
128
+ ),
129
+ React.createElement('button', {
130
+ onClick: loadLogs,
131
+ className: 'text-xs text-slate-500 hover:text-slate-300 mt-4',
132
+ }, 'View execution logs'),
133
+ ),
134
+
135
+ // Editor
136
+ React.createElement('div', { className: 'col-span-3' },
137
+ selectedFile && selectedType === 'context' && React.createElement(YamlEditor, {
138
+ key: `ctx-${selectedFile}`,
139
+ title: `context/${selectedFile}`,
140
+ loadFn: async () => (await api.get(`/context/${selectedFile}`)).content,
141
+ saveFn: async (content) => api.put(`/context/${selectedFile}`, { content }),
142
+ }),
143
+ selectedFile && selectedType === 'knowledge' && React.createElement(YamlEditor, {
144
+ key: `kn-${selectedCategory}-${selectedFile}`,
145
+ title: `knowledge/${selectedCategory}/${selectedFile}`,
146
+ loadFn: async () => (await api.get(`/knowledge/${selectedCategory}/${selectedFile}`)).content,
147
+ saveFn: async (content) => api.put(`/knowledge/${selectedCategory}/${selectedFile}`, { content }),
148
+ }),
149
+ !selectedFile && !showLogs && React.createElement('p', { className: 'text-slate-500 text-sm' }, 'Select a file to edit.'),
150
+ showLogs && React.createElement('div', { className: 'space-y-2' },
151
+ React.createElement('div', { className: 'flex items-center justify-between' },
152
+ React.createElement('h3', { className: 'text-sm font-semibold text-slate-300' }, 'Execution Logs'),
153
+ React.createElement('button', { onClick: () => setShowLogs(false), className: 'text-xs text-slate-500 hover:text-slate-300' }, 'Close'),
154
+ ),
155
+ React.createElement('pre', { className: 'bg-slate-900 border border-aia-border rounded p-3 text-xs text-slate-400 overflow-auto max-h-96' }, logs),
156
+ ),
157
+ ),
158
+ ),
159
+ );
160
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { api } from '/main.js';
3
+
4
+ const STATUS_CLASSES = {
5
+ done: 'step-done',
6
+ pending: 'step-pending',
7
+ 'in-progress': 'step-in-progress',
8
+ error: 'step-error',
9
+ };
10
+
11
+ function StepBadge({ step, status }) {
12
+ return React.createElement('span', {
13
+ className: `inline-block px-2 py-0.5 text-xs rounded border ${STATUS_CLASSES[status] || 'step-pending'}`,
14
+ }, step);
15
+ }
16
+
17
+ function FeatureCard({ feature }) {
18
+ const steps = feature.steps || {};
19
+ const doneCount = Object.values(steps).filter(s => s === 'done').length;
20
+ const totalCount = Object.keys(steps).length;
21
+
22
+ return React.createElement('a', {
23
+ href: `#/features/${feature.name}`,
24
+ className: 'block bg-aia-card border border-aia-border rounded-lg p-4 hover:border-aia-accent/50 transition-colors',
25
+ },
26
+ React.createElement('div', { className: 'flex items-center justify-between mb-3' },
27
+ React.createElement('h3', { className: 'text-slate-100 font-semibold' }, feature.name),
28
+ React.createElement('span', { className: 'text-xs text-slate-500' },
29
+ `${doneCount}/${totalCount} steps`
30
+ ),
31
+ ),
32
+ feature.current_step && React.createElement('p', { className: 'text-xs text-slate-400 mb-3' },
33
+ 'Current: ', React.createElement('span', { className: 'text-aia-accent' }, feature.current_step)
34
+ ),
35
+ React.createElement('div', { className: 'flex flex-wrap gap-1.5' },
36
+ ...Object.entries(steps).map(([step, status]) =>
37
+ React.createElement(StepBadge, { key: step, step, status })
38
+ )
39
+ )
40
+ );
41
+ }
42
+
43
+ function NewFeatureForm({ onCreated }) {
44
+ const [name, setName] = React.useState('');
45
+ const [err, setErr] = React.useState('');
46
+ const [loading, setLoading] = React.useState(false);
47
+
48
+ async function handleSubmit(e) {
49
+ e.preventDefault();
50
+ setErr('');
51
+ setLoading(true);
52
+ try {
53
+ await api.post('/features', { name });
54
+ setName('');
55
+ onCreated();
56
+ } catch (e) {
57
+ setErr(e.message);
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }
62
+
63
+ return React.createElement('form', { onSubmit: handleSubmit, className: 'flex gap-2 items-start' },
64
+ React.createElement('div', null,
65
+ React.createElement('input', {
66
+ type: 'text',
67
+ value: name,
68
+ onChange: e => setName(e.target.value),
69
+ placeholder: 'feature-name',
70
+ className: 'bg-aia-card border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none',
71
+ }),
72
+ err && React.createElement('p', { className: 'text-red-400 text-xs mt-1' }, err),
73
+ ),
74
+ React.createElement('button', {
75
+ type: 'submit',
76
+ disabled: loading || !name,
77
+ className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-3 py-1.5 text-sm hover:bg-aia-accent/30 disabled:opacity-40',
78
+ }, loading ? '...' : '+ New Feature')
79
+ );
80
+ }
81
+
82
+ export function Dashboard() {
83
+ const [features, setFeatures] = React.useState([]);
84
+ const [loading, setLoading] = React.useState(true);
85
+
86
+ async function load() {
87
+ try {
88
+ const data = await api.get('/features');
89
+ setFeatures(data);
90
+ } catch {}
91
+ setLoading(false);
92
+ }
93
+
94
+ React.useEffect(() => { load(); }, []);
95
+
96
+ return React.createElement('div', null,
97
+ React.createElement('div', { className: 'flex items-center justify-between mb-6' },
98
+ React.createElement('h1', { className: 'text-xl font-bold text-slate-100' }, 'Features'),
99
+ React.createElement(NewFeatureForm, { onCreated: load }),
100
+ ),
101
+ loading
102
+ ? React.createElement('p', { className: 'text-slate-500' }, 'Loading...')
103
+ : features.length === 0
104
+ ? React.createElement('p', { className: 'text-slate-500' }, 'No features yet. Create one to get started.')
105
+ : React.createElement('div', { className: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4' },
106
+ ...features.map(f => React.createElement(FeatureCard, { key: f.name, feature: f }))
107
+ )
108
+ );
109
+ }
@@ -0,0 +1,193 @@
1
+ import React from 'react';
2
+ import { api } from '/main.js';
3
+
4
+ const STATUS_CLASSES = {
5
+ done: 'step-done',
6
+ pending: 'step-pending',
7
+ 'in-progress': 'step-in-progress',
8
+ error: 'step-error',
9
+ };
10
+
11
+ const STATUS_ICONS = { done: '\u2713', pending: '\u00b7', 'in-progress': '\u25b6', error: '\u2717' };
12
+
13
+ function StepPill({ step, status, active, onClick }) {
14
+ return React.createElement('button', {
15
+ onClick,
16
+ className: `px-3 py-1.5 text-xs rounded border transition-all ${STATUS_CLASSES[status] || 'step-pending'} ${active ? 'ring-2 ring-aia-accent ring-offset-1 ring-offset-aia-bg' : 'hover:brightness-125'}`,
17
+ }, `${STATUS_ICONS[status] || ''} ${step}`);
18
+ }
19
+
20
+ function FileEditor({ name, filename, onSaved }) {
21
+ const [content, setContent] = React.useState('');
22
+ const [loading, setLoading] = React.useState(true);
23
+ const [saving, setSaving] = React.useState(false);
24
+ const [dirty, setDirty] = React.useState(false);
25
+
26
+ React.useEffect(() => {
27
+ setLoading(true);
28
+ setDirty(false);
29
+ api.get(`/features/${name}/files/${filename}`)
30
+ .then(data => setContent(data.content))
31
+ .catch(() => setContent(''))
32
+ .finally(() => setLoading(false));
33
+ }, [name, filename]);
34
+
35
+ async function save() {
36
+ setSaving(true);
37
+ try {
38
+ await api.put(`/features/${name}/files/${filename}`, { content });
39
+ setDirty(false);
40
+ if (onSaved) onSaved();
41
+ } catch {}
42
+ setSaving(false);
43
+ }
44
+
45
+ if (loading) return React.createElement('p', { className: 'text-slate-500 text-sm' }, 'Loading...');
46
+
47
+ return React.createElement('div', { className: 'flex flex-col gap-2' },
48
+ React.createElement('div', { className: 'flex items-center justify-between' },
49
+ React.createElement('span', { className: 'text-sm text-slate-400' }, filename),
50
+ React.createElement('div', { className: 'flex gap-2' },
51
+ dirty && React.createElement('span', { className: 'text-xs text-amber-400' }, 'unsaved'),
52
+ React.createElement('button', {
53
+ onClick: save,
54
+ disabled: saving || !dirty,
55
+ className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-3 py-1 text-xs hover:bg-aia-accent/30 disabled:opacity-40',
56
+ }, saving ? 'Saving...' : 'Save'),
57
+ ),
58
+ ),
59
+ React.createElement('textarea', {
60
+ value: content,
61
+ onChange: e => { setContent(e.target.value); setDirty(true); },
62
+ spellCheck: false,
63
+ className: 'w-full h-96 bg-slate-900 border border-aia-border rounded p-3 text-sm text-slate-300 font-mono resize-y focus:border-aia-accent focus:outline-none',
64
+ })
65
+ );
66
+ }
67
+
68
+ function RunPanel({ name, step, onDone }) {
69
+ const [description, setDescription] = React.useState('');
70
+ const [apply, setApply] = React.useState(false);
71
+ const [running, setRunning] = React.useState(false);
72
+ const [result, setResult] = React.useState(null);
73
+ const [error, setError] = React.useState(null);
74
+
75
+ async function run() {
76
+ setRunning(true);
77
+ setResult(null);
78
+ setError(null);
79
+ try {
80
+ const data = await api.post(`/features/${name}/run/${step}`, { description, apply });
81
+ setResult('Step completed.');
82
+ if (onDone) onDone();
83
+ } catch (e) {
84
+ setError(e.message);
85
+ }
86
+ setRunning(false);
87
+ }
88
+
89
+ async function reset() {
90
+ try {
91
+ await api.post(`/features/${name}/reset/${step}`);
92
+ if (onDone) onDone();
93
+ } catch (e) {
94
+ setError(e.message);
95
+ }
96
+ }
97
+
98
+ return React.createElement('div', { className: 'bg-slate-900 border border-aia-border rounded p-4 space-y-3' },
99
+ React.createElement('h4', { className: 'text-sm font-semibold text-slate-300' }, `Run: ${step}`),
100
+ React.createElement('input', {
101
+ type: 'text',
102
+ value: description,
103
+ onChange: e => setDescription(e.target.value),
104
+ placeholder: 'Optional description...',
105
+ className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none',
106
+ }),
107
+ React.createElement('div', { className: 'flex items-center gap-4' },
108
+ React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer' },
109
+ React.createElement('input', {
110
+ type: 'checkbox',
111
+ checked: apply,
112
+ onChange: e => setApply(e.target.checked),
113
+ className: 'rounded',
114
+ }),
115
+ 'Agent mode (--apply)'
116
+ ),
117
+ React.createElement('button', {
118
+ onClick: run,
119
+ disabled: running,
120
+ className: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded px-4 py-1.5 text-sm hover:bg-emerald-500/30 disabled:opacity-40',
121
+ }, running ? 'Running...' : 'Run Step'),
122
+ React.createElement('button', {
123
+ onClick: reset,
124
+ disabled: running,
125
+ className: 'text-slate-500 hover:text-slate-300 text-xs',
126
+ }, 'Reset'),
127
+ ),
128
+ running && React.createElement('p', { className: 'text-amber-400 text-xs animate-pulse' }, 'Step is running... This may take a few minutes.'),
129
+ result && React.createElement('p', { className: 'text-emerald-400 text-xs' }, result),
130
+ error && React.createElement('p', { className: 'text-red-400 text-xs' }, error),
131
+ );
132
+ }
133
+
134
+ export function FeatureDetail({ name }) {
135
+ const [feature, setFeature] = React.useState(null);
136
+ const [loading, setLoading] = React.useState(true);
137
+ const [activeFile, setActiveFile] = React.useState('init.md');
138
+ const [activeStep, setActiveStep] = React.useState(null);
139
+
140
+ async function load() {
141
+ try {
142
+ const data = await api.get(`/features/${name}`);
143
+ setFeature(data);
144
+ } catch {}
145
+ setLoading(false);
146
+ }
147
+
148
+ React.useEffect(() => { load(); }, [name]);
149
+
150
+ if (loading) return React.createElement('p', { className: 'text-slate-500' }, 'Loading...');
151
+ if (!feature) return React.createElement('p', { className: 'text-red-400' }, `Feature "${name}" not found.`);
152
+
153
+ const steps = feature.steps || {};
154
+
155
+ return React.createElement('div', { className: 'space-y-6' },
156
+ // Header
157
+ React.createElement('div', { className: 'flex items-center gap-3' },
158
+ React.createElement('a', { href: '#/', className: 'text-slate-500 hover:text-slate-300' }, '\u2190'),
159
+ React.createElement('h1', { className: 'text-xl font-bold text-slate-100' }, name),
160
+ feature.current_step && React.createElement('span', { className: 'text-xs bg-aia-accent/20 text-aia-accent px-2 py-0.5 rounded' }, feature.current_step),
161
+ ),
162
+
163
+ // Pipeline
164
+ React.createElement('div', { className: 'flex flex-wrap gap-2' },
165
+ ...Object.entries(steps).map(([step, status]) =>
166
+ React.createElement(StepPill, {
167
+ key: step,
168
+ step,
169
+ status,
170
+ active: activeStep === step,
171
+ onClick: () => { setActiveStep(step); setActiveFile(`${step}.md`); },
172
+ })
173
+ )
174
+ ),
175
+
176
+ // Run panel
177
+ activeStep && React.createElement(RunPanel, { name, step: activeStep, onDone: load }),
178
+
179
+ // File tabs
180
+ React.createElement('div', { className: 'flex gap-1 border-b border-aia-border' },
181
+ ...(feature.files || []).filter(f => f.endsWith('.md') || f.endsWith('.yaml')).map(f =>
182
+ React.createElement('button', {
183
+ key: f,
184
+ onClick: () => setActiveFile(f),
185
+ className: `px-3 py-1.5 text-xs border-b-2 transition-colors ${activeFile === f ? 'border-aia-accent text-aia-accent' : 'border-transparent text-slate-500 hover:text-slate-300'}`,
186
+ }, f)
187
+ )
188
+ ),
189
+
190
+ // Editor
191
+ activeFile && React.createElement(FileEditor, { key: `${name}-${activeFile}`, name, filename: activeFile, onSaved: load }),
192
+ );
193
+ }
@@ -0,0 +1,44 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AIA - AI Architecture Assistant</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ theme: {
11
+ extend: {
12
+ colors: {
13
+ 'aia-bg': '#0f172a',
14
+ 'aia-card': '#1e293b',
15
+ 'aia-border': '#334155',
16
+ 'aia-accent': '#38bdf8',
17
+ }
18
+ }
19
+ }
20
+ }
21
+ </script>
22
+ <script type="importmap">
23
+ {
24
+ "imports": {
25
+ "react": "https://esm.sh/react@19?dev",
26
+ "react-dom/client": "https://esm.sh/react-dom@19/client?dev",
27
+ "react/jsx-runtime": "https://esm.sh/react@19/jsx-runtime?dev"
28
+ }
29
+ }
30
+ </script>
31
+ <style>
32
+ body { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; }
33
+ .step-done { @apply bg-emerald-500/20 text-emerald-400 border-emerald-500/30; }
34
+ .step-pending { @apply bg-slate-700/50 text-slate-400 border-slate-600; }
35
+ .step-in-progress { @apply bg-amber-500/20 text-amber-400 border-amber-500/30; }
36
+ .step-error { @apply bg-red-500/20 text-red-400 border-red-500/30; }
37
+ textarea { tab-size: 2; }
38
+ </style>
39
+ </head>
40
+ <body class="bg-aia-bg text-slate-200 min-h-screen">
41
+ <div id="root"></div>
42
+ <script type="module" src="/main.js"></script>
43
+ </body>
44
+ </html>
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { Dashboard } from '/components/dashboard.js';
4
+ import { FeatureDetail } from '/components/feature-detail.js';
5
+ import { ConfigView } from '/components/config-view.js';
6
+
7
+ // --- API client ---
8
+ export const api = {
9
+ async get(path) {
10
+ const res = await fetch(`/api${path}`);
11
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
12
+ return res.json();
13
+ },
14
+ async post(path, body = {}) {
15
+ const res = await fetch(`/api${path}`, {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json' },
18
+ body: JSON.stringify(body),
19
+ });
20
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
21
+ return res.json();
22
+ },
23
+ async put(path, body = {}) {
24
+ const res = await fetch(`/api${path}`, {
25
+ method: 'PUT',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify(body),
28
+ });
29
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
30
+ return res.json();
31
+ },
32
+ };
33
+
34
+ // --- Simple hash router ---
35
+ function useHashRoute() {
36
+ const [route, setRoute] = React.useState(window.location.hash || '#/');
37
+ React.useEffect(() => {
38
+ const handler = () => setRoute(window.location.hash || '#/');
39
+ window.addEventListener('hashchange', handler);
40
+ return () => window.removeEventListener('hashchange', handler);
41
+ }, []);
42
+ return route;
43
+ }
44
+
45
+ function parseRoute(hash) {
46
+ if (hash.startsWith('#/features/')) {
47
+ return { page: 'feature', name: decodeURIComponent(hash.slice('#/features/'.length)) };
48
+ }
49
+ if (hash === '#/config') return { page: 'config' };
50
+ return { page: 'dashboard' };
51
+ }
52
+
53
+ // --- App ---
54
+ function App() {
55
+ const hash = useHashRoute();
56
+ const { page, name } = parseRoute(hash);
57
+
58
+ return React.createElement('div', { className: 'min-h-screen' },
59
+ React.createElement('nav', { className: 'border-b border-aia-border px-6 py-3 flex items-center gap-6' },
60
+ React.createElement('a', { href: '#/', className: 'text-aia-accent font-bold text-lg hover:text-sky-300' }, 'AIA'),
61
+ React.createElement('a', { href: '#/', className: 'text-slate-400 hover:text-slate-200 text-sm' }, 'Features'),
62
+ React.createElement('a', { href: '#/config', className: 'text-slate-400 hover:text-slate-200 text-sm' }, 'Config'),
63
+ ),
64
+ React.createElement('main', { className: 'max-w-6xl mx-auto p-6' },
65
+ page === 'dashboard' ? React.createElement(Dashboard) :
66
+ page === 'feature' ? React.createElement(FeatureDetail, { name }) :
67
+ page === 'config' ? React.createElement(ConfigView) : null
68
+ )
69
+ );
70
+ }
71
+
72
+ createRoot(document.getElementById('root')).render(React.createElement(App));
@@ -0,0 +1,47 @@
1
+ export function createRouter() {
2
+ const routes = [];
3
+
4
+ function add(method, pattern, handler) {
5
+ const keys = [];
6
+ const regex = new RegExp(
7
+ '^' + pattern.replace(/:([^/]+)/g, (_, key) => { keys.push(key); return '([^/]+)'; }) + '$'
8
+ );
9
+ routes.push({ method, regex, keys, handler });
10
+ }
11
+
12
+ function match(method, pathname) {
13
+ for (const route of routes) {
14
+ if (route.method !== method) continue;
15
+ const m = pathname.match(route.regex);
16
+ if (m) {
17
+ const params = {};
18
+ route.keys.forEach((key, i) => { params[key] = decodeURIComponent(m[i + 1]); });
19
+ return { handler: route.handler, params };
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ return { add, match, get: (p, h) => add('GET', p, h), post: (p, h) => add('POST', p, h), put: (p, h) => add('PUT', p, h), delete: (p, h) => add('DELETE', p, h) };
26
+ }
27
+
28
+ export async function parseBody(req) {
29
+ return new Promise((resolve, reject) => {
30
+ let body = '';
31
+ req.on('data', chunk => { body += chunk; });
32
+ req.on('end', () => {
33
+ try { resolve(body ? JSON.parse(body) : {}); }
34
+ catch (e) { reject(new Error('Invalid JSON body')); }
35
+ });
36
+ req.on('error', reject);
37
+ });
38
+ }
39
+
40
+ export function json(res, data, status = 200) {
41
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
42
+ res.end(JSON.stringify(data));
43
+ }
44
+
45
+ export function error(res, message, status = 500) {
46
+ json(res, { error: message }, status);
47
+ }
@@ -0,0 +1,105 @@
1
+ import { createServer } from 'node:http';
2
+ import { createConnection } from 'node:net';
3
+ import { createReadStream, existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createRouter, parseBody, json, error } from './router.js';
7
+ import { registerApiRoutes } from './api/index.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ const MIME_TYPES = {
12
+ '.html': 'text/html',
13
+ '.js': 'application/javascript',
14
+ '.css': 'text/css',
15
+ '.json': 'application/json',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.ico': 'image/x-icon',
19
+ };
20
+
21
+ function serveStatic(res, filePath) {
22
+ const ext = path.extname(filePath);
23
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
24
+
25
+ if (!existsSync(filePath)) {
26
+ // SPA fallback: serve index.html for non-API, non-file routes
27
+ const indexPath = path.join(__dirname, 'public', 'index.html');
28
+ if (existsSync(indexPath)) {
29
+ res.writeHead(200, { 'Content-Type': 'text/html' });
30
+ createReadStream(indexPath).pipe(res);
31
+ return;
32
+ }
33
+ res.writeHead(404);
34
+ res.end('Not found');
35
+ return;
36
+ }
37
+
38
+ res.writeHead(200, { 'Content-Type': mime });
39
+ createReadStream(filePath).pipe(res);
40
+ }
41
+
42
+ function isPortFree(port) {
43
+ return new Promise((resolve) => {
44
+ const socket = createConnection({ port, host: '127.0.0.1' });
45
+ socket.once('connect', () => { socket.destroy(); resolve(false); });
46
+ socket.once('error', () => { resolve(true); });
47
+ });
48
+ }
49
+
50
+ async function findFreePort(start = 3100, maxAttempts = 50) {
51
+ for (let port = start; port < start + maxAttempts; port++) {
52
+ if (await isPortFree(port)) return port;
53
+ }
54
+ throw new Error(`No free port found between ${start} and ${start + maxAttempts - 1}`);
55
+ }
56
+
57
+ export async function startServer(preferredPort, root = process.cwd()) {
58
+ const port = preferredPort ?? await findFreePort();
59
+
60
+ const router = createRouter();
61
+ registerApiRoutes(router, root);
62
+
63
+ const server = createServer(async (req, res) => {
64
+ // CORS preflight
65
+ if (req.method === 'OPTIONS') {
66
+ res.writeHead(204, {
67
+ 'Access-Control-Allow-Origin': '*',
68
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
69
+ 'Access-Control-Allow-Headers': 'Content-Type',
70
+ });
71
+ res.end();
72
+ return;
73
+ }
74
+
75
+ const url = new URL(req.url, `http://${req.headers.host}`);
76
+ const pathname = url.pathname;
77
+
78
+ // API routes
79
+ if (pathname.startsWith('/api/')) {
80
+ const matched = router.match(req.method, pathname);
81
+ if (matched) {
82
+ try {
83
+ await matched.handler(req, res, { params: matched.params, query: Object.fromEntries(url.searchParams), root, parseBody: () => parseBody(req) });
84
+ } catch (err) {
85
+ error(res, err.message, 500);
86
+ }
87
+ } else {
88
+ error(res, 'Not found', 404);
89
+ }
90
+ return;
91
+ }
92
+
93
+ // Static files
94
+ let filePath = path.join(__dirname, 'public', pathname === '/' ? 'index.html' : pathname);
95
+ serveStatic(res, filePath);
96
+ });
97
+
98
+ return new Promise((resolve, reject) => {
99
+ server.on('error', reject);
100
+ server.listen(port, '127.0.0.1', () => {
101
+ console.log(`AIA UI running at http://127.0.0.1:${port}`);
102
+ resolve({ server, port });
103
+ });
104
+ });
105
+ }