amorphous-cli 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # amorphous-cli
2
2
 
3
- Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users of its own.
3
+ Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
4
4
 
5
5
  ```bash
6
6
  npx amorphous-cli@latest setup
@@ -48,7 +48,7 @@ if (target === 'cursor') {
48
48
  process.stdout.write(
49
49
  `${JSON.stringify({
50
50
  additional_context:
51
- 'The Amorphous skill on this machine was just updated. Read ~/.agents/skills/amorphous/SKILL.md and follow it. Invoke the CLI as npx amorphous-cli@latest.',
51
+ 'The Amorphous skill on this machine was just updated. Read ~/.agents/skills/amorphous/SKILL.md and follow it. Install the CLI with npm install -g amorphous-cli@latest and invoke it as amorphous-cli.',
52
52
  })}\n`,
53
53
  )
54
54
  } else {
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import { homedir } from 'node:os'
6
6
  import { CLI_VERSION } from './version.mjs'
7
7
 
8
- export const CONTRACT_VERSION = 7
8
+ export const CONTRACT_VERSION = 10
9
9
 
10
10
  const skillRoot = join(dirname(fileURLToPath(import.meta.url)), '../skill/amorphous')
11
11
 
package/lib/api.mjs CHANGED
@@ -2,24 +2,24 @@ import { cliAppFetchOrigin, cliAppHostHeader } from './config.mjs'
2
2
  import { ensureFreshSession } from './session.mjs'
3
3
 
4
4
  const FRIENDLY = {
5
- 'You already own 2 abstracts': 'You already own 2 abstracts. Run: npx amorphous-cli@latest list',
6
- 'This abstract already has 8 people': 'This abstract already has 8 people. Run: npx amorphous-cli@latest people',
5
+ 'You already own 2 abstracts': 'You already own 2 abstracts. Run: amorphous-cli list',
6
+ 'This abstract already has 8 people': 'This abstract already has 8 people. Run: amorphous-cli people',
7
7
  'User not found': 'No account with that email yet.',
8
- 'Grant failed': 'Could not invite. Run: npx amorphous-cli@latest people',
9
- 'Cannot revoke owner': "You can't remove the owner. Run: npx amorphous-cli@latest people",
10
- 'Cannot remove yourself': "You can't remove yourself. Run: npx amorphous-cli@latest people",
11
- 'Collection not found': 'Folder not found. Run: npx amorphous-cli@latest folder list',
12
- Forbidden: "You don't have access. Run: npx amorphous-cli@latest list",
13
- 'Not Found': 'Not found. Run: npx amorphous-cli@latest list',
14
- 'Owner required': 'Only the owner can do that. Run: npx amorphous-cli@latest people',
15
- 'id required': 'Missing id. Run: npx amorphous-cli@latest list',
16
- 'userId required': 'Pass an email or id. Run: npx amorphous-cli@latest people',
17
- 'Invalid body': 'Invalid request. Run: npx amorphous-cli@latest pull',
18
- 'Name required': 'Name required. Run: npx amorphous-cli@latest rename --name <name>',
19
- 'Create failed': 'Could not create the abstract. Run: npx amorphous-cli@latest list',
20
- 'Publish failed': 'Could not publish. Run: npx amorphous-cli@latest push',
21
- 'File too large': 'That file is too large. Run: npx amorphous-cli@latest pull',
22
- 'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: npx amorphous-cli@latest login',
8
+ 'Grant failed': 'Could not invite. Run: amorphous-cli people',
9
+ 'Cannot revoke owner': "You can't remove the owner. Run: amorphous-cli people",
10
+ 'Cannot remove yourself': "You can't remove yourself. Run: amorphous-cli people",
11
+ 'Collection not found': 'Folder not found. Run: amorphous-cli folder list',
12
+ Forbidden: "You don't have access. Run: amorphous-cli list",
13
+ 'Not Found': 'Not found. Run: amorphous-cli list',
14
+ 'Owner required': 'Only the owner can do that. Run: amorphous-cli people',
15
+ 'id required': 'Missing id. Run: amorphous-cli list',
16
+ 'userId required': 'Pass an email or id. Run: amorphous-cli people',
17
+ 'Invalid body': 'Invalid request. Run: amorphous-cli pull',
18
+ 'Name required': 'Name required. Run: amorphous-cli rename --name <name>',
19
+ 'Create failed': 'Could not create the abstract. Run: amorphous-cli list',
20
+ 'Publish failed': 'Could not publish. Run: amorphous-cli push',
21
+ 'File too large': 'That file is too large. Run: amorphous-cli pull',
22
+ 'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: amorphous-cli login',
23
23
  }
24
24
 
25
25
  export function friendlyMessage(raw) {
package/lib/commands.mjs CHANGED
@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
7
7
  import { apiFetch } from './api.mjs'
8
8
  import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
9
9
  import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
10
+ import { startServe } from './serve.mjs'
10
11
  import { ALIASES, printHelp, wantsHelp } from './help.mjs'
11
12
  import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
12
13
 
@@ -23,6 +24,7 @@ const BOOL_FLAGS = new Set([
23
24
  'open',
24
25
  'closed',
25
26
  'quiet',
27
+ 'data',
26
28
  ])
27
29
 
28
30
  function parseFlags(args) {
@@ -75,7 +77,7 @@ function writeMeta(cwd, abstractId) {
75
77
  function requireMeta(cwd) {
76
78
  const meta = readMeta(cwd)
77
79
  if (!meta?.abstractId) {
78
- throw new Error('No .amorphous.json here. Run npx amorphous-cli@latest create first.')
80
+ throw new Error('No .amorphous.json here. Run amorphous-cli create first.')
79
81
  }
80
82
  return meta.abstractId
81
83
  }
@@ -330,20 +332,45 @@ async function cmdPush(cwd, rest) {
330
332
  const { flags } = parseFlags(rest)
331
333
  if (flags.help) return printHelp('push')
332
334
  const id = requireMeta(cwd)
333
- const localRecords = recordsFromLocal(cwd)
334
- const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
335
- method: 'POST',
336
- body: JSON.stringify({ records: localRecords }),
337
- })
338
- if (refused?.length) {
339
- console.error(`Skipped (already live): ${refused.join(', ')}. Run: npx amorphous-cli@latest pull`)
335
+ if (flags.data) {
336
+ const localRecords = recordsFromLocal(cwd)
337
+ const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
338
+ method: 'POST',
339
+ body: JSON.stringify({ records: localRecords }),
340
+ })
341
+ if (refused?.length) {
342
+ console.error(`Skipped (already live): ${refused.join(', ')}. Run: amorphous-cli pull`)
343
+ }
340
344
  }
341
345
  const { html, css, js } = readProjectFiles(cwd)
342
346
  await apiFetch(`/api/abstracts/${id}/push`, {
343
347
  method: 'POST',
344
348
  body: JSON.stringify({ html, css, js }),
345
349
  })
346
- console.log('Pushed.')
350
+ console.log(flags.data ? 'Pushed source and records.' : 'Pushed source.')
351
+ }
352
+
353
+ async function cmdServe(cwd, rest) {
354
+ const { flags } = parseFlags(rest)
355
+ if (flags.help) return printHelp('serve')
356
+ const id = requireMeta(cwd)
357
+ const port = flags.port ? Number(flags.port) : 0
358
+ if (flags.port && (!Number.isInteger(port) || port < 1 || port > 65535)) {
359
+ throw new Error('Use --port <number>.')
360
+ }
361
+ const running = await startServe({
362
+ cwd,
363
+ abstractId: id,
364
+ port,
365
+ json: jsonMode(flags),
366
+ })
367
+ await new Promise((resolve) => {
368
+ const stop = () => {
369
+ running.close().then(resolve, resolve)
370
+ }
371
+ process.once('SIGINT', stop)
372
+ process.once('SIGTERM', stop)
373
+ })
347
374
  }
348
375
 
349
376
  function urlsFor(id) {
@@ -737,6 +764,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
737
764
  return cmdOpen(cwd, rest)
738
765
  case 'preview':
739
766
  return cmdPreview(cwd, rest)
767
+ case 'serve':
768
+ return cmdServe(cwd, rest)
740
769
  case 'publish':
741
770
  return cmdPublish(cwd, rest)
742
771
  case 'live':
@@ -770,6 +799,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
770
799
  if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
771
800
  throw new Error('Use folder instead.')
772
801
  default:
773
- throw new Error(`Unknown command “${raw}”. Run npx amorphous-cli@latest --help.`)
802
+ throw new Error(`Unknown command “${raw}”. Run amorphous-cli --help.`)
774
803
  }
775
804
  }
package/lib/config.mjs CHANGED
@@ -72,6 +72,36 @@ export function abstractsOrigin() {
72
72
  return process.env.ABSTRACTS_ORIGIN || HOSTED.abstractsOrigin
73
73
  }
74
74
 
75
+ /** Local Herd HTTPS: hit Nuxt on loopback with the abstracts Host header (no TLS). */
76
+ export function cliAbstractsFetchOrigin() {
77
+ if (process.env.AMORPHOUS_CLI_ABSTRACTS_URL) {
78
+ return process.env.AMORPHOUS_CLI_ABSTRACTS_URL.replace(/\/$/, '')
79
+ }
80
+ const origin = abstractsOrigin()
81
+ try {
82
+ const u = new URL(origin)
83
+ if (u.protocol === 'https:' && (u.hostname.endsWith('.test') || u.hostname === 'localhost')) {
84
+ const port = process.env.AMORPHOUS_DEV_PORT || '3847'
85
+ return `http://127.0.0.1:${port}`
86
+ }
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ return origin.replace(/\/$/, '')
91
+ }
92
+
93
+ export function cliAbstractsHostHeader() {
94
+ const origin = abstractsOrigin()
95
+ const fetchOrigin = cliAbstractsFetchOrigin()
96
+ if (!fetchOrigin.startsWith('http://127.0.0.1')) return {}
97
+ try {
98
+ const u = new URL(origin)
99
+ return { Host: u.host }
100
+ } catch {
101
+ return {}
102
+ }
103
+ }
104
+
75
105
  export function supabaseUrl() {
76
106
  return process.env.SUPABASE_URL || HOSTED.supabaseUrl
77
107
  }
