amorphous-cli 0.1.11 → 0.1.13

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
@@ -5,3 +5,5 @@ Make whatever you want, yourself, with your own agent, or with others. And whate
5
5
  ```bash
6
6
  npx amorphous-cli@latest setup
7
7
  ```
8
+
9
+ Create a purpose-built starter with `amorphous-cli create --template list|board|landing` (or omit the flag for a blank page). Collaborators can run `amorphous-cli recommend --note "…"` to send local source to the owner for review. The owner can publish a recommendation live or decline it in the abstract.
@@ -53,12 +53,16 @@ export function syncProjectContract(cwd) {
53
53
  return 'refreshed'
54
54
  }
55
55
 
56
- export function writeStarter(cwd) {
56
+ export function writeStarter(cwd, template = 'blank') {
57
57
  const assets = join(skillRoot, 'assets')
58
+ const templates = JSON.parse(readFileSync(join(dirname(skillRoot), '../templates.json'), 'utf8'))
59
+ const selected = templates[template]
60
+ if (!selected) throw new Error(`Unknown template “${template}”. Choose blank, list, board, or landing.`)
61
+ const starterFiles = { 'index.html': selected.html, 'styles.css': selected.css, 'app.js': selected.js }
58
62
  for (const name of ['index.html', 'styles.css', 'app.js']) {
59
63
  const dest = join(cwd, name)
60
64
  if (!existsSync(dest)) {
61
- writeFileSync(dest, readFileSync(join(assets, name)))
65
+ writeFileSync(dest, starterFiles[name] ?? readFileSync(join(assets, name)))
62
66
  }
63
67
  }
64
68
  for (const name of ['data.ndjson', 'private.ndjson']) {
@@ -31,7 +31,18 @@ test('setup copies the skill, and an edited contract is left alone', () => {
31
31
  const project = mkdtempSync(join(tmpdir(), 'amorphous-page-'))
32
32
  assert.equal(syncProjectContract(project), 'wrote')
33
33
  writeStarter(project)
34
- assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /id="who"/)
34
+ assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /Make this abstract your own/)
35
+ assert.equal(readFileSync(join(project, 'styles.css'), 'utf8').length > 0, true)
36
+ const board = mkdtempSync(join(tmpdir(), 'amorphous-board-'))
37
+ writeStarter(board, 'board')
38
+ assert.match(readFileSync(join(board, 'index.html'), 'utf8'), /Project board/)
39
+ assert.match(readFileSync(join(board, 'app.js'), 'utf8'), /board-card/)
40
+ for (const [template, expected] of [['list', 'SHARED LIST'], ['landing', 'A CLEAR, HUMAN INTRODUCTION']]) {
41
+ const dir = mkdtempSync(join(tmpdir(), `amorphous-${template}-`))
42
+ writeStarter(dir, template)
43
+ assert.match(readFileSync(join(dir, 'index.html'), 'utf8'), new RegExp(expected))
44
+ }
45
+ assert.throws(() => writeStarter(project, 'unknown'), /Unknown template/)
35
46
  assert.match(readFileSync(join(project, 'AGENTS.md'), 'utf8'), new RegExp(`amorphous-contract: ${CONTRACT_VERSION}`))
36
47
 
37
48
  const edited = readFileSync(join(project, 'AGENTS.md'), 'utf8') + '\nEdited.\n'
package/lib/api.mjs CHANGED
@@ -2,7 +2,6 @@ import { cliAppFetchOrigin, cliAppHostHeader } from './config.mjs'
2
2
  import { ensureFreshSession } from './session.mjs'
3
3
 
4
4
  const FRIENDLY = {
5
- 'This abstract already has 8 people': 'This abstract already has 8 people. Run: amorphous-cli people',
6
5
  'User not found': 'No account with that email yet.',
7
6
  'Grant failed': 'Could not invite. Run: amorphous-cli people',
8
7
  'Cannot revoke owner': "You can't remove the owner. Run: amorphous-cli people",
@@ -29,6 +28,9 @@ export function friendlyMessage(raw) {
29
28
  if (/^You already own \d+ abstracts$/.test(stripped)) {
30
29
  return `${stripped}. Run: amorphous-cli list`
31
30
  }
31
+ if (/^This abstract already has \d+ people$/.test(stripped)) {
32
+ return `${stripped}. Run: amorphous-cli people`
33
+ }
32
34
  return stripped || text || 'Request failed.'
33
35
  }
34
36
 
package/lib/commands.mjs CHANGED
@@ -64,6 +64,12 @@ function printJson(data) {
64
64
  console.log(JSON.stringify(data, null, 2))
65
65
  }
66
66
 
67
+ function warnIfRemoteChanged(detail) {
68
+ if (detail?.remoteChangedSincePush) {
69
+ console.error('This abstract has changed since you last pushed.')
70
+ }
71
+ }
72
+
67
73
  function readMeta(cwd) {
68
74
  const path = projectMetaPath(cwd)
69
75
  if (!existsSync(path)) return null
@@ -209,7 +215,8 @@ They tell you when to push, preview, publish, or invite.`)
209
215
  async function cmdCreate(cwd, rest) {
210
216
  const { flags } = parseFlags(rest)
211
217
  if (flags.help) return printHelp('create')
212
- const data = await apiFetch('/api/abstracts', { method: 'POST' })
218
+ const template = typeof flags.template === 'string' ? flags.template : 'blank'
219
+ const data = await apiFetch('/api/abstracts', { method: 'POST', body: JSON.stringify({ template }) })
213
220
  const id = data.id
214
221
  writeMeta(cwd, id)
215
222
  const name = typeof flags.name === 'string' ? flags.name.trim() : ''
@@ -227,7 +234,7 @@ async function cmdCreate(cwd, rest) {
227
234
  body: JSON.stringify({ collection_id: folder.id }),
228
235
  })
229
236
  }
230
- writeStarter(cwd)
237
+ writeStarter(cwd, template)
231
238
  syncProjectContract(cwd)
232
239
  if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
233
240
  else {
@@ -274,8 +281,26 @@ async function cmdInvite(cwd, rest) {
274
281
  })
275
282
  const url = String(result.url || '')
276
283
  if (!url) throw new Error('Could not invite.')
277
- if (jsonMode(flags)) printJson({ email, access: role === 'user' ? 'view' : role === 'editor' ? 'edit' : role, url })
278
- else console.log(url)
284
+ const accessLabel = role === 'user' ? 'view' : role === 'editor' ? 'edit' : role
285
+ if (jsonMode(flags)) {
286
+ printJson({ email, access: accessLabel, url, mailed: Boolean(result.mailed) })
287
+ return
288
+ }
289
+ console.log(url)
290
+ if (result.notified === 'role') console.log('Updated their access.')
291
+ else if (result.mailed) console.log('Emailed the invite.')
292
+ else console.log('The invite is saved. The email did not send.')
293
+ }
294
+
295
+ async function cmdNotify(cwd, rest) {
296
+ const { flags, positional } = parseFlags(rest)
297
+ if (flags.help) return printHelp('notify')
298
+ const id = await resolveAbstractId(cwd, flags, positional)
299
+ const result = await apiFetch(`/api/abstracts/${id}/notify`, { method: 'POST' })
300
+ const sent = Number(result.sent || 0)
301
+ if (jsonMode(flags)) printJson({ sent })
302
+ else if (sent === 0) console.log('No one else to email.')
303
+ else console.log(sent === 1 ? 'Emailed 1 member.' : `Emailed ${sent} members.`)
279
304
  }
280
305
 
281
306
  async function cmdRemove(cwd, rest) {
@@ -321,6 +346,7 @@ async function cmdPull(cwd, rest) {
321
346
  if (flags.help) return printHelp('pull')
322
347
  const id = requireMeta(cwd)
323
348
  const detail = await apiFetch(`/api/abstracts/${id}`)
349
+ warnIfRemoteChanged(detail)
324
350
  const records = await apiFetch(`/api/abstracts/${id}/records`)
325
351
  const rows = records.records || []
326
352
  const shared = rows.filter((r) => r.visibility !== 'private')
@@ -341,6 +367,7 @@ async function cmdPush(cwd, rest) {
341
367
  const { flags } = parseFlags(rest)
342
368
  if (flags.help) return printHelp('push')
343
369
  const id = requireMeta(cwd)
370
+ warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
344
371
  if (flags.data) {
345
372
  const localRecords = recordsFromLocal(cwd)
346
373
  const { refused } = await apiFetch(`/api/abstracts/${id}/records`, {
@@ -373,8 +400,27 @@ async function cmdServe(cwd, rest) {
373
400
  port,
374
401
  json: jsonMode(flags),
375
402
  })
403
+ let warnedRemote = false
404
+ async function watchRemote() {
405
+ try {
406
+ const detail = await apiFetch(`/api/abstracts/${id}`)
407
+ if (detail.remoteChangedSincePush && !warnedRemote) {
408
+ warnedRemote = true
409
+ console.error('This abstract has changed since you last pushed.')
410
+ } else if (!detail.remoteChangedSincePush) {
411
+ warnedRemote = false
412
+ }
413
+ } catch {
414
+ /* leave the dev server up if the check cannot reach the abstract */
415
+ }
416
+ }
417
+ void watchRemote()
418
+ const remoteTimer = setInterval(() => {
419
+ void watchRemote()
420
+ }, 20000)
376
421
  await new Promise((resolve) => {
377
422
  const stop = () => {
423
+ clearInterval(remoteTimer)
378
424
  running.close().then(resolve, resolve)
379
425
  }
380
426
  process.once('SIGINT', stop)
@@ -403,8 +449,10 @@ async function cmdPreview(cwd, rest) {
403
449
  const { flags, positional } = parseFlags(rest)
404
450
  if (flags.help) return printHelp('preview')
405
451
  const id = await resolveAbstractId(cwd, flags, positional)
452
+ const detail = await apiFetch(`/api/abstracts/${id}`)
453
+ warnIfRemoteChanged(detail)
406
454
  const urls = urlsFor(id)
407
- if (jsonMode(flags)) printJson({ preview: urls.preview })
455
+ if (jsonMode(flags)) printJson({ preview: urls.preview, remoteChangedSincePush: Boolean(detail.remoteChangedSincePush) })
408
456
  else console.log(urls.preview)
409
457
  }
410
458
 
@@ -412,6 +460,7 @@ async function cmdPublish(cwd, rest) {
412
460
  const { flags, positional } = parseFlags(rest)
413
461
  if (flags.help) return printHelp('publish')
414
462
  const id = await resolveAbstractId(cwd, flags, positional)
463
+ warnIfRemoteChanged(await apiFetch(`/api/abstracts/${id}`))
415
464
  const live = Boolean(flags.live)
416
465
  const version = await apiFetch(`/api/abstracts/${id}/publish`, {
417
466
  method: 'POST',
@@ -421,6 +470,18 @@ async function cmdPublish(cwd, rest) {
421
470
  else console.log(live ? 'It’s live.' : 'Published.')
422
471
  }
423
472
 
473
+ async function cmdRecommend(cwd, rest) {
474
+ const { flags } = parseFlags(rest)
475
+ if (flags.help) return printHelp('recommend')
476
+ const id = requireMeta(cwd)
477
+ const { html, css, js } = readProjectFiles(cwd)
478
+ const result = await apiFetch(`/api/abstracts/${id}/recommendations`, {
479
+ method: 'POST', body: JSON.stringify({ html, css, js, note: typeof flags.note === 'string' ? flags.note : '' }),
480
+ })
481
+ if (jsonMode(flags)) printJson(result)
482
+ else console.log('Recommendation sent to the abstract owner.')
483
+ }
484
+
424
485
  async function cmdLive(cwd, rest) {
425
486
  const { flags, positional } = parseFlags(rest)
426
487
  if (flags.help) return printHelp('live')
@@ -472,7 +533,6 @@ async function cmdList(rest) {
472
533
  if (folder) params.set('collection', String(folder))
473
534
  if (flags.tag) params.set('tag', String(flags.tag))
474
535
  if (flags.search) params.set('search', String(flags.search))
475
- if (flags.space) params.set('space', String(flags.space))
476
536
  for (const key of ['starred', 'archived', 'owned', 'member', 'open', 'closed']) {
477
537
  if (flags[key]) params.set(key, 'true')
478
538
  }
@@ -655,58 +715,6 @@ async function cmdFolderMove(rest) {
655
715
  console.log('Moved folder.')
656
716
  }
657
717
 
658
- async function cmdFolderInvite(rest) {
659
- const { flags, positional } = parseFlags(rest)
660
- const token = positional[0]
661
- const email = flags.email || positional[1]
662
- if (!token || !email) throw new Error('Pass a folder and --email <email>.')
663
- const folder = await resolveFolder(token)
664
- await apiFetch(`/api/organise/collections/${folder.id}/invite`, {
665
- method: 'POST',
666
- body: JSON.stringify({ email }),
667
- })
668
- console.log('Added.')
669
- }
670
-
671
- async function cmdFolderRemove(rest) {
672
- const { flags, positional } = parseFlags(rest)
673
- const token = positional[0]
674
- const who = flags.email || positional[1]
675
- if (!token || !who) throw new Error('Pass a folder and an email or id.')
676
- const folder = await resolveFolder(token)
677
- const body = /@/.test(who) ? { email: who } : { userId: who }
678
- await apiFetch(`/api/organise/collections/${folder.id}/revoke`, {
679
- method: 'POST',
680
- body: JSON.stringify(body),
681
- })
682
- console.log('Removed.')
683
- }
684
-
685
- async function cmdFolderPeople(rest) {
686
- const { flags, positional } = parseFlags(rest)
687
- const token = positional[0]
688
- if (!token) throw new Error('Pass a folder.')
689
- const folder = await resolveFolder(token)
690
- const share = await apiFetch(`/api/organise/collections/${folder.id}`)
691
- const people = (share.people || []).map((m) => ({
692
- id: m.user_id,
693
- name: m.name,
694
- access: accessLabel(m.role),
695
- role: m.role,
696
- }))
697
- if (jsonMode(flags)) {
698
- printJson({ folder: folder.path, people })
699
- return
700
- }
701
- if (!people.length) {
702
- console.log('No people yet.')
703
- return
704
- }
705
- for (const p of people) {
706
- console.log(`${p.name} · ${p.access} · ${p.id}`)
707
- }
708
- }
709
-
710
718
  async function cmdFolder(rest) {
711
719
  const { flags } = parseFlags(rest)
712
720
  if (flags.help || !rest[0]) return printHelp('folder')
@@ -714,33 +722,7 @@ async function cmdFolder(rest) {
714
722
  if (sub === 'new') return cmdFolderNew(tail)
715
723
  if (sub === 'list') return cmdFolderList(tail)
716
724
  if (sub === 'move') return cmdFolderMove(tail)
717
- if (sub === 'invite') return cmdFolderInvite(tail)
718
- if (sub === 'remove') return cmdFolderRemove(tail)
719
- if (sub === 'people') return cmdFolderPeople(tail)
720
- throw new Error('folder needs new, list, move, invite, remove, or people.')
721
- }
722
-
723
- async function cmdSpaceNew(rest) {
724
- const { flags } = parseFlags(rest)
725
- const name = flags.name || rest.find((a) => !a.startsWith('--'))
726
- if (!name) throw new Error('Use folder new instead.')
727
- const data = await apiFetch('/api/spaces', {
728
- method: 'POST',
729
- body: JSON.stringify({ name }),
730
- })
731
- console.log(data.id, data.name)
732
- }
733
-
734
- async function cmdSpaceMember(rest) {
735
- const { flags, positional } = parseFlags(rest)
736
- const spaceId = flags.space || positional[0]
737
- const email = flags.email || positional[1]
738
- if (!spaceId || !email) throw new Error('Use folder invite instead.')
739
- await apiFetch(`/api/spaces/${spaceId}/members`, {
740
- method: 'POST',
741
- body: JSON.stringify({ email, role: flags.role || 'member' }),
742
- })
743
- console.log('Added.')
725
+ throw new Error('folder needs new, list, or move.')
744
726
  }
745
727
 
746
728
  export async function runCommand(argv, cwd = process.cwd()) {
@@ -761,6 +743,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
761
743
  return cmdCreate(cwd, rest)
762
744
  case 'invite':
763
745
  return cmdInvite(cwd, rest)
746
+ case 'notify':
747
+ return cmdNotify(cwd, rest)
764
748
  case 'remove':
765
749
  return cmdRemove(cwd, rest)
766
750
  case 'people':
@@ -777,6 +761,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
777
761
  return cmdServe(cwd, rest)
778
762
  case 'publish':
779
763
  return cmdPublish(cwd, rest)
764
+ case 'recommend':
765
+ return cmdRecommend(cwd, rest)
780
766
  case 'live':
781
767
  return cmdLive(cwd, rest)
782
768
  case 'visibility':
@@ -803,10 +789,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
803
789
  return cmdFolder(rest)
804
790
  case 'rename':
805
791
  return cmdRename(cwd, rest)
806
- case 'space':
807
- if (rest[0] === 'new') return cmdSpaceNew(rest.slice(1))
808
- if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
809
- throw new Error('Use folder instead.')
810
792
  default:
811
793
  throw new Error(`Unknown command “${raw}”. Run amorphous-cli --help.`)
812
794
  }
package/lib/help.mjs CHANGED
@@ -11,9 +11,11 @@ export const ROOT_HELP = `Amorphous
11
11
  pull Pull draft and records to this folder
12
12
  push Push local files
13
13
  publish [--live] Publish a version; --live is Go live
14
+ recommend [--note <text>] Send this draft to the owner for review
14
15
  live [abstract] Make a version live
15
16
  visibility anyone|members Anyone can view / Members only
16
- invite <email> Print the link to give them
17
+ invite <email> Email an invite and print the link
18
+ notify Email members about the published version
17
19
  remove <email|id> Remove
18
20
  people [abstract] People
19
21
  tag / untag Tag
@@ -24,9 +26,6 @@ export const ROOT_HELP = `Amorphous
24
26
  folder new New folder
25
27
  folder list Folders
26
28
  folder move Move folder
27
- folder invite Invite to a folder
28
- folder remove Remove from a folder
29
- folder people People on a folder
30
29
  rename --name Rename
31
30
 
32
31
  Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
@@ -53,7 +52,7 @@ Start the local MCP server on stdio.`,
53
52
  List abstracts. Default view is all (All Abstracts).
54
53
  --folder filters to a folder. --tag filters to a tag.`,
55
54
 
56
- create: `create [--name <name>] [--folder <name>] [--json]
55
+ create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--json]
57
56
 
58
57
  New abstract. Writes .amorphous.json in this directory.`,
59
58
 
@@ -83,6 +82,10 @@ Push local index.html, styles.css, and app.js.
83
82
 
84
83
  Publish a new version. --live also makes it the live version (Go live).`,
85
84
 
85
+ recommend: `recommend [--note <text>] [--json]
86
+
87
+ Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version.`,
88
+
86
89
  live: `live [abstract] [--version <id>]
87
90
 
88
91
  Make this the live version. Uses the latest published version if you omit --version.`,
@@ -93,7 +96,11 @@ Anyone can view, or Members only. Uses the live version if you omit --version.`,
93
96
 
94
97
  invite: `invite <email> [view|use|edit] [--abstract <id|name>] [--json]
95
98
 
96
- Save an invite for this email and print the link to give them. Access: view, use, or edit. Default: view.`,
99
+ Email an invite and print the link. Access: view, use, or edit. Default: view.`,
100
+
101
+ notify: `notify [abstract] [--json]
102
+
103
+ Email members that the published version changed. Does not publish. You are not emailed.`,
97
104
 
98
105
  remove: `remove <email|id> [--abstract <id|name>]
99
106
 
@@ -135,16 +142,12 @@ Unstar.`,
135
142
 
136
143
  Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
137
144
 
138
- folder: `folder <new|list|move|invite|remove|people> …
145
+ folder: `folder <new|list|move> …
139
146
 
140
147
  folder new --name <name> [--parent <id>]
141
148
  folder list [--json]
142
149
  folder move <folder> --parent <id|none>
143
- folder invite <folder> --email <email>
144
- folder remove <folder> <email|id>
145
- folder people <folder> [--json]
146
-
147
- New folder, or move a folder. folder invite adds someone who already has an account. It does not send an email.`,
150
+ New folder, or move a folder. Folders organize your library and do not grant access.`,
148
151
 
149
152
  rename: `rename [abstract] --name <name>
150
153
 
package/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Local MCP. Six tools for abstracts.
3
+ * Local MCP. Tools for abstracts.
4
4
  * Stdout is the protocol. The CLI runs as a child so its output stays off this stream.
5
5
  */
6
6
  import { spawn } from 'node:child_process'
@@ -117,12 +117,23 @@ await serveStdio(() => {
117
117
  async ({ live }) => call(live ? ['publish', '--live', '--json'] : ['publish', '--json']),
118
118
  )
119
119
 
120
+ server.registerTool(
121
+ 'recommend',
122
+ {
123
+ title: 'Recommend a version to the owner',
124
+ description: 'Submit the local HTML, CSS, and JavaScript as a proposal. It stays out of the live version until the owner publishes it.',
125
+ inputSchema: z.object({ note: z.string().max(1000).optional().describe('Context for the owner') }),
126
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
127
+ },
128
+ async ({ note }) => call(note ? ['recommend', '--note', note] : ['recommend']),
129
+ )
130
+
120
131
  server.registerTool(
121
132
  'invite',
122
133
  {
123
134
  title: 'Invite someone',
124
135
  description:
125
- 'Save an invite for this email and return the link to give them. This does not send an email. access is view (can open), use (can write shared records), or edit (can change the abstract).',
136
+ 'Email an invite and return the link. access is view (can open), use (can write shared records), or edit (can change the abstract).',
126
137
  inputSchema: z.object({
127
138
  email: z.string().describe('Email address'),
128
139
  access: z.enum(['view', 'use', 'edit']).describe('view, use, or edit'),
@@ -132,5 +143,17 @@ await serveStdio(() => {
132
143
  async ({ email, access }) => call(['invite', email, access, '--json']),
133
144
  )
134
145
 
146
+ server.registerTool(
147
+ 'notify',
148
+ {
149
+ title: 'Email members about the published version',
150
+ description:
151
+ 'Email members that the published version changed. On demand. This does not publish. The person running it is not emailed.',
152
+ inputSchema: z.object({}),
153
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
154
+ },
155
+ async () => call(['notify', '--json']),
156
+ )
157
+
135
158
  return server
136
159
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amorphous-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "mcpName": "io.github.amorphous-one/amorphous",
5
5
  "description": "Make whatever you want, yourself, with your own agent, or with others.",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "lib",
16
16
  "hooks",
17
17
  "skill",
18
+ "templates.json",
18
19
  "README.md"
19
20
  ],
20
21
  "engines": {
@@ -43,7 +43,7 @@ They tell you when to push, preview, publish, or invite. Do those when they ask.
43
43
  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.
44
44
 
45
45
  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.
46
- 2. `amorphous-cli create --name "..."` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there.
46
+ 2. `amorphous-cli create --name "..." [--template blank|list|board|landing]` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there. The list, board, and landing starters are working examples, not just mockups.
47
47
  3. Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here.
48
48
  4. Prefer `amorphous-cli serve` while you edit. Open that URL and leave it running. It reloads when the files change, and it is the easiest way to check the work. Records on that page start in this folder. Switch the page to prod to read and write the abstract's records. You can push and preview without it.
49
49
 
@@ -52,7 +52,9 @@ When they ask:
52
52
  - `amorphous-cli push` sends the source. `amorphous-cli push --data` also sends the records in this folder.
53
53
  - `amorphous-cli preview` and open that URL.
54
54
  - `amorphous-cli publish --live`
55
- - `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.
55
+ - Collaborators who should propose source for the owner to review can use `amorphous-cli recommend --note "..."`. It does not change the live abstract; the owner reviews and chooses Publish live or Decline.
56
+ - `amorphous-cli invite <email> use` or `edit` or `view`. It emails them a link and prints that link. They open it and sign in with that email.
57
+ - `amorphous-cli notify` emails members that the published version changed. It does not publish. Do that only when they ask.
56
58
 
57
59
  `amorphous-cli visibility anyone` lets anyone view and write shared records. `amorphous-cli visibility members` keeps the abstract to invited people.
58
60
 
package/templates.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "blank": {
3
+ "label": "Blank",
4
+ "description": "A clean starting point",
5
+ "html": "<main>\n <h1>Untitled</h1>\n <p>Make this abstract your own.</p>\n</main>",
6
+ "css": "body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: #18181b; background: #fafafa; }\nmain { max-width: 720px; margin: 12vh auto; padding: 24px; }\nh1 { letter-spacing: -0.04em; }",
7
+ "js": ""
8
+ },
9
+ "list": {
10
+ "label": "List",
11
+ "description": "A shared checklist everyone can update",
12
+ "html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">SHARED LIST</p><h1>Things to do</h1><p class=\"muted\">Add an item or check one off. Everyone sees the same list.</p></header>\n <form id=\"add-form\"><input id=\"new-item\" aria-label=\"New item\" placeholder=\"Add something…\" maxlength=\"180\" required><button>Add</button></form>\n <p id=\"empty\" class=\"muted\">Your list is empty.</p><ul id=\"items\"></ul>\n</main>",
13
+ "css": "* { box-sizing: border-box; } body { margin: 0; background: #f5f4f0; color: #20201e; font: 16px/1.5 system-ui, sans-serif; } .shell { max-width: 680px; margin: 8vh auto; padding: 32px; background: #fff; border: 1px solid #e8e6e0; border-radius: 18px; } .eyebrow { color: #77746c; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 8vw, 52px); letter-spacing: -.055em; } .muted { color: #77746c; } form { display: flex; gap: 8px; margin: 28px 0 18px; } input { min-width: 0; flex: 1; padding: 12px 14px; border: 1px solid #d8d5cd; border-radius: 9px; font: inherit; } button { padding: 10px 18px; border: 0; border-radius: 9px; background: #242421; color: white; font: inherit; cursor: pointer; } ul { display: grid; gap: 8px; padding: 0; list-style: none; } li { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #eeece7; border-radius: 9px; } li.done span { color: #929087; text-decoration: line-through; } li span { flex: 1; overflow-wrap: anywhere; } li button { padding: 5px 8px; color: #66635d; background: transparent; }",
14
+ "js": "const form = document.querySelector('#add-form')\nconst input = document.querySelector('#new-item')\nconst list = document.querySelector('#items')\nconst empty = document.querySelector('#empty')\n\nfunction render(rows) {\n list.replaceChildren()\n empty.hidden = rows.length > 0\n for (const row of rows.filter(item => item.kind === 'list-item')) {\n const li = document.createElement('li')\n if (row.done) li.classList.add('done')\n const check = document.createElement('input')\n check.type = 'checkbox'\n check.checked = Boolean(row.done)\n check.setAttribute('aria-label', `Mark ${row.text} complete`)\n check.addEventListener('change', async () => {\n await amorphous.data.replace(row.id, { ...row, done: check.checked })\n render(await amorphous.data.find())\n })\n const text = document.createElement('span')\n text.textContent = row.text\n const remove = document.createElement('button')\n remove.type = 'button'\n remove.textContent = 'Remove'\n remove.addEventListener('click', async () => {\n // The bridge intentionally has no delete method; mark removed items instead.\n await amorphous.data.replace(row.id, { ...row, removed: true })\n render(await amorphous.data.find())\n })\n if (row.removed) continue\n li.append(check, text, remove)\n list.append(li)\n }\n}\n\nform.addEventListener('submit', async event => {\n event.preventDefault()\n const text = input.value.trim()\n if (!text) return\n await amorphous.data.insert({ kind: 'list-item', text, done: false })\n input.value = ''\n render(await amorphous.data.find())\n})\n\nrender(await amorphous.data.find())"
15
+ },
16
+ "board": {
17
+ "label": "Board",
18
+ "description": "A small kanban board with shared cards",
19
+ "html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">TEAM BOARD</p><h1>Project board</h1><p class=\"muted\">Move cards across the columns as work progresses.</p></header>\n <form id=\"add-form\"><input id=\"new-card\" aria-label=\"New card\" placeholder=\"Add a card…\" maxlength=\"180\" required><button>Add card</button></form>\n <section id=\"columns\" class=\"columns\"></section>\n</main>",
20
+ "css": "* { box-sizing: border-box; } body { margin: 0; background: #f4f5f7; color: #20242b; font: 15px/1.5 system-ui, sans-serif; } .shell { max-width: 1120px; margin: 7vh auto; padding: 28px; } .eyebrow { color: #727985; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.05em; } .muted { color: #727985; } form { display: flex; gap: 8px; max-width: 580px; margin: 24px 0; } input { min-width: 0; flex: 1; padding: 11px 13px; border: 1px solid #d7dbe2; border-radius: 8px; font: inherit; } button { padding: 9px 14px; border: 0; border-radius: 8px; background: #29313d; color: #fff; font: inherit; cursor: pointer; } .columns { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; } .column { min-height: 190px; padding: 12px; border: 1px solid #e0e3e9; border-radius: 12px; background: #e9ecf1; } .column h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; } .card { display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 11px; border: 1px solid #e2e4e9; border-radius: 9px; background: white; } .card span { flex: 1; overflow-wrap: anywhere; } .card select { max-width: 105px; padding: 5px; border: 1px solid #d7dbe2; border-radius: 6px; background: white; font: inherit; font-size: 12px; } @media (max-width: 760px) { .columns { grid-template-columns: repeat(3, minmax(250px, 1fr)); } }",
21
+ "js": "const stages = ['To do', 'In progress', 'Done']\nconst form = document.querySelector('#add-form')\nconst input = document.querySelector('#new-card')\nconst columns = document.querySelector('#columns')\n\nasync function render() {\n const rows = (await amorphous.data.find()).filter(row => row.kind === 'board-card')\n columns.replaceChildren()\n for (const stage of stages) {\n const column = document.createElement('section')\n column.className = 'column'\n const heading = document.createElement('h2')\n heading.textContent = `${stage} · ${rows.filter(row => (row.stage || stages[0]) === stage).length}`\n column.append(heading)\n for (const row of rows.filter(item => (item.stage || stages[0]) === stage)) {\n const card = document.createElement('article')\n card.className = 'card'\n const text = document.createElement('span')\n text.textContent = row.text\n const select = document.createElement('select')\n select.setAttribute('aria-label', `Move ${row.text}`)\n for (const optionText of stages) {\n const option = document.createElement('option')\n option.value = optionText\n option.textContent = optionText\n option.selected = optionText === (row.stage || stages[0])\n select.append(option)\n }\n select.addEventListener('change', async () => {\n await amorphous.data.replace(row.id, { ...row, stage: select.value })\n await render()\n })\n card.append(text, select)\n column.append(card)\n }\n columns.append(column)\n }\n}\n\nform.addEventListener('submit', async event => {\n event.preventDefault()\n const text = input.value.trim()\n if (!text) return\n await amorphous.data.insert({ kind: 'board-card', text, stage: stages[0] })\n input.value = ''\n await render()\n})\n\nrender()"
22
+ },
23
+ "landing": {
24
+ "label": "Landing page",
25
+ "description": "A polished one-page introduction for an idea or project",
26
+ "html": "<main>\n <nav><a class=\"brand\" href=\"#top\">Your project</a><a href=\"#about\">About</a><a class=\"nav-cta\" href=\"#contact\">Get in touch</a></nav>\n <section id=\"top\" class=\"hero\"><p class=\"eyebrow\">A CLEAR, HUMAN INTRODUCTION</p><h1>Make your next good idea easier to find.</h1><p class=\"lead\">Describe what you are building, who it helps, and why it matters. Replace this copy with your own.</p><a class=\"cta\" href=\"#contact\">Start a conversation <span>↗</span></a><div class=\"orb\" aria-hidden=\"true\"></div></section>\n <section id=\"about\" class=\"about\"><p class=\"eyebrow\">WHAT IT IS</p><h2>A simple page for a specific purpose.</h2><div class=\"cards\"><article><b>01</b><h3>Who it is for</h3><p>Name the people you want to reach.</p></article><article><b>02</b><h3>What it does</h3><p>Explain the useful thing you make possible.</p></article><article><b>03</b><h3>Why now</h3><p>Give visitors a reason to care today.</p></article></div></section>\n <footer id=\"contact\"><div><p class=\"eyebrow\">NEXT STEP</p><h2>Have a question?</h2><p>Replace this with your preferred contact link.</p></div><a class=\"cta\" href=\"mailto:hello@example.com\">Say hello <span>↗</span></a></footer>\n</main>",
27
+ "css": "* { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; background: #f8f7f4; color: #20211f; font: 16px/1.55 system-ui, sans-serif; } main { max-width: 1180px; margin: auto; padding: 0 28px; } nav { display: flex; align-items: center; gap: 26px; min-height: 76px; border-bottom: 1px solid #e5e3dc; } nav a { color: inherit; text-decoration: none; font-size: 14px; } nav .brand { margin-right: auto; font-weight: 700; } .nav-cta { padding: 9px 13px; border: 1px solid #d6d3ca; border-radius: 99px; } .hero { position: relative; min-height: 570px; padding: 110px 0 95px; overflow: hidden; } .eyebrow { color: #77786d; font-size: 11px; font-weight: 700; letter-spacing: .15em; } h1 { position: relative; z-index: 1; max-width: 760px; margin: 18px 0; font-size: clamp(48px, 9vw, 88px); line-height: .98; letter-spacing: -.07em; } .lead { position: relative; z-index: 1; max-width: 490px; color: #696a62; font-size: 18px; } .cta { position: relative; z-index: 1; display: inline-flex; gap: 22px; align-items: center; margin-top: 22px; padding: 13px 17px; border-radius: 99px; background: #242622; color: white; text-decoration: none; font-size: 14px; } .orb { position: absolute; right: 4%; bottom: 4%; width: 270px; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #e9c7a6, #c89f7e 48%, #756c61 75%); filter: saturate(.7); opacity: .72; } .about { padding: 65px 0 95px; border-top: 1px solid #e5e3dc; } h2 { max-width: 600px; font-size: clamp(32px, 5vw, 48px); line-height: 1.05; letter-spacing: -.055em; } .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 42px; } .cards article { padding: 22px; border: 1px solid #e5e3dc; border-radius: 12px; background: #fffefa; } .cards b { color: #9d795c; font-size: 12px; } h3 { margin: 18px 0 4px; } .cards p, footer p:not(.eyebrow) { color: #696a62; } footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 55px; border-top: 1px solid #e5e3dc; } footer h2 { margin: 6px 0; } @media (max-width: 650px) { main { padding: 0 18px; } nav { gap: 14px; } .hero { min-height: 520px; padding-top: 84px; } .orb { right: -40px; width: 210px; } .cards { grid-template-columns: 1fr; } footer { align-items: flex-start; flex-direction: column; } }",
28
+ "js": "// This starter is a static landing page. Replace the copy, links, and sections in index.html.\n"
29
+ }
30
+ }