amorphous-cli 0.1.6 → 0.1.8

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/lib/serve.mjs ADDED
@@ -0,0 +1,360 @@
1
+ import { createServer } from 'node:http'
2
+ import { existsSync, readFileSync, watch } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { apiFetch } from './api.mjs'
5
+ import { cliAbstractsFetchOrigin, cliAbstractsHostHeader } from './config.mjs'
6
+ import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
7
+
8
+ const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
9
+ #amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
10
+ #amorphous-serve button[aria-pressed="true"]{background:#fff;color:#18181b}`
11
+
12
+ function stripScriptTags(html) {
13
+ return String(html || '').replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
14
+ }
15
+
16
+ function readCookie(req, name) {
17
+ const raw = req.headers.cookie || ''
18
+ const match = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(raw)
19
+ return match ? decodeURIComponent(match[1]) : ''
20
+ }
21
+
22
+ function recordsMode(req, url) {
23
+ const query = url.searchParams.get('records')
24
+ if (query === 'prod' || query === 'local') return query
25
+ const cookie = readCookie(req, 'amorphous_records')
26
+ return cookie === 'prod' ? 'prod' : 'local'
27
+ }
28
+
29
+ async function readJson(req) {
30
+ const chunks = []
31
+ for await (const chunk of req) chunks.push(chunk)
32
+ const text = Buffer.concat(chunks).toString('utf8')
33
+ if (!text) return {}
34
+ return JSON.parse(text)
35
+ }
36
+
37
+ function send(res, status, body, headers = {}) {
38
+ const payload = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body)
39
+ res.writeHead(status, {
40
+ 'Cache-Control': 'no-store',
41
+ ...headers,
42
+ ...(payload && typeof body !== 'string' ? { 'Content-Type': 'application/json; charset=utf-8' } : {}),
43
+ })
44
+ res.end(payload)
45
+ }
46
+
47
+ function pageHtml(html, mode) {
48
+ const pressed = (value) => (mode === value ? ' aria-pressed="true"' : '')
49
+ return `<!DOCTYPE html>
50
+ <html>
51
+ <head>
52
+ <meta charset="utf-8">
53
+ <link rel="stylesheet" href="/styles.css">
54
+ <style>${BAR_CSS}</style>
55
+ </head>
56
+ <body>
57
+ <div id="amorphous-root">${stripScriptTags(html)}</div>
58
+ <div id="amorphous-serve" data-mode="${mode}">
59
+ <span>Records</span>
60
+ <button type="button" data-set="local"${pressed('local')}>Local</button>
61
+ <button type="button" data-set="prod"${pressed('prod')}>Prod</button>
62
+ </div>
63
+ <script>
64
+ window.amorphous = {
65
+ login() {},
66
+ async me() {
67
+ const res = await fetch('/__amorphous/me')
68
+ if (!res.ok || res.status === 204) return null
69
+ const text = await res.text()
70
+ if (!text) return null
71
+ return JSON.parse(text)
72
+ },
73
+ async names(ids) {
74
+ const res = await fetch('/__amorphous/names', {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ ids })
78
+ })
79
+ if (!res.ok) return { names: {} }
80
+ return res.json()
81
+ },
82
+ data: {
83
+ async find() {
84
+ const res = await fetch('/__amorphous/data')
85
+ if (!res.ok) return []
86
+ const payload = await res.json()
87
+ return payload.records || []
88
+ },
89
+ async insert(row) {
90
+ const body = { ...row }
91
+ if (!body.id && crypto.randomUUID) body.id = crypto.randomUUID()
92
+ const res = await fetch('/__amorphous/data', {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify(body)
96
+ })
97
+ if (!res.ok) throw new Error('insert failed: ' + res.status)
98
+ return body
99
+ },
100
+ async replace(id, row) {
101
+ const res = await fetch('/__amorphous/data/' + encodeURIComponent(id), {
102
+ method: 'PUT',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify(row)
105
+ })
106
+ if (!res.ok) throw new Error('replace failed: ' + res.status)
107
+ return row
108
+ }
109
+ }
110
+ }
111
+ document.getElementById('amorphous-serve').addEventListener('click', function (event) {
112
+ var mode = event.target && event.target.getAttribute('data-set')
113
+ if (!mode) return
114
+ fetch('/__amorphous/mode', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ mode: mode })
118
+ }).then(function () { location.reload() })
119
+ })
120
+ new EventSource('/__amorphous/events').addEventListener('reload', function () {
121
+ location.reload()
122
+ })
123
+ </script>
124
+ <script src="/app.js"></script>
125
+ </body>
126
+ </html>`
127
+ }
128
+
129
+ function passFromResponse(res) {
130
+ const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
131
+ const lines = list.length ? list : [res.headers.get('set-cookie') || '']
132
+ for (const line of lines) {
133
+ const match = /(?:^|,\s*)am_pass=([^;]+)/.exec(line)
134
+ if (match) return `am_pass=${match[1]}`
135
+ }
136
+ return null
137
+ }
138
+
139
+ export async function mintAbstractPass(abstractId) {
140
+ const data = await apiFetch(`/api/abstracts/${abstractId}/code`, { method: 'POST', body: '{}' })
141
+ const code = data?.code
142
+ if (!code) throw new Error('Could not open the abstract records.')
143
+ const res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}?code=${encodeURIComponent(code)}`, {
144
+ redirect: 'manual',
145
+ headers: { ...cliAbstractsHostHeader() },
146
+ })
147
+ const cookie = passFromResponse(res)
148
+ if (!cookie) throw new Error('Could not open the abstract records.')
149
+ return cookie
150
+ }
151
+
152
+ async function proxyAbstract(abstractId, path, init, session) {
153
+ const headers = {
154
+ ...cliAbstractsHostHeader(),
155
+ ...(init.headers || {}),
156
+ }
157
+ if (session.cookie) headers.Cookie = session.cookie
158
+ let res
159
+ try {
160
+ res = await fetch(`${cliAbstractsFetchOrigin()}/${abstractId}${path}`, {
161
+ method: init.method || 'GET',
162
+ headers,
163
+ body: init.body,
164
+ redirect: 'manual',
165
+ })
166
+ } catch {
167
+ return new Response(JSON.stringify({ message: 'Could not reach the abstract.' }), {
168
+ status: 502,
169
+ headers: { 'Content-Type': 'application/json' },
170
+ })
171
+ }
172
+ if (res.status === 401 && session.cookie && !init.retried) {
173
+ session.cookie = null
174
+ session.cookie = await session.mint()
175
+ return proxyAbstract(abstractId, path, { ...init, retried: true }, session)
176
+ }
177
+ return res
178
+ }
179
+
180
+ export function startServe(options) {
181
+ const cwd = options.cwd
182
+ const abstractId = options.abstractId
183
+ const host = options.host || '127.0.0.1'
184
+ const session = {
185
+ cookie: null,
186
+ failed: false,
187
+ mint: options.mintPass || (() => mintAbstractPass(abstractId)),
188
+ }
189
+ const clients = new Set()
190
+ let reloadTimer = null
191
+
192
+ function notifyReload() {
193
+ clearTimeout(reloadTimer)
194
+ reloadTimer = setTimeout(() => {
195
+ for (const client of clients) client.write('event: reload\ndata: 1\n\n')
196
+ }, 60)
197
+ }
198
+
199
+ const watchers = []
200
+ for (const name of ['index.html', 'styles.css', 'app.js']) {
201
+ const path = join(cwd, name)
202
+ if (!existsSync(path)) continue
203
+ watchers.push(watch(path, notifyReload))
204
+ }
205
+
206
+ async function ensurePass() {
207
+ if (session.cookie) return session.cookie
208
+ if (session.failed) return null
209
+ try {
210
+ session.cookie = await session.mint()
211
+ return session.cookie
212
+ } catch {
213
+ session.failed = true
214
+ return null
215
+ }
216
+ }
217
+
218
+ async function prod(path, init) {
219
+ if (options.prodFetch) return options.prodFetch(abstractId, path, init, session)
220
+ const cookie = await ensurePass()
221
+ if (!cookie && init.auth !== false) {
222
+ return new Response(JSON.stringify({ message: 'Not signed in. Run: npx amorphous-cli@latest login' }), {
223
+ status: 401,
224
+ headers: { 'Content-Type': 'application/json' },
225
+ })
226
+ }
227
+ const res = await proxyAbstract(abstractId, path, { ...init, headers: { ...(init.headers || {}), Cookie: cookie || '' } }, session)
228
+ const text = await res.text()
229
+ return new Response(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') || 'application/json' } })
230
+ }
231
+
232
+ const server = createServer(async (req, res) => {
233
+ try {
234
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
235
+ if (req.method === 'GET' && url.pathname === '/') {
236
+ const mode = recordsMode(req, url)
237
+ const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
238
+ if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
239
+ headers['Set-Cookie'] = `amorphous_records=${mode}; Path=/; SameSite=Lax`
240
+ }
241
+ send(res, 200, pageHtml(readProjectFiles(cwd).html, mode), headers)
242
+ return
243
+ }
244
+ if (req.method === 'GET' && url.pathname === '/styles.css') {
245
+ send(res, 200, readProjectFiles(cwd).css, { 'Content-Type': 'text/css; charset=utf-8' })
246
+ return
247
+ }
248
+ if (req.method === 'GET' && url.pathname === '/app.js') {
249
+ send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
250
+ return
251
+ }
252
+ if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
253
+ res.writeHead(200, {
254
+ 'Content-Type': 'text/event-stream',
255
+ 'Cache-Control': 'no-cache',
256
+ Connection: 'keep-alive',
257
+ })
258
+ res.write(':\n\n')
259
+ clients.add(res)
260
+ req.on('close', () => clients.delete(res))
261
+ return
262
+ }
263
+ if (req.method === 'POST' && url.pathname === '/__amorphous/mode') {
264
+ const body = await readJson(req)
265
+ const mode = body.mode === 'prod' ? 'prod' : 'local'
266
+ send(res, 204, null, { 'Set-Cookie': `amorphous_records=${mode}; Path=/; SameSite=Lax` })
267
+ return
268
+ }
269
+ const mode = recordsMode(req, url)
270
+ if (req.method === 'GET' && url.pathname === '/__amorphous/me') {
271
+ const upstream = await prod('/api/me', { method: 'GET', auth: false })
272
+ const text = await upstream.text()
273
+ send(res, upstream.ok ? 200 : upstream.status, text, {
274
+ 'Content-Type': 'application/json; charset=utf-8',
275
+ })
276
+ return
277
+ }
278
+ if (req.method === 'POST' && url.pathname === '/__amorphous/names') {
279
+ const body = await readJson(req)
280
+ const upstream = await prod('/api/names', {
281
+ method: 'POST',
282
+ auth: false,
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({ ids: Array.isArray(body.ids) ? body.ids : [] }),
285
+ })
286
+ const text = await upstream.text()
287
+ send(res, upstream.ok ? 200 : upstream.status, text, {
288
+ 'Content-Type': 'application/json; charset=utf-8',
289
+ })
290
+ return
291
+ }
292
+ if (url.pathname === '/__amorphous/data' || url.pathname.startsWith('/__amorphous/data/')) {
293
+ if (mode === 'prod') {
294
+ const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
295
+ const path = req.method === 'PUT' ? `/api/data/${encodeURIComponent(recordId)}` : '/api/data'
296
+ const init = { method: req.method, auth: true }
297
+ if (req.method === 'POST' || req.method === 'PUT') {
298
+ init.headers = { 'Content-Type': 'application/json' }
299
+ init.body = JSON.stringify(await readJson(req))
300
+ }
301
+ const upstream = await prod(path, init)
302
+ const text = await upstream.text()
303
+ send(res, upstream.status, text, { 'Content-Type': 'application/json; charset=utf-8' })
304
+ return
305
+ }
306
+ if (req.method === 'GET') {
307
+ send(res, 200, { records: recordsFromLocal(cwd) })
308
+ return
309
+ }
310
+ if (req.method === 'POST') {
311
+ const result = insertLocalRecord(cwd, await readJson(req))
312
+ send(res, result.status, result.row || { message: result.message })
313
+ return
314
+ }
315
+ if (req.method === 'PUT') {
316
+ const recordId = decodeURIComponent(url.pathname.slice('/__amorphous/data/'.length))
317
+ const result = replaceLocalRecord(cwd, recordId, await readJson(req))
318
+ send(res, result.status, result.row || { message: result.message })
319
+ return
320
+ }
321
+ }
322
+ send(res, 404, { message: 'Not found' })
323
+ } catch (error) {
324
+ if (error instanceof SyntaxError) {
325
+ send(res, 400, { message: 'Invalid request' })
326
+ return
327
+ }
328
+ send(res, 500, { message: error instanceof Error ? error.message : 'Request failed' })
329
+ }
330
+ })
331
+
332
+ const ready = new Promise((resolve, reject) => {
333
+ server.once('error', reject)
334
+ server.listen(options.port || 0, host, () => {
335
+ server.removeListener('error', reject)
336
+ const address = server.address()
337
+ const port = typeof address === 'object' && address ? address.port : options.port
338
+ const url = `http://${host}:${port}/`
339
+ resolve({ url, port })
340
+ })
341
+ })
342
+
343
+ return ready.then((info) => {
344
+ if (!options.silent) {
345
+ if (options.json) console.log(JSON.stringify({ url: info.url }))
346
+ else console.log(info.url)
347
+ }
348
+ return {
349
+ ...info,
350
+ close() {
351
+ clearTimeout(reloadTimer)
352
+ for (const watcher of watchers) watcher.close()
353
+ for (const client of clients) client.end()
354
+ return new Promise((resolve, reject) => {
355
+ server.close((error) => (error ? reject(error) : resolve()))
356
+ })
357
+ },
358
+ }
359
+ })
360
+ }
@@ -0,0 +1,123 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+ import { startServe } from './serve.mjs'
7
+
8
+ function project() {
9
+ const cwd = mkdtempSync(join(tmpdir(), 'amorphous-serve-'))
10
+ writeFileSync(join(cwd, 'index.html'), '<main><script>alert(1)</script><p id="who">Hi</p></main>')
11
+ writeFileSync(join(cwd, 'styles.css'), 'main{color:black}')
12
+ writeFileSync(join(cwd, 'app.js'), 'document.title = "served"')
13
+ writeFileSync(join(cwd, 'data.ndjson'), '')
14
+ writeFileSync(join(cwd, 'private.ndjson'), '')
15
+ return cwd
16
+ }
17
+
18
+ function cookieFrom(res) {
19
+ const line = res.headers.get('set-cookie') || ''
20
+ const match = /amorphous_records=(local|prod)/.exec(line)
21
+ return match ? `amorphous_records=${match[1]}` : ''
22
+ }
23
+
24
+ test('serve reloads local source and keeps prod records off disk', async () => {
25
+ const cwd = project()
26
+ const calls = []
27
+ const running = await startServe({
28
+ cwd,
29
+ abstractId: '00000000-0000-4000-8000-000000000001',
30
+ silent: true,
31
+ prodFetch: async (_id, path, init) => {
32
+ calls.push({ path, method: init.method, body: init.body })
33
+ if (path === '/api/data' && init.method === 'GET') {
34
+ return new Response(JSON.stringify({ records: [{ id: 'live', visibility: 'shared', text: 'remote' }] }), {
35
+ status: 200,
36
+ headers: { 'Content-Type': 'application/json' },
37
+ })
38
+ }
39
+ if (path === '/api/data' && init.method === 'POST') {
40
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
41
+ }
42
+ if (path === '/api/me') {
43
+ return new Response(JSON.stringify({ id: 'user-1', name: 'Ada' }), { status: 200 })
44
+ }
45
+ return new Response(JSON.stringify({ names: { 'user-1': 'Ada' } }), { status: 200 })
46
+ },
47
+ })
48
+
49
+ try {
50
+ const page = await fetch(running.url)
51
+ const html = await page.text()
52
+ assert.match(html, /id="who"/)
53
+ assert.doesNotMatch(html, /alert\(1\)/)
54
+ assert.match(html, /data-set="local" aria-pressed="true"/)
55
+ assert.match(html, /data-set="prod"/)
56
+ assert.equal(await (await fetch(`${running.url}styles.css`)).text(), 'main{color:black}')
57
+ assert.equal(await (await fetch(`${running.url}app.js`)).text(), 'document.title = "served"')
58
+
59
+ const inserted = await fetch(`${running.url}__amorphous/data`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ id: 'local-1', text: 'note' }),
63
+ })
64
+ assert.equal(inserted.status, 200)
65
+ const found = await (await fetch(`${running.url}__amorphous/data`)).json()
66
+ assert.deepEqual(found.records, [{ id: 'local-1', text: 'note', visibility: 'shared' }])
67
+
68
+ const replaced = await fetch(`${running.url}__amorphous/data/local-1`, {
69
+ method: 'PUT',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ text: 'edited' }),
72
+ })
73
+ assert.equal(replaced.status, 200)
74
+ assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /"text":"edited"/)
75
+ assert.equal(calls.filter((call) => call.path === '/api/data').length, 0)
76
+
77
+ const again = await fetch(`${running.url}__amorphous/data`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ id: 'local-1', text: 'nope' }),
81
+ })
82
+ assert.equal(again.status, 409)
83
+
84
+ const switched = await fetch(`${running.url}__amorphous/mode`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ mode: 'prod' }),
88
+ })
89
+ const jar = cookieFrom(switched)
90
+ assert.equal(jar, 'amorphous_records=prod')
91
+ const live = await (
92
+ await fetch(`${running.url}__amorphous/data`, { headers: { Cookie: jar } })
93
+ ).json()
94
+ assert.equal(live.records[0].text, 'remote')
95
+ const pushed = await fetch(`${running.url}__amorphous/data`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json', Cookie: jar },
98
+ body: JSON.stringify({ id: 'from-prod', text: 'up' }),
99
+ })
100
+ assert.equal(pushed.status, 200)
101
+ assert.doesNotMatch(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /from-prod/)
102
+ assert.equal(calls.filter((call) => call.method === 'POST' && call.path === '/api/data').length, 1)
103
+
104
+ const me = await (await fetch(`${running.url}__amorphous/me`)).json()
105
+ assert.equal(me.name, 'Ada')
106
+
107
+ const events = await fetch(`${running.url}__amorphous/events`)
108
+ const reader = events.body.getReader()
109
+ const first = await reader.read()
110
+ assert.match(Buffer.from(first.value).toString(), /:/)
111
+ writeFileSync(join(cwd, 'styles.css'), 'main{color:white}')
112
+ let chunk = ''
113
+ const deadline = Date.now() + 2000
114
+ while (!chunk.includes('reload') && Date.now() < deadline) {
115
+ const next = await reader.read()
116
+ chunk += Buffer.from(next.value).toString()
117
+ }
118
+ assert.match(chunk, /reload/)
119
+ reader.cancel()
120
+ } finally {
121
+ await running.close()
122
+ }
123
+ })
package/lib/session.mjs CHANGED
@@ -58,7 +58,7 @@ async function withCredentialLock(dir, fn) {
58
58
  /* the other process released it */
59
59
  }
