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