axmap-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/app/web/shell.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 새 껍데기 — 3분할과 대화만 있다.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 여기는 **아무것도 실행하지 않는다.**
|
|
5
|
+
*
|
|
6
|
+
* 프로세스를 띄우고 출력을 모으는 일은 전부 서버(`app/lib/session.mjs`)가 한다.
|
|
7
|
+
* 이 파일은 모인 것을 받아 그릴 뿐이다. 그래야 창이 느려지거나 닫혀도 진행
|
|
8
|
+
* 중인 작업이 계속 돈다 — 그것이 이 구조의 유일한 목적이다.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 그리고 이 파일은 **일부러 작다.**
|
|
11
|
+
*
|
|
12
|
+
* 옛 화면이 3,527줄까지 자란 것은 기능을 하나씩 덧댄 결과다(지금은 지웠다).
|
|
13
|
+
* 여기에 무언가를 올릴 때는 `docs/BENCH.md` 의 M3(핵심까지 가는 데 몇 번
|
|
14
|
+
* 눌렀나)를 앞당기는지 먼저 묻는다. 앞당기지 않으면 올리지 않는다.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 가운데 칸은 별도 모듈이 그린다.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 이 파일에 밀어 넣지 않는 이유: 옛 화면이 3,527줄까지 자란 것이 정확히
|
|
21
|
+
* "한 파일에 하나씩 덧대기" 의 결과였다. 칸이 하나 늘면 파일도 하나 는다.
|
|
22
|
+
*/
|
|
23
|
+
import { drawStage, watchStage } from './stage.js'
|
|
24
|
+
|
|
25
|
+
const $ = (s) => document.querySelector(s)
|
|
26
|
+
|
|
27
|
+
const S = {
|
|
28
|
+
/** 지금 열어 둔 세션 id */
|
|
29
|
+
cur: null,
|
|
30
|
+
/** 이 세션에서 어디까지 받아 갔나. 매번 전부 받으면 화면이 느려진다. */
|
|
31
|
+
since: -1,
|
|
32
|
+
state: 'idle',
|
|
33
|
+
agent: null,
|
|
34
|
+
agentName: null,
|
|
35
|
+
agents: [],
|
|
36
|
+
/** 지금 보고 있는 저장소의 절대 경로 */
|
|
37
|
+
root: null,
|
|
38
|
+
/** 지금 도는 코드가 어느 빌드인가. 낡은 설치본을 알아차리게 한다 */
|
|
39
|
+
version: null,
|
|
40
|
+
sending: false,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const api = async (path, opt) => {
|
|
44
|
+
const r = await fetch(path, opt)
|
|
45
|
+
const body = await r.json().catch(() => ({ error: '응답을 읽지 못했습니다' }))
|
|
46
|
+
if (!r.ok) throw new Error(body.error ?? `HTTP ${r.status}`)
|
|
47
|
+
return body
|
|
48
|
+
}
|
|
49
|
+
const post = (path, body) => api(path, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(body ?? {}),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 대화가 비었는가. 인사말과 입력창을 칸 가운데로 띄울지 정한다.
|
|
57
|
+
* 상태를 CSS 클래스 하나로 두어 배치 규칙이 전부 `shell.css` 에 남게 한다 —
|
|
58
|
+
* 자바스크립트가 좌표를 계산하기 시작하면 반응형이 곧바로 깨진다.
|
|
59
|
+
*/
|
|
60
|
+
const blank = (on) => $('#chat').classList.toggle('blank', on)
|
|
61
|
+
|
|
62
|
+
const el = (tag, cls, text) => {
|
|
63
|
+
const n = document.createElement(tag)
|
|
64
|
+
if (cls) n.className = cls
|
|
65
|
+
if (text != null) n.textContent = text
|
|
66
|
+
return n
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 왼쪽 ────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function tabs() {
|
|
72
|
+
for (const t of document.querySelectorAll('.tab')) {
|
|
73
|
+
t.onclick = () => {
|
|
74
|
+
for (const o of document.querySelectorAll('.tab')) o.classList.toggle('on', o === t)
|
|
75
|
+
/**
|
|
76
|
+
* 🔴 아직 아무 일도 하지 않는다. 일부러 그렇다.
|
|
77
|
+
* 감시 · 이해가 각각 가운데 칸에 무엇을 그릴지는 아직 안 정했다.
|
|
78
|
+
* 안 정한 것을 정한 것처럼 배선해 두면 나중에 그 배선을 먼저 풀어야 한다.
|
|
79
|
+
*/
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function refreshChats() {
|
|
85
|
+
let list
|
|
86
|
+
try { ({ sessions: list } = await api('/api/sessions')) } catch { return }
|
|
87
|
+
|
|
88
|
+
const box = $('#chats')
|
|
89
|
+
box.replaceChildren()
|
|
90
|
+
if (!list.length) { box.append(el('div', 'empty', '아직 없습니다')); return }
|
|
91
|
+
|
|
92
|
+
for (const s of list) {
|
|
93
|
+
const cls = ['chat', s.id === S.cur && 'on', s.state === 'running' && 'run', s.state === 'error' && 'err']
|
|
94
|
+
const row = el('div', cls.filter(Boolean).join(' '))
|
|
95
|
+
row.append(el('span', 'dot'))
|
|
96
|
+
row.append(el('span', 'name', s.title))
|
|
97
|
+
// 고친 파일 수. 세어서 보이는 것 자체가 이 제품의 요점이다 (D1 의 F2).
|
|
98
|
+
if (s.touched) row.append(el('span', 'n', String(s.touched)))
|
|
99
|
+
row.title = `${s.agentName} · ${s.cwd}`
|
|
100
|
+
row.onclick = () => open(s.id)
|
|
101
|
+
box.append(row)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 대화 ────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async function open(id) {
|
|
108
|
+
if (S.cur === id) return
|
|
109
|
+
S.cur = id
|
|
110
|
+
S.since = -1
|
|
111
|
+
$('#thread').replaceChildren()
|
|
112
|
+
await pump()
|
|
113
|
+
await refreshChats()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function create() {
|
|
117
|
+
if (!S.agent) return null
|
|
118
|
+
try {
|
|
119
|
+
const s = await post('/api/sessions', { agent: S.agent })
|
|
120
|
+
S.cur = s.id
|
|
121
|
+
S.since = -1
|
|
122
|
+
$('#thread').replaceChildren()
|
|
123
|
+
await refreshChats()
|
|
124
|
+
return s.id
|
|
125
|
+
} catch (e) { fail(e.message); return null }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 서버에 밀린 메시지를 받아 온다. */
|
|
129
|
+
async function pump() {
|
|
130
|
+
if (!S.cur) return
|
|
131
|
+
let v
|
|
132
|
+
try { v = await api(`/api/session/${S.cur}?since=${S.since}`) } catch { return }
|
|
133
|
+
|
|
134
|
+
S.state = v.state
|
|
135
|
+
$('#title').textContent = v.title ?? ''
|
|
136
|
+
$('#stop').hidden = v.state !== 'running'
|
|
137
|
+
|
|
138
|
+
if (v.messages.length) {
|
|
139
|
+
for (const m of v.messages) { S.since = m.seq; draw(m) }
|
|
140
|
+
const t = $('#thread')
|
|
141
|
+
t.scrollTop = t.scrollHeight
|
|
142
|
+
}
|
|
143
|
+
touched(v.touched)
|
|
144
|
+
think(v.state === 'running')
|
|
145
|
+
$('#send').disabled = v.state === 'running' || !S.agent
|
|
146
|
+
$('#hello').hidden = true
|
|
147
|
+
blank(false)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function draw(m) {
|
|
151
|
+
const t = $('#thread')
|
|
152
|
+
|
|
153
|
+
if (m.role === 'tool') {
|
|
154
|
+
const n = el('div', `msg tool${m.edits ? ' edits' : ''}`)
|
|
155
|
+
n.append(el('span', 't', m.edits ? '고침' : m.tool))
|
|
156
|
+
n.append(el('span', 'a', m.text || m.tool))
|
|
157
|
+
n.title = `${m.tool}${m.file ? ` · ${m.file}` : ''}`
|
|
158
|
+
t.append(n)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (m.role === 'meta' || m.role === 'raw') {
|
|
163
|
+
// 버리지 않고 접어 둔다 — 사용량 한도에 걸려 멈춘 것인지 알 방법이 있어야 한다.
|
|
164
|
+
const d = el('details', 'msg')
|
|
165
|
+
d.append(el('summary', null, m.role === 'meta' ? '내부 신호' : '해석 못 한 출력'))
|
|
166
|
+
d.append(el('pre', null, m.text))
|
|
167
|
+
t.append(d)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
t.append(el('div', `msg ${m.role}`, m.text))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 🔴 AI 가 고친 파일. 접지 않는다.
|
|
176
|
+
*
|
|
177
|
+
* 터미널의 겉모습은 감춰도 결과를 감추면, 이 제품이 없애려던 공포
|
|
178
|
+
* (DECISIONS.md D1 의 F2 — "AI 가 뭘 망가뜨렸는지 모른다")를 이 제품이
|
|
179
|
+
* 직접 만들어내게 된다. 채팅으로 포장할수록 이 줄이 더 중요해진다.
|
|
180
|
+
*/
|
|
181
|
+
function touched(list) {
|
|
182
|
+
const box = $('#touched')
|
|
183
|
+
if (!list?.length) { box.hidden = true; box.replaceChildren(); return }
|
|
184
|
+
box.replaceChildren()
|
|
185
|
+
box.hidden = false
|
|
186
|
+
const head = el('span')
|
|
187
|
+
head.append(document.createTextNode('이 대화가 고친 파일 '))
|
|
188
|
+
head.append(el('b', null, String(list.length)))
|
|
189
|
+
head.append(document.createTextNode('개'))
|
|
190
|
+
box.append(head)
|
|
191
|
+
for (const t of list.slice(0, 6)) box.append(el('span', 'f', `${t.file}${t.n > 1 ? ` ×${t.n}` : ''}`))
|
|
192
|
+
if (list.length > 6) box.append(el('span', 'f', `… 그 밖에 ${list.length - 6}개`))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function think(on) {
|
|
196
|
+
const t = $('#thread')
|
|
197
|
+
const cur = t.querySelector('.think')
|
|
198
|
+
if (on && !cur) { t.append(el('div', 'think', '작업하는 중')); t.scrollTop = t.scrollHeight }
|
|
199
|
+
if (!on && cur) cur.remove()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fail(text) {
|
|
203
|
+
$('#hello').hidden = true
|
|
204
|
+
blank(false)
|
|
205
|
+
$('#thread').append(el('div', 'msg error', text))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function send() {
|
|
209
|
+
const box = $('#input')
|
|
210
|
+
const text = box.value.trim()
|
|
211
|
+
if (!text || S.sending) return
|
|
212
|
+
|
|
213
|
+
// 세션이 없으면 조용히 하나 만든다. 사용자는 "새 대화" 를 의식할 필요가 없다.
|
|
214
|
+
if (!S.cur && !await create()) return
|
|
215
|
+
|
|
216
|
+
S.sending = true
|
|
217
|
+
box.value = ''
|
|
218
|
+
box.style.height = 'auto'
|
|
219
|
+
$('#send').disabled = true
|
|
220
|
+
$('#hello').hidden = true
|
|
221
|
+
blank(false)
|
|
222
|
+
try {
|
|
223
|
+
await post(`/api/session/${S.cur}/send`, { prompt: text })
|
|
224
|
+
} catch (e) {
|
|
225
|
+
fail(e.message)
|
|
226
|
+
box.value = text // 사용자가 쓴 말을 잃지 않는다
|
|
227
|
+
} finally { S.sending = false }
|
|
228
|
+
await pump()
|
|
229
|
+
await refreshChats()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── 분할선 ──────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function gutters() {
|
|
235
|
+
for (const g of document.querySelectorAll('.gut')) {
|
|
236
|
+
const pane = g.dataset.pane === 'side' ? $('#side') : $('#chat')
|
|
237
|
+
const fromLeft = g.dataset.pane === 'side'
|
|
238
|
+
let on = false
|
|
239
|
+
g.addEventListener('mousedown', (e) => { on = true; e.preventDefault() })
|
|
240
|
+
window.addEventListener('mousemove', (e) => {
|
|
241
|
+
if (!on) return
|
|
242
|
+
const w = fromLeft ? e.clientX : window.innerWidth - e.clientX
|
|
243
|
+
pane.style.width = `${Math.max(200, Math.min(900, w))}px`
|
|
244
|
+
})
|
|
245
|
+
window.addEventListener('mouseup', () => { on = false })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── 입력 ────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function input() {
|
|
252
|
+
const box = $('#input')
|
|
253
|
+
box.addEventListener('input', () => {
|
|
254
|
+
box.style.height = 'auto'
|
|
255
|
+
box.style.height = `${Math.min(200, box.scrollHeight)}px`
|
|
256
|
+
})
|
|
257
|
+
box.addEventListener('keydown', (e) => {
|
|
258
|
+
// Enter 로 보내고 Shift+Enter 로 줄을 바꾼다. 채팅의 관습 그대로다.
|
|
259
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send() }
|
|
260
|
+
})
|
|
261
|
+
$('#send').onclick = send
|
|
262
|
+
$('#new').onclick = async () => {
|
|
263
|
+
S.cur = null
|
|
264
|
+
S.since = -1
|
|
265
|
+
$('#thread').replaceChildren()
|
|
266
|
+
$('#touched').hidden = true
|
|
267
|
+
$('#title').textContent = ''
|
|
268
|
+
$('#hello').hidden = false
|
|
269
|
+
blank(true)
|
|
270
|
+
await refreshChats()
|
|
271
|
+
$('#input').focus()
|
|
272
|
+
}
|
|
273
|
+
$('#stop').onclick = async () => {
|
|
274
|
+
if (!S.cur) return
|
|
275
|
+
try { await post(`/api/session/${S.cur}/stop`) } catch { /* 이미 끝났으면 그만 */ }
|
|
276
|
+
await pump()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── 시작 ────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async function agents() {
|
|
283
|
+
try {
|
|
284
|
+
const r = await api('/api/agents')
|
|
285
|
+
S.agent = r.preferred
|
|
286
|
+
const pick = r.agents.find((a) => a.id === r.preferred)
|
|
287
|
+
S.agentName = pick?.name ?? null
|
|
288
|
+
S.agents = r.agents
|
|
289
|
+
/**
|
|
290
|
+
* 🔴 못 쓰는 이유를 **대화를 시작하기 전에** 말한다.
|
|
291
|
+
* 터미널을 감춰 놨기 때문에, 로그인이 안 된 채로 프롬프트를 보내면
|
|
292
|
+
* 사용자에게는 그냥 아무 일도 안 일어난 것으로 보인다.
|
|
293
|
+
* 그 침묵이 이 설계에서 가장 위험한 실패 모드다.
|
|
294
|
+
*/
|
|
295
|
+
if (!pick) {
|
|
296
|
+
$('#agent').textContent = 'AI CLI 없음 — claude · agy · codex 중 하나를 설치하세요'
|
|
297
|
+
$('#send').disabled = true
|
|
298
|
+
return S.agents
|
|
299
|
+
}
|
|
300
|
+
$('#agent').textContent = pick.signedIn === 'unknown'
|
|
301
|
+
? `${pick.name} · 로그인 상태를 확인하지 못했습니다`
|
|
302
|
+
: pick.name
|
|
303
|
+
} catch (e) {
|
|
304
|
+
$('#agent').textContent = `AI CLI 를 확인하지 못했습니다 — ${e.message}`
|
|
305
|
+
$('#send').disabled = true
|
|
306
|
+
}
|
|
307
|
+
return S.agents ?? []
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function repo() {
|
|
311
|
+
try {
|
|
312
|
+
const g = await api('/api/graph')
|
|
313
|
+
const name = String(g.root ?? '').split(/[\\/]/).filter(Boolean).pop() ?? ''
|
|
314
|
+
S.root = g.root ?? null
|
|
315
|
+
$('#repo').textContent = name
|
|
316
|
+
$('#repo').title = g.root ?? ''
|
|
317
|
+
// 툴바 가운데가 창 제목 노릇을 한다 — VS Code 가 파일 이름을 두는 자리다
|
|
318
|
+
$('#where').textContent = g.root ?? ''
|
|
319
|
+
// 가운데 칸의 내용물은 `stage.js` 가 그린다. 여기는 이름만 적는다.
|
|
320
|
+
drawStage()
|
|
321
|
+
} catch { /* 대상을 못 읽으면 stage 가 스스로 이유를 말한다 */ }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function init() {
|
|
325
|
+
tabs()
|
|
326
|
+
input()
|
|
327
|
+
gutters()
|
|
328
|
+
watchStage()
|
|
329
|
+
blank(true)
|
|
330
|
+
$('#note').textContent = 'Enter 로 보내기 · Shift+Enter 줄바꿈'
|
|
331
|
+
|
|
332
|
+
toolbar()
|
|
333
|
+
const list = await agents()
|
|
334
|
+
await refreshChats()
|
|
335
|
+
try { S.version = (await api('/api/version')).label } catch { /* 없으면 그만 */ }
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 🔴 온보딩이 끝나기 전에는 **아무것도 분석하지 않는다.**
|
|
339
|
+
*
|
|
340
|
+
* 예전에는 시작하자마자 `/api/graph` 를 불렀다. 서버는 늘 어떤 대상을 갖고
|
|
341
|
+
* 있으므로(기본값이 실행 디렉터리다) 사용자가 아무것도 고르지 않았는데도
|
|
342
|
+
* "axMap · 파일 77개" 가 떴다. 묻지도 않고 남의 폴더를 읽어 놓고
|
|
343
|
+
* 그 결과를 보여준 셈이다 — 무엇을 보고 있는지 사용자가 모른다.
|
|
344
|
+
*/
|
|
345
|
+
if (await onboarding(list)) await repo()
|
|
346
|
+
|
|
347
|
+
const tick = async () => {
|
|
348
|
+
await pump()
|
|
349
|
+
await refreshChats()
|
|
350
|
+
setTimeout(tick, S.state === 'running' ? 600 : 3000)
|
|
351
|
+
}
|
|
352
|
+
setTimeout(tick, 800)
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 🔴 여기까지 왔다는 표식. `tools/smoke.mjs` 가 이것을 본다.
|
|
356
|
+
* 부트스트랩이 중간에 죽으면 화면이 조용히 빈 채로 뜨고, 문법 검사도 단위
|
|
357
|
+
* 테스트도 그것을 못 잡는다 — 이 저장소가 하룻밤에 두 번 겪은 일이다.
|
|
358
|
+
*/
|
|
359
|
+
document.body.dataset.shell = 'ready'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
// ── 툴바 = 창 테두리 ────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 🔴 메뉴 이름을 글자로 늘어놓지 않는다.
|
|
367
|
+
*
|
|
368
|
+
* `저장소`·`보기`·`도움말` 셋을 나란히 두면 창 위에 띠가 하나 더 얹힌 것처럼
|
|
369
|
+
* 보이고, 그 줄은 낭비다. 아이콘으로 대신할 수 있는 것은 아이콘으로 옮기고
|
|
370
|
+
* 나머지는 전부 `☰` 하나에 접었다.
|
|
371
|
+
*
|
|
372
|
+
* 항목이 늘어나도 여기만 늘어난다 — 막대는 그대로다.
|
|
373
|
+
*/
|
|
374
|
+
const MENU = () => [
|
|
375
|
+
['저장소 열기…', 'Ctrl+O', () => openStep()],
|
|
376
|
+
['사이드바 접기·펴기', 'Ctrl+B', () => toggleSide()],
|
|
377
|
+
null,
|
|
378
|
+
['감시', '', () => document.querySelector('.tab[data-mode="watch"]').click()],
|
|
379
|
+
['이해', '', () => document.querySelector('.tab[data-mode="understand"]').click()],
|
|
380
|
+
['새 대화', '', () => $('#new').click()],
|
|
381
|
+
null,
|
|
382
|
+
['새로고침', 'Ctrl+R', () => location.reload()],
|
|
383
|
+
['처음 설정 다시 하기', '', () => resetOnboard()],
|
|
384
|
+
null,
|
|
385
|
+
[`대상: ${S.root ?? '—'}`, '', null],
|
|
386
|
+
[`AI: ${S.agentName ?? '—'}`, '', null],
|
|
387
|
+
/**
|
|
388
|
+
* 🔴 지금 도는 것이 어느 빌드인가.
|
|
389
|
+
* 바탕화면 아이콘은 마지막으로 **빌드한** 것을 가리킨다. 커밋만 하고 다시
|
|
390
|
+
* 빌드하지 않으면 옛것이 계속 열린다 — 그것 때문에 한 번 헤맸다.
|
|
391
|
+
*/
|
|
392
|
+
[`버전: ${S.version ?? '—'}`, '', null],
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
function closeDrop() {
|
|
396
|
+
$('#drop').hidden = true
|
|
397
|
+
for (const m of document.querySelectorAll('.ico[data-menu]')) m.classList.remove('open')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function openDrop(btn) {
|
|
401
|
+
const drop = $('#drop')
|
|
402
|
+
drop.replaceChildren()
|
|
403
|
+
for (const it of MENU()) {
|
|
404
|
+
if (!it) { drop.append(document.createElement('hr')); continue }
|
|
405
|
+
const [label, key, run] = it
|
|
406
|
+
const b = el('button', null, label)
|
|
407
|
+
if (key) b.append(el('span', 'k', key))
|
|
408
|
+
if (run) b.onclick = () => { closeDrop(); run() }
|
|
409
|
+
else b.disabled = true
|
|
410
|
+
drop.append(b)
|
|
411
|
+
}
|
|
412
|
+
const r = btn.getBoundingClientRect()
|
|
413
|
+
drop.style.left = `${r.left}px`
|
|
414
|
+
drop.style.top = `${r.bottom + 3}px`
|
|
415
|
+
drop.hidden = false
|
|
416
|
+
btn.classList.add('open')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 사이드바를 접어 가운데 칸에 자리를 준다. 배치 규칙은 전부 CSS 에 둔다. */
|
|
420
|
+
function toggleSide() {
|
|
421
|
+
document.body.classList.toggle('no-side')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toolbar() {
|
|
425
|
+
for (const m of document.querySelectorAll('.ico[data-menu]')) {
|
|
426
|
+
m.onclick = (e) => {
|
|
427
|
+
e.stopPropagation()
|
|
428
|
+
if (m.classList.contains('open')) closeDrop()
|
|
429
|
+
else openDrop(m)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
$('#sidetoggle').onclick = toggleSide
|
|
433
|
+
$('#openrepo').onclick = () => openStep()
|
|
434
|
+
|
|
435
|
+
window.addEventListener('click', closeDrop)
|
|
436
|
+
window.addEventListener('keydown', (e) => {
|
|
437
|
+
if (e.key === 'Escape') { closeDrop(); closeModal() }
|
|
438
|
+
// VS Code 와 같은 자리에 둔다. 손이 이미 기억하고 있는 조합이다.
|
|
439
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'o') { e.preventDefault(); openStep() }
|
|
440
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault(); toggleSide() }
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 창 단추는 데스크톱에서만 보인다.
|
|
445
|
+
* 브라우저에는 이미 창 단추가 있고, 우리가 하나 더 그리면 두 벌이 된다.
|
|
446
|
+
*/
|
|
447
|
+
const sh = window.axmapShell
|
|
448
|
+
if (!sh?.window) return
|
|
449
|
+
$('#wctl').hidden = false
|
|
450
|
+
for (const b of document.querySelectorAll('#wctl button')) {
|
|
451
|
+
b.onclick = () => sh.window(b.dataset.win)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── 처음 오는 사람 ──────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
const PREF = 'axmap.agent'
|
|
458
|
+
|
|
459
|
+
const step = (n) => {
|
|
460
|
+
for (const s of document.querySelectorAll('#onboard section')) s.hidden = s.dataset.step !== String(n)
|
|
461
|
+
const dots = [...document.querySelectorAll('.steps i')]
|
|
462
|
+
dots.forEach((d, i) => d.classList.toggle('on', i < n))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 🔴 설치·로그인 상태를 카드에 **그대로 적는다.**
|
|
467
|
+
*
|
|
468
|
+
* 터미널을 감춰 놨기 때문에, 못 쓰는 CLI 를 고르면 첫 프롬프트에서 아무 일도
|
|
469
|
+
* 안 일어난 것처럼 보인다. 그 침묵이 이 설계의 가장 위험한 실패 모드라
|
|
470
|
+
* 고르기 전에 말한다. 모르면 모른다고 적는다 (`unknown`).
|
|
471
|
+
*/
|
|
472
|
+
function drawPicks(list) {
|
|
473
|
+
const box = $('#picks')
|
|
474
|
+
box.replaceChildren()
|
|
475
|
+
for (const a of list) {
|
|
476
|
+
const b = el('button', 'pick')
|
|
477
|
+
b.append(el('span', 'nm', a.name))
|
|
478
|
+
const st = a.installed
|
|
479
|
+
? (a.signedIn === 'yes' ? ['ok', '준비됨']
|
|
480
|
+
: a.signedIn === 'unknown' ? ['warn', '로그인 확인 못 함'] : ['warn', '로그인 필요'])
|
|
481
|
+
: ['', '설치되어 있지 않음']
|
|
482
|
+
b.append(el('span', `st ${st[0]}`, st[1] + (a.unverified ? ' · 미검증' : '')))
|
|
483
|
+
b.disabled = !a.installed
|
|
484
|
+
b.onclick = () => {
|
|
485
|
+
for (const o of box.children) o.classList.remove('on')
|
|
486
|
+
b.classList.add('on')
|
|
487
|
+
S.agent = a.id
|
|
488
|
+
S.agentName = a.name
|
|
489
|
+
$('#s1next').disabled = false
|
|
490
|
+
}
|
|
491
|
+
if (a.id === S.agent) { b.classList.add('on'); $('#s1next').disabled = false }
|
|
492
|
+
box.append(b)
|
|
493
|
+
}
|
|
494
|
+
if (!list.some((a) => a.installed)) {
|
|
495
|
+
box.append(el('div', 'empty', 'claude · agy · codex 중 하나를 설치한 뒤 다시 열어 주세요.'))
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 저장소 열기 — **모달**로 띄운다.
|
|
501
|
+
*
|
|
502
|
+
* 🔴 처음 오는 사람에게는 화면 전체를 쓰고, 그 뒤로는 모달이다.
|
|
503
|
+
*
|
|
504
|
+
* 처음에는 다른 할 일이 없으니 전체를 써도 되지만, 이미 일하고 있는 사람에게
|
|
505
|
+
* 화면을 통째로 뺏으면 하던 대화가 사라진 것처럼 보인다. 같은 markup 을 쓰고
|
|
506
|
+
* 겉모습만 바꾼다 — 두 벌을 만들면 한쪽만 고쳐서 어긋난다.
|
|
507
|
+
*/
|
|
508
|
+
function openStep() {
|
|
509
|
+
$('#onboard').classList.add('as-modal')
|
|
510
|
+
$('#onboard').hidden = false
|
|
511
|
+
step(2)
|
|
512
|
+
setTimeout(() => $('#url').focus(), 0)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function closeModal() {
|
|
516
|
+
// 전체화면(첫 실행)일 때는 닫히면 안 된다 — 고르기 전에는 할 수 있는 게 없다.
|
|
517
|
+
if (!$('#onboard').classList.contains('as-modal')) return
|
|
518
|
+
$('#onboard').hidden = true
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function resetOnboard() {
|
|
522
|
+
try { localStorage.removeItem(PREF) } catch { /* 사생활 모드면 그만 */ }
|
|
523
|
+
$('#onboard').classList.remove('as-modal')
|
|
524
|
+
$('#onboard').hidden = false
|
|
525
|
+
step(1)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 저장소를 연다. 서버는 **받아 오기만** 하고 대상을 바꾸지 않는다 —
|
|
530
|
+
* 이유는 `/api/open` 의 주석에 있다. 바꾸는 것은 셸이 프로세스를 갈아끼워서 한다.
|
|
531
|
+
*/
|
|
532
|
+
async function openRepo(target) {
|
|
533
|
+
const msg = $('#omsg')
|
|
534
|
+
msg.className = ''
|
|
535
|
+
msg.textContent = '확인하는 중…'
|
|
536
|
+
$('#go').disabled = true
|
|
537
|
+
try {
|
|
538
|
+
let r = await post('/api/open', { target })
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* git 주소면 서버가 자식에게 클론을 맡기고 **작업 id 만** 돌려준다.
|
|
542
|
+
* 여기서 끝날 때까지 기다리되, 진행 상황을 그대로 보여준다 —
|
|
543
|
+
* 처음 받는 저장소는 몇 분이 걸리고, 그동안 아무 말도 없으면 멈춘 줄 안다.
|
|
544
|
+
*/
|
|
545
|
+
if (r.job) {
|
|
546
|
+
const current = r.current
|
|
547
|
+
for (;;) {
|
|
548
|
+
await new Promise((s) => setTimeout(s, 700))
|
|
549
|
+
const j = await api(`/api/open/status?job=${encodeURIComponent(r.job)}`)
|
|
550
|
+
if (j.state === 'error') throw new Error(j.error ?? '받아오지 못했습니다')
|
|
551
|
+
msg.textContent = j.log.at(-1) ?? '받아오는 중…'
|
|
552
|
+
if (j.state === 'done') { r = { root: j.root, current, commits: j.commits }; break }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (r.root === r.current) {
|
|
557
|
+
msg.textContent = '이미 이 저장소를 보고 있습니다.'
|
|
558
|
+
finishOnboard()
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const sh = window.axmapShell
|
|
562
|
+
if (sh?.openPath) {
|
|
563
|
+
msg.textContent = '여는 중…'
|
|
564
|
+
await sh.openPath(r.root) // 셸이 서버를 갈아끼운다. 곧 화면이 새로 뜬다
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* 브라우저에서는 프로세스를 갈아끼울 수 없다. 숨기지 않고 그대로 말한다 —
|
|
569
|
+
* 조용히 이전 저장소를 계속 보여주는 것이 훨씬 나쁘다.
|
|
570
|
+
*/
|
|
571
|
+
msg.className = 'err'
|
|
572
|
+
msg.textContent = `받았습니다: ${r.root}\n브라우저에서는 서버를 다시 띄워야 합니다 — 데스크톱 앱에서는 바로 열립니다.`
|
|
573
|
+
} catch (e) {
|
|
574
|
+
msg.className = 'err'
|
|
575
|
+
msg.textContent = e.message
|
|
576
|
+
} finally { $('#go').disabled = false }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function finishOnboard() {
|
|
580
|
+
try { if (S.agent) localStorage.setItem(PREF, S.agent) } catch { /* 그만 */ }
|
|
581
|
+
$('#onboard').hidden = true
|
|
582
|
+
$('#input').focus()
|
|
583
|
+
// 🔴 여기서 **처음으로** 저장소를 읽는다. 이유는 `repo()` 주석 참고.
|
|
584
|
+
repo()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function onboarding(list) {
|
|
588
|
+
let saved = null
|
|
589
|
+
try { saved = localStorage.getItem(PREF) } catch { /* 사생활 모드 */ }
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* `?agent=claude` — 고를 값을 주소로 심는다.
|
|
593
|
+
*
|
|
594
|
+
* 왜 있나. 두 가지 다 진짜 쓰임이다.
|
|
595
|
+
* · 팀에서 쓰는 CLI 가 정해져 있으면 그 주소를 공유하면 된다.
|
|
596
|
+
* 받는 사람은 1단계를 안 본다
|
|
597
|
+
* · 🔴 `npm run smoke` 가 **온보딩 뒤의 화면**에 닿는 유일한 길이다.
|
|
598
|
+
* 헤드리스는 클릭을 못 하고 localStorage 도 비어 있어서, 이것이 없으면
|
|
599
|
+
* 가운데 칸 렌더러가 검사에서 통째로 빠진다 — 검사기가 눈이 머는 것이 최악이다
|
|
600
|
+
*
|
|
601
|
+
* 설치돼 있지 않은 값이면 **무시한다.** 주소에 적혔다고 없는 CLI 를
|
|
602
|
+
* 있다고 치면 첫 프롬프트에서 조용히 멈춘다.
|
|
603
|
+
*/
|
|
604
|
+
try {
|
|
605
|
+
const want = new URLSearchParams(location.search).get('agent')
|
|
606
|
+
if (want && list.some((x) => x.id === want && x.installed)) {
|
|
607
|
+
saved = want
|
|
608
|
+
try { localStorage.setItem(PREF, want) } catch { /* 그만 */ }
|
|
609
|
+
}
|
|
610
|
+
} catch { /* 주소가 이상하면 그냥 묻는다 */ }
|
|
611
|
+
|
|
612
|
+
// 저장된 선택이 아직 쓸 수 있으면 묻지 않는다. 매번 묻는 것도 실패다.
|
|
613
|
+
const ok = list.find((a) => a.id === saved && a.installed)
|
|
614
|
+
if (ok) { S.agent = ok.id; S.agentName = ok.name; return true }
|
|
615
|
+
|
|
616
|
+
S.agent = null
|
|
617
|
+
drawPicks(list)
|
|
618
|
+
step(1)
|
|
619
|
+
$('#onboard').hidden = false
|
|
620
|
+
|
|
621
|
+
$('#s1next').onclick = () => step(2)
|
|
622
|
+
$('#s2back').onclick = () => step(1)
|
|
623
|
+
$('#skip').onclick = finishOnboard
|
|
624
|
+
$('#go').onclick = () => openRepo($('#url').value.trim())
|
|
625
|
+
$('#url').onkeydown = (e) => { if (e.key === 'Enter') $('#go').click() }
|
|
626
|
+
$('#oclose').onclick = closeModal
|
|
627
|
+
return false
|
|
628
|
+
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* 🔴 여기가 파일의 **맨 끝**이어야 한다.
|
|
633
|
+
*
|
|
634
|
+
* 위쪽 어딘가에서 부르면 그 아래의 `const`(MENUS · PREF …)가 TDZ 에 걸려
|
|
635
|
+
* 부트스트랩이 통째로 죽는다. 문법 검사도 단위 테스트도 통과하고 화면만 빈다 —
|
|
636
|
+
* 이 저장소가 하룻밤에 두 번 겪은 실패가 정확히 이것이다.
|
|
637
|
+
*/
|
|
638
|
+
init()
|