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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # amorphous-cli
2
2
 
3
- Build abstracts on Amorphous. An abstract can be anything. Your own agent builds it. Other people open it and write to it.
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
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session-start refresh. Claude Code waits for this and rescans skills when
4
+ * asked. Cursor does not wait; it can still take additional_context if it
5
+ * reads the output.
6
+ *
7
+ * setup --quiet exists from amorphous-cli 0.1.7. An older latest would
8
+ * install the previous skill, so this exits before calling it.
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+
12
+ const MIN = [0, 1, 7]
13
+ const target = process.argv[2] === 'cursor' ? 'cursor' : 'claude'
14
+ const win = process.platform === 'win32'
15
+ const npm = win ? 'npm.cmd' : 'npm'
16
+ const npx = win ? 'npx.cmd' : 'npx'
17
+ const spawnOpts = { encoding: 'utf8', shell: win, stdio: ['ignore', 'pipe', 'ignore'] }
18
+
19
+ function parts(version) {
20
+ const core = String(version).trim().split('-')[0]
21
+ return core.split('.').map((n) => Number.parseInt(n, 10))
22
+ }
23
+
24
+ function atLeast(version, min) {
25
+ const got = parts(version)
26
+ if (got.some((n) => Number.isNaN(n))) return false
27
+ const length = Math.max(got.length, min.length)
28
+ for (let i = 0; i < length; i++) {
29
+ const a = got[i] || 0
30
+ const b = min[i] || 0
31
+ if (a > b) return true
32
+ if (a < b) return false
33
+ }
34
+ return true
35
+ }
36
+
37
+ const view = spawnSync(npm, ['view', 'amorphous-cli', 'version'], { ...spawnOpts, timeout: 8_000 })
38
+ if (view.status !== 0 || !atLeast(view.stdout, MIN)) process.exit(0)
39
+
40
+ const run = spawnSync(npx, ['--yes', 'amorphous-cli@latest', 'setup', '--quiet'], {
41
+ ...spawnOpts,
42
+ timeout: 20_000,
43
+ })
44
+ const updated = run.status === 0 && String(run.stdout || '').trim() === 'updated'
45
+ if (!updated) process.exit(0)
46
+
47
+ if (target === 'cursor') {
48
+ process.stdout.write(
49
+ `${JSON.stringify({
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.',
52
+ })}\n`,
53
+ )
54
+ } else {
55
+ process.stdout.write(
56
+ `${JSON.stringify({
57
+ hookSpecificOutput: { hookEventName: 'SessionStart', reloadSkills: true },
58
+ })}\n`,
59
+ )
60
+ }
@@ -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 = 2
8
+ export const CONTRACT_VERSION = 10
9
9
 
10
10
  const skillRoot = join(dirname(fileURLToPath(import.meta.url)), '../skill/amorphous')
11
11
 