package/lib/files.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
4
 
@@ -47,4 +48,45 @@ export function recordsFromLocal(cwd) {
47
48
  ]
48
49
  }
49
50
 
51
+ export function writeLocalRecords(cwd, rows) {
52
+ writeNdjson(
53
+ join(cwd, 'data.ndjson'),
54
+ rows.filter((r) => r.visibility !== 'private'),
55
+ )
56
+ writeNdjson(
57
+ join(cwd, 'private.ndjson'),
58
+ rows.filter((r) => r.visibility === 'private'),
59
+ )
60
+ }
61
+
62
+ export function insertLocalRecord(cwd, row) {
63
+ const body = { ...(row && typeof row === 'object' ? row : {}) }
64
+ if (body.visibility && body.visibility !== 'shared' && body.visibility !== 'private') {
65
+ return { status: 400, message: 'Invalid visibility' }
66
+ }
67
+ body.visibility = body.visibility === 'private' ? 'private' : 'shared'
68
+ if (!body.id) body.id = randomUUID()
69
+ const rows = recordsFromLocal(cwd)
70
+ if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
71
+ rows.push(body)
72
+ writeLocalRecords(cwd, rows)
73
+ return { status: 200, row: body }
74
+ }
75
+
76
+ export function replaceLocalRecord(cwd, id, row) {
77
+ const rows = recordsFromLocal(cwd)
78
+ const index = rows.findIndex((r) => r.id === id)
79
+ if (index < 0) return { status: 404, message: 'Not Found' }
80
+ const existing = rows[index]
81
+ const next = {
82
+ ...(existing && typeof existing === 'object' ? existing : {}),
83
+ ...(row && typeof row === 'object' ? row : {}),
84
+ id,
85
+ visibility: existing.visibility,
86
+ }
87
+ rows[index] = next
88
+ writeLocalRecords(cwd, rows)
89
+ return { status: 200, row: next }
90
+ }
91
+
50
92
  export { FILES }
