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 +2 -0
- package/lib/agent-contract.mjs +6 -2
- package/lib/agent-contract.test.mjs +12 -1
- package/lib/api.mjs +3 -1
- package/lib/commands.mjs +71 -89
- package/lib/help.mjs +15 -12
- package/mcp.mjs +25 -2
- package/package.json +2 -1
- package/skill/amorphous/SKILL.md +4 -2
- package/templates.json +30 -0
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.
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -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'), /
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
'
|
|
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.
|
|
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": {
|
package/skill/amorphous/SKILL.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
+
}
|