@@ -16,7 +16,10 @@ test('setup copies the skill, and an edited contract is left alone', () => {
16
16
  const home = mkdtempSync(join(tmpdir(), 'amorphous-skill-'))
17
17
  const dirs = installUserSkill(home)
18
18
  assert.equal(dirs.length, 2)
19
- assert.match(readFileSync(join(dirs[0], 'SKILL.md'), 'utf8'), /build abstracts/)
19
+ assert.match(
20
+ readFileSync(join(dirs[0], 'SKILL.md'), 'utf8'),
21
+ /Make whatever you want, yourself, with your own agent, or with others\./,
22
+ )
20
23
  assert.match(readFileSync(join(dirs[1], 'assets/app.js'), 'utf8'), /amorphous\.me/)
21
24
  assert.equal(readFileSync(join(dirs[0], '.amorphous-version'), 'utf8'), `${CLI_VERSION}\n`)
22
25
  assert.equal(readFileSync(join(dirs[1], '.amorphous-version'), 'utf8'), `${CLI_VERSION}\n`)
@@ -0,0 +1,134 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { homedir } from 'node:os'
5
+
6
+ function hasOurCommand(command, home) {
7
+ return typeof command === 'string' && command.includes(sessionStartScriptPath(home))
8
+ }
9
+
10
+ function scriptSource() {
11
+ return readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../hooks/session-start.mjs'))
12
+ }
13
+
14
+ export function sessionStartScriptPath(home = homedir()) {
15
+ return join(home, '.config', 'amorphous', 'session-start.mjs')
16
+ }
17
+
18
+ function readJson(path) {
19
+ try {
20
+ return JSON.parse(readFileSync(path, 'utf8'))
21
+ } catch (error) {
22
+ if (error && error.code === 'ENOENT') return null
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ function writeJson(path, data) {
28
+ mkdirSync(dirname(path), { recursive: true })
29
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`)
30
+ }
31
+
32
+ function commandFor(home, target) {
33
+ return `node ${JSON.stringify(sessionStartScriptPath(home))} ${target}`
34
+ }
35
+
36
+ function installScript(home) {
37
+ const dest = sessionStartScriptPath(home)
38
+ const next = scriptSource()
39
+ let prev
40
+ try {
41
+ prev = readFileSync(dest)
42
+ } catch {
43
+ prev = null
44
+ }
45
+ if (prev && prev.equals(next)) return false
46
+ mkdirSync(dirname(dest), { recursive: true })
47
+ writeFileSync(dest, next, { mode: 0o755 })
48
+ try {
49
+ chmodSync(dest, 0o755)
50
+ } catch {
51
+ /* the copy is still runnable via node */
52
+ }
53
+ return true
54
+ }
55
+
56
+ function claudeHook(home) {
57
+ return {
58
+ matcher: 'startup|resume',
59
+ hooks: [{ type: 'command', command: commandFor(home, 'claude') }],
60
+ }
61
+ }
62
+
63
+ function ensureClaude(home) {
64
+ const path = join(home, '.claude', 'settings.json')
65
+ const current = readJson(path)
66
+ if (current === undefined) return false
67
+ const doc = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
68
+ const hooks = doc.hooks && typeof doc.hooks === 'object' ? doc.hooks : {}
69
+ const session = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []
70
+ const command = commandFor(home, 'claude')
71
+ const exists = session.some(
72
+ (entry) =>
73
+ Array.isArray(entry?.hooks) &&
74
+ entry.hooks.some((hook) => hasOurCommand(hook?.command, home)),
75
+ )
76
+ if (exists) {
77
+ let changed = false
78
+ const next = session.map((entry) => {
79
+ if (!Array.isArray(entry?.hooks)) return entry
80
+ const hooks = entry.hooks.map((hook) => {
81
+ if (!hasOurCommand(hook?.command, home)) return hook
82
+ if (hook.command === command) return hook
83
+ changed = true
84
+ return { ...hook, command }
85
+ })
86
+ const onlyOurs = hooks.every((hook) => hasOurCommand(hook?.command, home))
87
+ const matcher = onlyOurs ? 'startup|resume' : entry.matcher
88
+ if (matcher !== entry.matcher) changed = true
89
+ return { ...entry, matcher, hooks }
90
+ })
91
+ if (!changed) return false
92
+ writeJson(path, { ...doc, hooks: { ...hooks, SessionStart: next } })
93
+ return true
94
+ }
95
+ writeJson(path, {
96
+ ...doc,
97
+ hooks: { ...hooks, SessionStart: [...session, claudeHook(home)] },
98
+ })
99
+ return true
100
+ }
101
+
102
+ function ensureCursor(home) {
103
+ const path = join(home, '.cursor', 'hooks.json')
104
+ const current = readJson(path)
105
+ if (current === undefined) return false
106
+ const doc = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
107
+ const hooks = doc.hooks && typeof doc.hooks === 'object' ? doc.hooks : {}
108
+ const session = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : []
109
+ const command = commandFor(home, 'cursor')
110
+ const index = session.findIndex(
111
+ (hook) => hasOurCommand(hook?.command, home),
112
+ )
113
+ if (index >= 0) {
114
+ if (session[index].command === command) return false
115
+ const next = session.slice()
116
+ next[index] = { ...session[index], command }
117
+ writeJson(path, { ...doc, version: doc.version ?? 1, hooks: { ...hooks, sessionStart: next } })
118
+ return true
119
+ }
120
+ writeJson(path, {
121
+ ...doc,
122
+ version: doc.version ?? 1,
123
+ hooks: { ...hooks, sessionStart: [...session, { command }] },
124
+ })
125
+ return true
126
+ }
127
+
128
+ /** Install the Claude and Cursor session hooks. Leave a broken settings file untouched. */
129
+ export function ensureSessionHooks(home = homedir()) {
130
+ const script = installScript(home)
131
+ const claude = ensureClaude(home)
132
+ const cursor = ensureCursor(home)
133
+ return script || claude || cursor
134
+ }
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdirSync, 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 { ensureSessionHooks, sessionStartScriptPath } from './agent-hooks.mjs'
7
+
8
+ test('session hooks merge and stay idempotent', () => {
9
+ const home = mkdtempSync(join(tmpdir(), 'amorphous-hooks-'))
10
+ mkdirSync(join(home, '.claude'), { recursive: true })
11
+ writeFileSync(
12
+ join(home, '.claude', 'settings.json'),
13
+ `${JSON.stringify({ permissions: { allow: ['Bash'] } }, null, 2)}\n`,
14
+ )
15
+ mkdirSync(join(home, '.cursor'), { recursive: true })
16
+ writeFileSync(
17
+ join(home, '.cursor', 'hooks.json'),
18
+ `${JSON.stringify({ version: 1, hooks: { stop: [{ command: 'echo keep' }] } }, null, 2)}\n`,
19
+ )
20
+
21
+ assert.equal(ensureSessionHooks(home), true)
22
+ assert.equal(ensureSessionHooks(home), false)
23
+
24
+ const claude = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'))
25
+ assert.deepEqual(claude.permissions, { allow: ['Bash'] })
26
+ assert.equal(claude.hooks.SessionStart.length, 1)
27
+ assert.equal(claude.hooks.SessionStart[0].matcher, 'startup|resume')
28
+ assert.equal(
29
+ claude.hooks.SessionStart[0].hooks[0].command,
30
+ `node ${JSON.stringify(sessionStartScriptPath(home))} claude`,
31
+ )
32
+
33
+ const cursor = JSON.parse(readFileSync(join(home, '.cursor', 'hooks.json'), 'utf8'))
34
+ assert.deepEqual(cursor.hooks.stop, [{ command: 'echo keep' }])
35
+ assert.equal(cursor.hooks.sessionStart.length, 1)
36
+ assert.match(readFileSync(sessionStartScriptPath(home), 'utf8'), /amorphous-cli@latest', 'setup', '--quiet'/)
37
+ })
38
+
39
+ test('broken settings are left untouched', () => {
40
+ const home = mkdtempSync(join(tmpdir(), 'amorphous-hooks-'))
41
+ mkdirSync(join(home, '.claude'), { recursive: true })
42
+ writeFileSync(join(home, '.claude', 'settings.json'), '{')
43
+ assert.equal(ensureSessionHooks(home), true)
44
+ assert.equal(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'), '{')
45
+ assert.equal(ensureSessionHooks(home), false)
46
+ })
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: amorphous list',
6
- 'This abstract already has 8 people': 'This abstract already has 8 people. Run: amorphous people',
7
- 'User not found': 'No account with that email yet. They sign in once, then run: amorphous invite <email> use',
8
- 'Grant failed': 'Could not invite. Run: amorphous people',
9
- 'Cannot revoke owner': "You can't remove the owner. Run: amorphous people",
10
- 'Cannot remove yourself': "You can't remove yourself. Run: amorphous people",
11
- 'Collection not found': 'Folder not found. Run: amorphous folder list',
12
- Forbidden: "You don't have access. Run: amorphous list",
13
- 'Not Found': 'Not found. Run: amorphous list',
14
- 'Owner required': 'Only the owner can do that. Run: amorphous people',
15
- 'id required': 'Missing id. Run: amorphous list',
16
- 'userId required': 'Pass an email or id. Run: amorphous people',
17
- 'Invalid body': 'Invalid request. Run: amorphous pull',
18
- 'Name required': 'Name required. Run: amorphous rename --name <name>',
19
- 'Create failed': 'Could not create the abstract. Run: amorphous list',
20
- 'Publish failed': 'Could not publish. Run: amorphous push',
21
- 'File too large': 'That file is too large. Run: amorphous pull',
22
- 'Not logged in. Run: amorphous login <email>': 'Not signed in. Run: amorphous login',
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',
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',
23
23
  }
24
24
 
25
25
  export function friendlyMessage(raw) {
package/lib/commands.mjs CHANGED
@@ -1,8 +1,13 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { installUserSkill, syncProjectContract, writeStarter } from './agent-contract.mjs'
3
+ import { ensureSessionHooks } from './agent-hooks.mjs'
4
+ import { shouldRefreshSkill } from './update-check.mjs'
5
+ import { CLI_VERSION } from './version.mjs'
6
+ import { homedir } from 'node:os'
3
7
  import { apiFetch } from './api.mjs'
4
8
  import { abstractsOrigin, appOrigin, projectMetaPath } from './config.mjs'
5
9
  import { recordsFromLocal, readProjectFiles, writeProjectFiles } from './files.mjs'
10
+ import { startServe } from './serve.mjs'
6
11
  import { ALIASES, printHelp, wantsHelp } from './help.mjs'
7
12
  import { loginWithBrowser, loginWithMagicLink } from './login.mjs'
8
13
 
@@ -18,6 +23,8 @@ const BOOL_FLAGS = new Set([
18
23
  'member',
19
24
  'open',
20
25
  'closed',
26
+ 'quiet',
27
+ 'data',
21
28
  ])
22
29
 
23
30
  function parseFlags(args) {
@@ -70,7 +77,7 @@ function writeMeta(cwd, abstractId) {
70
77
  function requireMeta(cwd) {
71
78
  const meta = readMeta(cwd)
72
79
  if (!meta?.abstractId) {
73
- throw new Error('No .amorphous.json here. Run amorphous create first.')
80
+ throw new Error('No .amorphous.json here. Run npx amorphous-cli@latest create first.')
74
81
  }
75
82
  return meta.abstractId
76
83
  }
@@ -183,9 +190,15 @@ async function cmdMcp(rest) {
183
190
  async function cmdSetup(rest) {
184
191
  const { flags } = parseFlags(rest)
185
192
  if (flags.help) return printHelp('setup')
186
- const dirs = installUserSkill()
193
+ const refresh = shouldRefreshSkill(homedir(), CLI_VERSION)
194
+ const dirs = refresh ? installUserSkill() : undefined
195
+ const hooks = ensureSessionHooks()
196
+ if (flags.quiet) {
197
+ if (refresh || hooks) console.log('updated')
198
+ return
199
+ }
187
200
  console.log('Installed the Amorphous skill.')
188
- for (const dir of dirs) console.log(dir)
201
+ for (const dir of dirs ?? installUserSkill()) console.log(dir)
189
202
  }
190
203
 
191
204
  async function cmdCreate(cwd, rest) {
@@ -247,11 +260,14 @@ async function cmdInvite(cwd, rest) {
247
260
  const { access, rest: absArgs } = takeAccess(flags, afterEmail)
248
261
  const id = await resolveAbstractId(cwd, flags, absArgs)
249
262
  const role = parseAccess(access)
250
- await apiFetch(`/api/abstracts/${id}/grant`, {
263
+ const result = await apiFetch(`/api/abstracts/${id}/grant`, {
251
264
  method: 'POST',
252
265
  body: JSON.stringify({ email, role }),
253
266
  })
254
- console.log('Invited.')
267
+ const url = String(result.url || '')
268
+ if (!url) throw new Error('Could not invite.')
269
+ if (jsonMode(flags)) printJson({ email, access: role === 'user' ? 'view' : role === 'editor' ? 'edit' : role, url })
270
+ else console.log(url)
255
271
  }
256
272
 
257
273
  async function cmdRemove(cwd, rest) {
@@ -316,20 +332,45 @@ async function cmdPush(cwd, rest) {
316
332
  const { flags } = parseFlags(rest)
317
333
  if (flags.help) return printHelp('push')
318
334
  const id = requireMeta(cwd)
319
- const localRecords = recordsFromLocal(cwd)
320
- const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
321
- method: 'POST',
322
- body: JSON.stringify({ records: localRecords }),
323
- })
324
- if (refused?.length) {
325
- console.error(`Skipped (already live): ${refused.join(', ')}. Run: amorphous 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: npx amorphous-cli@latest pull`)
343
+ }
326
344
  }
327
345
  const { html, css, js } = readProjectFiles(cwd)
328
346
  await apiFetch(`/api/abstracts/${id}/push`, {
329
347
  method: 'POST',
330
348
  body: JSON.stringify({ html, css, js }),
331
349
  })
332
- 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
+ })
333
374
  }
334
375
 
335
376
  function urlsFor(id) {
@@ -615,7 +656,7 @@ async function cmdFolderInvite(rest) {
615
656
  method: 'POST',
616
657
  body: JSON.stringify({ email }),
617
658
  })
618
- console.log('Invited.')
659
+ console.log('Added.')
619
660
  }
620
661
 
621
662
  async function cmdFolderRemove(rest) {
@@ -690,7 +731,7 @@ async function cmdSpaceMember(rest) {
690
731
  method: 'POST',
691
732
  body: JSON.stringify({ email, role: flags.role || 'member' }),
692
733
  })
693
- console.log('Invited.')
734
+ console.log('Added.')
694
735
  }
695
736
 
696
737
  export async function runCommand(argv, cwd = process.cwd()) {
@@ -723,6 +764,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
723
764
  return cmdOpen(cwd, rest)
724
765
  case 'preview':
725
766
  return cmdPreview(cwd, rest)
767
+ case 'serve':
768
+ return cmdServe(cwd, rest)
726
769
  case 'publish':
727
770
  return cmdPublish(cwd, rest)
728
771
  case 'live':
@@ -756,6 +799,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
756
799
  if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
757
800
  throw new Error('Use folder instead.')
758
801
  default:
759
- throw new Error(`Unknown command “${raw}”. Run amorphous --help.`)
802
+ throw new Error(`Unknown command “${raw}”. Run npx amorphous-cli@latest --help.`)
760
803
  }
761
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,12 +7,13 @@ 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
13
14
  live [abstract] Make a version live
14
15
  visibility anyone|members Anyone can view / Members only
15
- invite <email> Invite
16
+ invite <email> Print the link to give them
16
17
  remove <email|id> Remove
17
18
  people [abstract] People
18
19
  tag / untag Tag
@@ -28,13 +29,14 @@ export const ROOT_HELP = `Amorphous
28
29
  folder people People on a folder
29
30
  rename --name Rename
30
31
 
31
- Run amorphous <command> --help for flags. Pass --json where you need machine output.
32
+ Run npx amorphous-cli@latest <command> --help for flags. Pass --json where you need machine output.
32
33
  `
33
34
 
34
35
  export const COMMAND_HELP = {
35
36
  setup: `setup
36
37
 
37
- Install the Amorphous skill for Claude Code, Cursor, and Codex.`,
38
+ Install the Amorphous skill for Claude Code, Cursor, and Codex.
39
+ Claude Code and Cursor get a session hook that refreshes it from the current CLI.`,
38
40
 
39
41
  login: `login [--email <email>] [--code <code>]
40
42
 
@@ -63,13 +65,19 @@ Print the Open URL.`,
63
65
 
64
66
  Print the Preview URL (draft).`,
65
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
+
66
73
  pull: `pull
67
74
 
68
75
  Pull the draft and records into this directory.`,
69
76
 
70
- push: `push
77
+ push: `push [--data]
71
78
 
72
- 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.`,
73
81
 
74
82
  publish: `publish [--live]
75
83
 
@@ -83,9 +91,9 @@ Make this the live version. Uses the latest published version if you omit --vers
83
91
 
84
92
  Anyone can view, or Members only. Uses the live version if you omit --version.`,
85
93
 
86
- invite: `invite <email> [view|use|edit] [--abstract <id|name>]
94
+ invite: `invite <email> [view|use|edit] [--abstract <id|name>] [--json]
87
95
 
88
- Invite someone by email. Access: view, use (Can use), or edit. Default: view.`,
96
+ Save an invite for this email and print the link to give them. Access: view, use, or edit. Default: view.`,
89
97
 
90
98
  remove: `remove <email|id> [--abstract <id|name>]
91
99
 
@@ -136,7 +144,7 @@ Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
136
144
  folder remove <folder> <email|id>
137
145
  folder people <folder> [--json]
138
146
 
139
- New folder, move a folder, or invite people to a folder.`,
147
+ New folder, or move a folder. folder invite adds someone who already has an account. It does not send an email.`,
140
148
 
141
149
  rename: `rename [abstract] --name <name>
142
150
 
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: amorphous login ${email} --code <code>`,
73
+ `Code sent to ${email}. Read it from that inbox, then run: npx amorphous-cli@latest login ${email} --code <code>`,
74
74
  )
75
75
  }
76
76
  const rl = createInterface({ input: process.stdin, output: process.stderr })