package/lib/help.mjs CHANGED
@@ -7,6 +7,7 @@ export const ROOT_HELP = `Amorphous
7
7
  create New abstract
8
8
  open [abstract] Open URL
9
9
  preview [abstract] Preview URL (draft)
10
+ serve Serve this folder locally
10
11
  pull Pull draft and records to this folder
11
12
  push Push local files
12
13
  publish [--live] Publish a version; --live is Go live
@@ -28,7 +29,7 @@ export const ROOT_HELP = `Amorphous
28
29
  folder people People on a folder
29
30
  rename --name Rename
30
31
 
31
- Run npx amorphous-cli@latest <command> --help for flags. Pass --json where you need machine output.
32
+ Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
32
33
  `
33
34
 
34
35
  export const COMMAND_HELP = {
@@ -64,13 +65,19 @@ Print the Open URL.`,
64
65
 
65
66
  Print the Preview URL (draft).`,
66
67
 
68
+ serve: `serve [--port <n>] [--json]
69
+
70
+ Serve this folder on 127.0.0.1 and reload when index.html, styles.css, or app.js changes.
71
+ Records start local, in this folder. The page can switch to prod, which reads and writes the abstract's records.`,
72
+
67
73
  pull: `pull
68
74
 
69
75
  Pull the draft and records into this directory.`,