60
60
  if (Date.now() - started > LOCK_WAIT_MS) {
61
- throw new Error('Not signed in. Run: amorphous login')
61
+ throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
62
62
  }
63
63
  await sleep(25)
64
64
  }
@@ -115,11 +115,11 @@ export async function ensureFreshSession(options = {}) {
115
115
  const now = options.now ?? Date.now()
116
116
  const session = loadSession()
117
117
  if (!session?.access_token) {
118
- throw new Error('Not signed in. Run: amorphous login')
118
+ throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
119
119
  }
120
120
  if (!options.force && !sessionNeedsRefresh(session, now)) return session
121
121
  if (!session.refresh_token) {
122
- throw new Error('Not signed in. Run: amorphous login')
122
+ throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
123
123
  }
124
124
  const refresh = options.refresh ?? refreshWithSupabase
125
125
  try {
@@ -135,7 +135,7 @@ export async function ensureFreshSession(options = {}) {
135
135
  ) {
136
136
  return again
137
137
  }
138
- throw new Error('Not signed in. Run: amorphous login')
138
+ throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
139
139
  }
140
140
  })
141
141
  }
@@ -148,7 +148,7 @@ export function accessToken() {
148
148
  export function authHeaders(extra = {}) {
149
149
  const token = accessToken()
150
150
  if (!token) {
151
- throw new Error('Not signed in. Run: amorphous login')
151
+ throw new Error('Not signed in. Run: npx amorphous-cli@latest login')
152
152
  }
153
153
  return {
154
154
  Authorization: `Bearer ${token}`,
@@ -0,0 +1,8 @@
1
+ /** Tool text for an MCP result. The model sees `content`, not the child process stderr. */
2
+ export function toolText(stdout, stderr, code) {
3
+ const out = String(stdout || '').trim()
4
+ const err = String(stderr || '').trim()
5
+ if (code !== 0) return err || out || `exit ${code}`
6
+ if (!err) return out || 'ok'
7
+ return out ? `${out}\n${err}` : err
8
+ }
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { toolText } from './tool-result.mjs'
4
+
5
+ const NUDGE = 'Run: npx amorphous-cli@latest setup'
6
+
7
+ test('a successful command keeps stderr the model must see', () => {
8
+ assert.equal(toolText('[{"id":"a"}]', `${NUDGE}\n`, 0), `[{"id":"a"}]\n${NUDGE}`)
9
+ })
10
+
11
+ test('a failed command returns the error text', () => {
12
+ assert.equal(toolText('', 'Not signed in. Run: npx amorphous-cli@latest login\n', 1), 'Not signed in. Run: npx amorphous-cli@latest login')
13
+ })
14
+
15
+ test('empty success is ok', () => {
16
+ assert.equal(toolText('', '', 0), 'ok')
17
+ })
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
+ import { installUserSkill } from './agent-contract.mjs'
5
6
  import { ALIASES } from './help.mjs'
6
7
  import { userSkillDirs } from './agent-contract.mjs'
7
8
  import { CLI_VERSION } from './version.mjs'
@@ -59,6 +60,29 @@ function existsSkill(path) {
59
60
  }
60
61
  }
61
62
 
63
+ export function shouldRefreshSkill(home, version) {
64
+ let refresh = false
65
+ for (const dir of userSkillDirs(home)) {
66
+ const skillPath = join(dir, 'SKILL.md')
67
+ const stampPath = join(dir, '.amorphous-version')
68
+ if (!existsSkill(skillPath)) {
69
+ refresh = true
70
+ continue
71
+ }
72
+ let stamp
73
+ try {
74
+ stamp = readFileSync(stampPath, 'utf8').trim()
75
+ } catch {
76
+ refresh = true
77
+ continue
78
+ }
79
+ const cmp = compareVersions(stamp, version)
80
+ if (cmp != null && cmp > 0) return false
81
+ if (cmp == null || cmp < 0) refresh = true
82
+ }
83
+ return refresh
84
+ }
85
+
62
86
  export function skillStale(home, version) {
63
87
  const dirs = userSkillDirs(home)
64
88
  for (const dir of dirs) {
@@ -125,10 +149,8 @@ export function defaultFetchLatest(env = process.env) {
125
149
  })
126
150
  }
127
151
 
128
- function needsNudge({ cmd, version, latest, home }) {
129
- if (latest && compareVersions(version, latest) === -1) return true
130
- if (cmd !== 'setup' && skillStale(home, version)) return true
131
- return false
152
+ function needsNudge({ version, latest }) {
153
+ return Boolean(latest && compareVersions(version, latest) === -1)
132
154
  }
133
155
 
134
156
  export function startUpdateCheck({
@@ -176,7 +198,16 @@ export function startUpdateCheck({
176
198
  }
177
199
  }
178
200
 
179
- if (!printed && needsNudge({ cmd, version, latest, home })) {
201
+ const behind = needsNudge({ version, latest })
202
+ if (!behind && cmd !== 'setup' && shouldRefreshSkill(home, version)) {
203
+ try {
204
+ installUserSkill(home)
205
+ } catch {
206
+ /* a read-only home must not change the command's exit code */
207
+ }
208
+ }
209
+
210
+ if (!printed && behind) {
180
211
  printed = true
181
212
  stderr.write(NUDGE)
182
213
  }
@@ -231,7 +231,7 @@ test('setup skips skill stamp nudge when registry matches', async () => {
231
231
  assert.equal(err, '')
232
232
  })
233
233
 
234
- test('non-setup nudges on stale skill when registry matches', async () => {
234
+ test('non-setup refreshes a stale skill when registry matches', async () => {
235
235
  const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
236
236
  const [d0] = userSkillDirs(home)
237
237
  mkdirSync(d0, { recursive: true })
@@ -250,7 +250,33 @@ test('non-setup nudges on stale skill when registry matches', async () => {
250
250
  } },
251
251
  })
252
252
  await update.finish()
253
+ assert.equal(err, '')
254
+ assert.equal(readFileSync(join(d0, '.amorphous-version'), 'utf8').trim(), CLI_VERSION)
255
+ assert.match(readFileSync(join(d0, 'SKILL.md'), 'utf8'), /npx amorphous-cli@latest/)
256
+ })
257
+
258
+ test('a newer registry is not copied over the installed skill', async () => {
259
+ const home = mkdtempSync(join(tmpdir(), 'amorphous-uc-'))
260
+ const [d0] = userSkillDirs(home)
261
+ mkdirSync(d0, { recursive: true })
262
+ writeFileSync(join(d0, 'SKILL.md'), 'keep-this')
263
+ writeFileSync(join(d0, '.amorphous-version'), '0.0.1\n')
264
+ writeCache(home, { checkedAt: new Date().toISOString(), latest: '9.9.9' })
265
+ let err = ''
266
+ const update = startUpdateCheck({
267
+ argv: ['list'],
268
+ env: {},
269
+ home,
270
+ version: CLI_VERSION,
271
+ now: () => Date.now(),
272
+ fetchLatest: async () => '9.9.9',
273
+ stderr: { write: (s) => {
274
+ err += s
275
+ } },
276
+ })
277
+ await update.finish()
253
278
  assert.equal(err, NUDGE)
279
+ assert.equal(readFileSync(join(d0, 'SKILL.md'), 'utf8'), 'keep-this')
254
280
  })
255
281
 
256
282
  test('integration help skips update check file', () => {