amorphous-cli 0.3.3 → 0.3.4

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.
@@ -107,6 +107,46 @@ test('context fetches only authorized guide endpoints and materializes both docs
107
107
  assert.equal(calls.length, 4, 'a new context invocation freshly authorizes both guides')
108
108
  })
109
109
 
110
+ test('context does not ask the agent to read guides that contain no text', async (t) => {
111
+ const h = await contextHarness(t, {
112
+ responder(req, res) {
113
+ res.writeHead(200, { 'content-type': 'application/json' })
114
+ res.end(JSON.stringify({ body: '', revision: 0, updatedAt: null, updatedBy: null }))
115
+ },
116
+ })
117
+ const logs = []
118
+ const originalLog = console.log
119
+ console.log = (...args) => logs.push(args.join(' '))
120
+ try {
121
+ await runCommand(['context'], h.project)
122
+ } finally { console.log = originalLog }
123
+
124
+ assert.equal(readFileSync(join(h.project, 'agent.md'), 'utf8'), '')
125
+ assert.equal(readFileSync(join(h.project, 'private-agent.md'), 'utf8'), '')
126
+ assert.ok(logs.includes('Neither guide contains text; continue with the abstract task.'))
127
+ assert.equal(logs.some((line) => line.startsWith('Read agent.md')), false)
128
+ assert.equal(logs.some((line) => line.startsWith('The private guide is supplied')), false)
129
+ })
130
+
131
+ test('context names only guide files that contain text', async (t) => {
132
+ const h = await contextHarness(t, {
133
+ responder(req, res) {
134
+ const isPrivate = req.url.endsWith('/private-agent-guide')
135
+ res.writeHead(200, { 'content-type': 'application/json' })
136
+ res.end(JSON.stringify({ body: isPrivate ? '' : 'Shared instructions.', revision: isPrivate ? 0 : 1, updatedAt: null, updatedBy: null }))
137
+ },
138
+ })
139
+ const logs = []
140
+ const originalLog = console.log
141
+ console.log = (...args) => logs.push(args.join(' '))
142
+ try {
143
+ await runCommand(['context'], h.project)
144
+ } finally { console.log = originalLog }
145
+
146
+ assert.ok(logs.includes('Read agent.md before working on the abstract.'))
147
+ assert.equal(logs.some((line) => line.includes('private-agent.md before')), false)
148
+ })
149
+
110
150
  test('context refuses both guides above the combined cap before writing either', async (t) => {
111
151
  const root = mkdtempSync(join(tmpdir(), 'am-guide-cap-'))
112
152
  const project = join(root, 'project')
package/lib/commands.mjs CHANGED
@@ -227,19 +227,25 @@ function quarantineActiveGuides(cwd, fallbackAbstractId) {
227
227
 
228
228
  function enforceActiveGuideCaps(cwd, abstractId, userId) {
229
229
  const paths = guidePaths(cwd, abstractId, userId)
230
- const files = [paths.abstractGuide, paths.privateGuide]
230
+ const files = [
231
+ { name: 'agent.md', path: paths.abstractGuide },
232
+ { name: 'private-agent.md', path: paths.privateGuide },
233
+ ]
231
234
  let combinedBytes = 0
232
- for (const path of files) {
235
+ const inspected = []
236
+ for (const { path } of files) {
233
237
  const info = lstatSync(path)
234
238
  if (info.isSymbolicLink() || !info.isFile()) throw new Error('Active guide files must be regular non-symlink files.')
235
239
  if (info.size > GUIDE_MAX_BYTES) {
236
240
  throw new Error('An active guide exceeds the 32 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
237
241
  }
238
242
  combinedBytes += info.size
243
+ inspected.push(info.size)
239
244
  }
240
245
  if (combinedBytes > COMBINED_GUIDE_MAX_BYTES) {
241
246
  throw new Error('Active guide files exceed the combined 48 KiB UTF-8 context limit. Neither guide was loaded; local text is preserved without truncation.')
242
247
  }
248
+ return files.filter((_, index) => inspected[index] > 0).map(({ name }) => name)
243
249
  }
244
250
 
245
251
  async function cmdContext(cwd, rest) {
@@ -277,7 +283,7 @@ async function cmdContext(cwd, rest) {
277
283
  }
278
284
  outcomes.push(outcome)
279
285
  }
280
- enforceActiveGuideCaps(cwd, id, userId)
286
+ const nonemptyGuides = enforceActiveGuideCaps(cwd, id, userId)
281
287
  const conflicts = outcomes.filter((outcome) => outcome.status === 'conflict')
282
288
  if (conflicts.length) {
283
289
  const paths = conflicts.map((item) => item.conflictPath).join(', ')
@@ -285,8 +291,14 @@ async function cmdContext(cwd, rest) {
285
291
  }
286
292
  console.log(`Loaded guide context for ${id} (abstract revision ${abstractGuide.revision}; private revision ${privateGuide.revision}).`)
287
293
  console.log('Context does not fetch source; source release/version is separate from these guide revisions.')
288
- console.log('The private guide is supplied to this local agent process and cannot be recalled after download.')
289
- console.log('Read agent.md, then private-agent.md before working on the abstract.')
294
+ if (nonemptyGuides.includes('private-agent.md')) {
295
+ console.log('The private guide is supplied to this local agent process and cannot be recalled after download.')
296
+ }
297
+ if (nonemptyGuides.length) {
298
+ console.log(`Read ${nonemptyGuides.join(' and ')} before working on the abstract.`)
299
+ } else {
300
+ console.log('Neither guide contains text; continue with the abstract task.')
301
+ }
290
302
  } catch (error) {
291
303
  if (!authorizationComplete) {
292
304
  try { quarantineActiveGuides(cwd, id) } catch (quarantineError) {
@@ -988,14 +1000,33 @@ async function cmdFolderMove(rest) {
988
1000
  console.log('Moved folder.')
989
1001
  }
990
1002
 
1003
+ async function cmdFolderRemove(rest) {
1004
+ const { flags, positional } = parseFlags(rest)
1005
+ const token = flags.id || positional[0]
1006
+ if (!token) throw new Error('Pass a folder name or id.')
1007
+ const folders = await loadFolders()
1008
+ const want = String(token)
1009
+ const folder = folders.find((candidate) => candidate.id === want || candidate.path === want)
1010
+ const nameMatches = folders.filter((candidate) => candidate.name.toLowerCase() === want.toLowerCase())
1011
+ if (!folder && nameMatches.length > 1) {
1012
+ throw new Error(`More than one folder is named “${token}”. Use its full path or id.`)
1013
+ }
1014
+ const target = folder || nameMatches[0]
1015
+ if (!target) throw new Error(`No folder named “${token}”`)
1016
+ await apiFetch(`/api/organise/collections/${target.id}`, { method: 'DELETE' })
1017
+ if (jsonMode(flags)) printJson({ id: target.id, name: target.name, removed: true })
1018
+ else console.log('Folder removed.')
1019
+ }
1020
+
991
1021
  async function cmdFolder(rest) {
992
1022
  const { flags } = parseFlags(rest)
993
- if (flags.help || !rest[0]) return printHelp('folder')
1023
+ if (flags.help || !rest[0]) return printHelp(rest[0] === 'list' ? 'folder-list' : 'folder')
994
1024
  const [sub, ...tail] = rest
995
1025
  if (sub === 'new') return cmdFolderNew(tail)
996
1026
  if (sub === 'list') return cmdFolderList(tail)
997
1027
  if (sub === 'move') return cmdFolderMove(tail)
998
- throw new Error('folder needs new, list, or move.')
1028
+ if (sub === 'remove') return cmdFolderRemove(tail)
1029
+ throw new Error('folder needs new, list, move, or remove.')
999
1030
  }
1000
1031
 
1001
1032
  export async function runCommand(argv, cwd = process.cwd()) {
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createServer } from 'node:http'
3
+ import { mkdtempSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import test from 'node:test'
7
+ import { runCommand } from './commands.mjs'
8
+ import { saveSession } from './session.mjs'
9
+
10
+ const folderId = '00000000-0000-4000-8000-000000000003'
11
+ const userId = '10000000-0000-4000-8000-000000000001'
12
+ const payload = (value) => Buffer.from(JSON.stringify(value)).toString('base64url')
13
+ const accessToken = `header.${payload({ sub: userId, exp: Math.floor(Date.now() / 1000) + 3600 })}.signature`
14
+
15
+ async function folderHarness(t, deleteStatus = 200, collections) {
16
+ const root = mkdtempSync(join(tmpdir(), 'am-folder-command-'))
17
+ const oldCredentials = process.env.AMORPHOUS_CREDENTIALS
18
+ const oldAppUrl = process.env.AMORPHOUS_CLI_APP_URL
19
+ const oldAppOrigin = process.env.APP_ORIGIN
20
+ process.env.AMORPHOUS_CREDENTIALS = join(root, 'credentials.json')
21
+ process.env.APP_ORIGIN = 'https://amorphous.test'
22
+ saveSession({
23
+ access_token: accessToken,
24
+ refresh_token: 'unused',
25
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
26
+ })
27
+
28
+ const calls = []
29
+ const server = createServer((req, res) => {
30
+ calls.push({ method: req.method, url: req.url, authorization: req.headers.authorization })
31
+ res.setHeader('content-type', 'application/json')
32
+ if (req.method === 'GET' && req.url === '/api/organise/meta') {
33
+ res.end(JSON.stringify({
34
+ all_collections: collections || [{
35
+ id: folderId,
36
+ name: 'QA smoke 2026-10-21',
37
+ path: 'QA smoke 2026-10-21',
38
+ parent_id: null,
39
+ abstract_count: 0,
40
+ }],
41
+ }))
42
+ return
43
+ }
44
+ if (req.method === 'DELETE' && req.url === `/api/organise/collections/${folderId}`) {
45
+ res.statusCode = deleteStatus
46
+ res.end(JSON.stringify(deleteStatus === 200 ? { ok: true } : {
47
+ statusCode: 409,
48
+ statusMessage: 'Move the folder contents out before removing it',
49
+ message: 'Move the folder contents out before removing it',
50
+ }))
51
+ return
52
+ }
53
+ res.statusCode = 404
54
+ res.end(JSON.stringify({ message: 'Not found' }))
55
+ })
56
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
57
+ process.env.AMORPHOUS_CLI_APP_URL = `http://127.0.0.1:${server.address().port}`
58
+
59
+ t.after(async () => {
60
+ server.closeAllConnections?.()
61
+ await new Promise((resolve) => server.close(resolve))
62
+ rmSync(root, { recursive: true, force: true })
63
+ if (oldCredentials === undefined) delete process.env.AMORPHOUS_CREDENTIALS
64
+ else process.env.AMORPHOUS_CREDENTIALS = oldCredentials
65
+ if (oldAppUrl === undefined) delete process.env.AMORPHOUS_CLI_APP_URL
66
+ else process.env.AMORPHOUS_CLI_APP_URL = oldAppUrl
67
+ if (oldAppOrigin === undefined) delete process.env.APP_ORIGIN
68
+ else process.env.APP_ORIGIN = oldAppOrigin
69
+ })
70
+
71
+ return calls
72
+ }
73
+
74
+ test('folder remove resolves a folder name and deletes the empty folder', async (t) => {
75
+ const calls = await folderHarness(t)
76
+ let output = ''
77
+ const originalLog = console.log
78
+ console.log = (message) => { output = String(message) }
79
+ try {
80
+ await runCommand(['folder', 'remove', 'QA smoke 2026-10-21'])
81
+ } finally {
82
+ console.log = originalLog
83
+ }
84
+
85
+ assert.deepEqual(calls.map(({ method, url }) => [method, url]), [
86
+ ['GET', '/api/organise/meta'],
87
+ ['DELETE', `/api/organise/collections/${folderId}`],
88
+ ])
89
+ assert.ok(calls.every((call) => call.authorization === `Bearer ${accessToken}`))
90
+ assert.equal(output, 'Folder removed.')
91
+ })
92
+
93
+ test('folder remove requires an id or path when folder names collide', async (t) => {
94
+ const calls = await folderHarness(t, 200, [
95
+ {
96
+ id: folderId,
97
+ name: 'QA smoke',
98
+ path: 'Production / QA smoke',
99
+ parent_id: '00000000-0000-4000-8000-000000000005',
100
+ abstract_count: 0,
101
+ },
102
+ {
103
+ id: '00000000-0000-4000-8000-000000000004',
104
+ name: 'QA smoke',
105
+ path: 'Launch / QA smoke',
106
+ parent_id: '00000000-0000-4000-8000-000000000005',
107
+ abstract_count: 0,
108
+ },
109
+ ])
110
+
111
+ await assert.rejects(
112
+ runCommand(['folder', 'remove', 'QA smoke']),
113
+ /More than one folder is named.*full path or id/,
114
+ )
115
+ assert.deepEqual(calls.map(({ method, url }) => [method, url]), [
116
+ ['GET', '/api/organise/meta'],
117
+ ])
118
+ })
119
+
120
+ test('folder remove reports that a non-empty folder must be cleared first', async (t) => {
121
+ const calls = await folderHarness(t, 409)
122
+ await assert.rejects(
123
+ runCommand(['folder', 'remove', folderId]),
124
+ /Move the folder contents out before removing it/,
125
+ )
126
+ assert.deepEqual(calls.map(({ method, url }) => [method, url]), [
127
+ ['GET', '/api/organise/meta'],
128
+ ['DELETE', `/api/organise/collections/${folderId}`],
129
+ ])
130
+ })
131
+
132
+ test('folder remove returns machine-readable confirmation in JSON mode', async (t) => {
133
+ await folderHarness(t)
134
+ let output = ''
135
+ const originalLog = console.log
136
+ console.log = (message) => { output = String(message) }
137
+ try {
138
+ await runCommand(['folder', 'remove', folderId, '--json'])
139
+ } finally {
140
+ console.log = originalLog
141
+ }
142
+
143
+ assert.deepEqual(JSON.parse(output), {
144
+ id: folderId,
145
+ name: 'QA smoke 2026-10-21',
146
+ removed: true,
147
+ })
148
+ })
149
+
150
+ test('folder help describes the remove command and its empty-folder requirement', async () => {
151
+ let output = ''
152
+ const originalLog = console.log
153
+ console.log = (message) => { output = String(message) }
154
+ try {
155
+ await runCommand(['folder', '--help'])
156
+ } finally {
157
+ console.log = originalLog
158
+ }
159
+
160
+ assert.match(output, /folder remove <folder>/)
161
+ assert.match(output, /Only empty folders can be removed/i)
162
+ assert.match(output, /--json/)
163
+ })
164
+
165
+ test('folder list help stays focused on listing folders', async () => {
166
+ let output = ''
167
+ const originalLog = console.log
168
+ console.log = (message) => { output = String(message) }
169
+ try {
170
+ await runCommand(['folder', 'list', '--help'])
171
+ } finally {
172
+ console.log = originalLog
173
+ }
174
+
175
+ assert.match(output, /folder list \[--json\]/)
176
+ assert.match(output, /list your folders/i)
177
+ assert.doesNotMatch(output, /folder (new|move|remove)/)
178
+ })
package/lib/help.mjs CHANGED
@@ -15,7 +15,7 @@ export const ROOT_HELP = `Amorphous
15
15
  publish [--live] Publish a version; --live is Go live
16
16
  recommend [--note <text>] Send this draft to the owner for review
17
17
  live [abstract] Make a version live
18
- visibility anyone|members Anyone can view / Members only
18
+ visibility anyone|members Anyone can view; app may accept shared-record writes / Members only
19
19
  invite <email> Email an invite and print the link
20
20
  notify Email members about the published version
21
21
  remove <email|id> Remove
@@ -28,6 +28,7 @@ export const ROOT_HELP = `Amorphous
28
28
  folder new New folder
29
29
  folder list Folders
30
30
  folder move Move folder
31
+ folder remove Remove empty folder
31
32
  rename --name Rename
32
33
 
33
34
  Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
@@ -105,7 +106,9 @@ Make this the live version. Uses the latest published version if you omit --vers
105
106
 
106
107
  visibility: `visibility <anyone|members> [abstract] [--version <id>]
107
108
 
108
- Anyone can view, or Members only. Uses the live version if you omit --version.`,
109
+ Anyone: anyone with the link can view. If the app writes shared records, anyone with the link can add them; use this only when public submissions are intended.
110
+ Members: only invited people can view; shared-record writes follow their access.
111
+ Uses the live version if you omit --version.`,
109
112
 
110
113
  invite: `invite <email> [view|use|edit] [--abstract <id|name>] [--json]
111
114
 
@@ -155,12 +158,18 @@ Unstar.`,
155
158
 
156
159
  Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
157
160
 
158
- folder: `folder <new|list|move> …
161
+ folder: `folder <new|list|move|remove> …
159
162
 
160
163
  folder new --name <name> [--parent <id>]
161
164
  folder list [--json]
162
165
  folder move <folder> --parent <id|none>
163
- New folder, or move a folder. Folders organize your library and do not grant access.`,
166
+ folder remove <folder> [--json]
167
+ New folder, or move a folder. Folders organize your library and do not grant access.
168
+ Only empty folders can be removed; move their contents out first.`,
169
+
170
+ 'folder-list': `folder list [--json]
171
+
172
+ List your folders.`,
164
173
 
165
174
  rename: `rename [abstract] --name <name>
166
175
 
package/lib/help.test.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { printHelp } from './help.mjs'
3
+ import { printHelp, ROOT_HELP } from './help.mjs'
4
4
 
5
5
  test('serve help explains that prod mode can write live records', () => {
6
6
  let output = ''
@@ -33,3 +33,24 @@ test('pull help makes the fresh-folder abstract target explicit', () => {
33
33
  assert.match(output, /before context/i)
34
34
  assert.match(output, /owner or editor/i)
35
35
  })
36
+
37
+ test('visibility help explains public shared-record writes', () => {
38
+ let output = ''
39
+ const originalLog = console.log
40
+ console.log = (message) => { output = String(message) }
41
+ try {
42
+ printHelp('visibility')
43
+ } finally {
44
+ console.log = originalLog
45
+ }
46
+
47
+ assert.match(output, /if the app writes shared records/i)
48
+ assert.match(output, /anyone with the link can add them/i)
49
+ assert.match(output, /members: only invited people can view/i)
50
+ assert.match(output, /shared-record writes follow their access/i)
51
+ assert.match(output, /live version/i)
52
+ })
53
+
54
+ test('root help does not hide the write effect of public visibility', () => {
55
+ assert.match(ROOT_HELP, /anyone can view; app may accept shared-record writes/i)
56
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amorphous-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",
@@ -50,6 +50,8 @@ Then choose the path that matches the request:
50
50
  - **Access:** View members can open the live abstract and load its guides, but cannot pull, recommend, or change source. Use members can use the live abstract and submit source recommendations for the owner to review when source files are already available to them. Only owners and editors can pull and edit source or publish a version; only the owner can invite/remove members, change visibility, or make a version live. Follow the access already granted; ask the owner for a role change if the requested work needs more access.
51
51
  - **New abstract:** Run `amorphous-cli create --name "..." [--template blank|list|board|landing]`. The list, board, and landing starters are working examples, not just mockups.
52
52
 
53
+ After `context` succeeds, follow its final guide-reading instruction. It names only guide files that contain text; if neither guide contains text, continue without opening empty guide files. The private-guide handling warning appears only when that guide contains text.
54
+
53
55
  Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here. Prefer `amorphous-cli serve` while you edit. Open that URL and leave it running; it reloads when the files change, making it the easiest way to check the work. Records start local in this folder. Use prod mode only when the person explicitly asks you to inspect or change live records: `find` reads live data, while `insert` and `replace` change it immediately. Do not write live records during UI work or read-only checks. You can push and preview without prod mode.
54
56
 
55
57
  ## Loop
@@ -64,6 +66,8 @@ When they ask:
64
66
  - `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.
65
67
  - `amorphous-cli notify` emails members that the published version changed. It does not publish. Do that only when they ask.
66
68
 
69
+ Use `amorphous-cli folder list` to inspect library folders, `amorphous-cli folder new --name "<name>"` to create one, and `amorphous-cli folder move <folder> --parent <id|none>` to reorganize them. Remove an empty folder with `amorphous-cli folder remove <folder>`; move its abstracts and subfolders out first.
70
+
67
71
  `amorphous-cli visibility anyone` lets anyone view the abstract and write shared records through it. Those writes persist on the abstract. Make it public only when the person explicitly asks for public access and understands that anyone can add shared records. `amorphous-cli visibility members` keeps the abstract to invited people.
68
72
 
69
73
  Do not make a public abstract that lets anyone keep adding shared records, such as an open list. Those records stay on the abstract and use up its storage. Keep shared records to a set you replace, or keep the abstract to invited people.