70
76
 
71
- push: `push
77
+ push: `push [--data]
72
78
 
73
- Push local index.html, styles.css, app.js, and records.`,
79
+ Push local index.html, styles.css, and app.js.
80
+ --data also merges records from this folder. A record changed both here and on the abstract is refused.`,
74
81
 
75
82
  publish: `publish [--live]
76
83
 
package/lib/login.mjs CHANGED
@@ -70,7 +70,7 @@ async function loginViaMailpit(supabase, email, redirectTo, sinceMs) {
70
70
  async function readCode(email) {
71
71
  if (!process.stdin.isTTY) {
72
72
  throw new Error(
73
- `Code sent to ${email}. Read it from that inbox, then run: npx amorphous-cli@latest login ${email} --code <code>`,
73
+ `Code sent to ${email}. Read it from that inbox, then run: amorphous-cli login ${email} --code <code>`,
74
74
  )
75
75
  }
76
76
  const rl = createInterface({ input: process.stdin, output: process.stderr })
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: amorphous-cli 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: npx amorphous-cli@latest login')
61
+ throw new Error('Not signed in. Run: amorphous-cli 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: npx amorphous-cli@latest login')
118
+ throw new Error('Not signed in. Run: amorphous-cli 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: npx amorphous-cli@latest login')
122
+ throw new Error('Not signed in. Run: amorphous-cli 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: npx amorphous-cli@latest login')
138
+ throw new Error('Not signed in. Run: amorphous-cli 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: npx amorphous-cli@latest login')
151
+ throw new Error('Not signed in. Run: amorphous-cli login')
152
152
  }
153
153
  return {
154
154
  Authorization: `Bearer ${token}`,
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
  import { toolText } from './tool-result.mjs'
4
4
 
5
- const NUDGE = 'Run: npx amorphous-cli@latest setup'
5
+ const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup'
6
6
 
7
7
  test('a successful command keeps stderr the model must see', () => {
8
8
  assert.equal(toolText('[{"id":"a"}]', `${NUDGE}\n`, 0), `[{"id":"a"}]\n${NUDGE}`)
9
9
  })
10
10
 
11
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')
12
+ assert.equal(toolText('', 'Not signed in. Run: amorphous-cli login\n', 1), 'Not signed in. Run: amorphous-cli login')
13
13
  })
14
14
 
15
15
  test('empty success is ok', () => {
@@ -8,7 +8,7 @@ import { userSkillDirs } from './agent-contract.mjs'
8
8
  import { CLI_VERSION } from './version.mjs'
9
9
 
10
10
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000
11
- const NUDGE = 'Run: npx amorphous-cli@latest setup\n'
11
+ const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup\n'
12
12
 
13
13
  function parseVersion(v) {
14
14
  if (typeof v !== 'string') return null
@@ -14,7 +14,7 @@ import {
14
14
  startUpdateCheck,
15
15
  } from './update-check.mjs'
16
16
 
17
- const NUDGE = 'Run: npx amorphous-cli@latest setup\n'
17
+ const NUDGE = 'Run: npm install -g amorphous-cli@latest && amorphous-cli setup\n'
18
18
 
19
19
  test('compareVersions', () => {
20
20
  assert.equal(compareVersions('1.0.0', '1.0.0'), 0)
@@ -252,7 +252,7 @@ test('non-setup refreshes a stale skill when registry matches', async () => {
252
252
  await update.finish()
253
253
  assert.equal(err, '')
254
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/)
255
+ assert.match(readFileSync(join(d0, 'SKILL.md'), 'utf8'), /npm install -g amorphous-cli@latest/)
256
256
  })
257
257
 
258
258
  test('a newer registry is not copied over the installed skill', async () => {
@@ -291,6 +291,6 @@ test('integration help skips update check file', () => {
291
291
  encoding: 'utf8',
292
292
  })
293
293
  assert.equal(r.status, 0)
294
- assert.ok(!r.stderr.includes('Run: npx amorphous-cli@latest setup'))
294
+ assert.ok(!r.stderr.includes('Run: npm install -g amorphous-cli@latest && amorphous-cli setup'))
295
295
  assert.throws(() => readFileSync(cacheFile(home)), { code: 'ENOENT' })
296
296
  })
package/mcp.mjs CHANGED
@@ -84,11 +84,13 @@ await serveStdio(() => {
84
84
  {
85
85
  title: 'Push the abstract',
86
86
  description:
87
- 'Upload the local abstract and merge records. A record changed both here and on the live abstract is refused; edit it and push again.',
88
- inputSchema: z.object({}),
87
+ 'Upload the local source. Set data to also merge records from this folder. A record changed both here and on the live abstract is refused; edit it and push again.',
88
+ inputSchema: z.object({
89
+ data: z.boolean().optional().describe('Also merge records from this folder'),
90
+ }),
89
91
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
90
92
  },
91
- async () => call(['push']),
93
+ async ({ data }) => call(data ? ['push', '--data'] : ['push']),
92
94
  )
93
95
 
94
96
  server.registerTool(
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "amorphous-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "mcpName": "io.github.amorphous-one/amorphous",
5
- "description": "Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users of its own.",
5
+ "description": "Make whatever you want, yourself, with your own agent, or with others.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "amorphous-cli": "index.mjs",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: amorphous
3
- description: Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users of its own. Use when someone wants an abstract other people can open, or wants to invite people, publish, or change an abstract.
3
+ description: Make whatever you want, yourself, with your own agent, or with others. Use when someone wants an abstract other people can open, or wants to invite people, publish, or change an abstract.
4
4
  ---
5
5
 
6
- Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users of its own.
6
+ Make whatever you want, yourself, with your own agent, or with others. And whatever you want can be private, public, or even have users.
7
7
 
8
8
  ## Files
9
9
 
@@ -32,17 +32,20 @@ A private record belongs to the signed-in person who wrote it. The abstract does
32
32
 
33
33
  ## Loop
34
34
 
35
- Call the CLI as `npx amorphous-cli@latest <command>`. If a command prints `Run: npx amorphous-cli@latest setup`, run that line, then repeat the command.
35
+ Install the CLI once with `npm install -g amorphous-cli@latest`, then call it as `amorphous-cli <command>`. If a command prints `Run: npm install -g amorphous-cli@latest && amorphous-cli setup`, run that line, then repeat the command.
36
36
 
37
- 1. If you are not signed in, run `npx amorphous-cli@latest login`. Show the person the link it prints and ask them to finish in the browser. Do not ask for an email or a code.
38
- 2. `npx amorphous-cli@latest create --name "..."` in a folder, or `npx amorphous-cli@latest pull` when `.amorphous.json` is already there.
37
+ 1. If you are not signed in, run `amorphous-cli login`. Show the person the link it prints and ask them to finish in the browser. Do not ask for an email or a code.
38
+ 2. `amorphous-cli create --name "..."` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there.
39
39
  3. Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here.
40
- 4. `npx amorphous-cli@latest push`
41
- 5. `npx amorphous-cli@latest preview` and open that URL.
42
- 6. `npx amorphous-cli@latest publish --live`
43
- 7. `npx amorphous-cli@latest invite <email> use` or `edit` or `view`. It prints a link and does not send an email. Give them that link. They open it and sign in with that email.
40
+ 4. `amorphous-cli serve` and open that URL. It reloads when the files change. Records on that page start in this folder. Switch the page to prod to read and write the abstract's records.
41
+ 5. `amorphous-cli push` sends the source. `amorphous-cli push --data` also sends the records in this folder.
42
+ 6. `amorphous-cli preview` and open that URL.
43
+ 7. `amorphous-cli publish --live`
44
+ 8. `amorphous-cli invite <email> use` or `edit` or `view`. It prints a link and does not send an email. Give them that link. They open it and sign in with that email.
44
45
 
45
- `npx amorphous-cli@latest visibility anyone` lets anyone view and write shared records. `npx amorphous-cli@latest visibility members` keeps the abstract to invited people.
46
+ `amorphous-cli visibility anyone` lets anyone view and write shared records. `amorphous-cli visibility members` keeps the abstract to invited people.
47
+
48
+ Do not make a public abstract that lets anyone keep adding shared records, such as an open list. Those records stay on the abstract and use up its storage. Keep shared records to a set you replace, or keep the abstract to invited people.
46
49
 
47
50
  A person's access is view, use, or edit. Member tiers, scores, and roles inside an abstract are records on that abstract.
48
51
 
@@ -1,26 +1,8 @@
1
1
  const who = document.getElementById('who')
2
- const list = document.getElementById('items')
3
- const form = document.getElementById('add')
4
2
 
5
3
  async function refresh() {
6
4
  const me = await amorphous.me()
7
- who.textContent = me ? me.name : 'Sign in to take part'
8
- const rows = await amorphous.data.find()
9
- list.replaceChildren()
10
- for (const row of rows) {
11
- const li = document.createElement('li')
12
- li.textContent = typeof row.text === 'string' ? row.text : ''
13
- list.appendChild(li)
14
- }
5
+ who.textContent = me ? me.name : 'Sign in'
15
6
  }
16
7
 
17
- form.addEventListener('submit', async (event) => {
18
- event.preventDefault()
19
- const text = new FormData(form).get('text')
20
- if (typeof text !== 'string' || !text.trim()) return
21
- await amorphous.data.insert({ text: text.trim() })
22
- form.reset()
23
- await refresh()
24
- })
25
-
26
8
  refresh()
@@ -1,8 +1,3 @@
1
1
  <main>
2
2
  <p id="who"></p>
3
- <ul id="items"></ul>
4
- <form id="add">
5
- <input name="text" required placeholder="Add something">
6
- <button type="submit">Add</button>
7
- </form>
8
3
  </main>
@@ -9,23 +9,3 @@ main {
9
9
  #who {
10
10
  color: #52525b;
11
11
  }
12
-
13
- ul {
14
- padding-left: 1.1rem;
15
- }
16
-
17
- form {
18
- display: flex;
19
- gap: 0.5rem;
20
- margin-top: 1rem;
21
- }
22
-
23
- input {
24
- flex: 1;
25
- font: inherit;
26
- padding: 0.4rem 0.55rem;
27
- }
28
-
29
- button {
30
- font: inherit;
31
- }