heyquilt 0.0.1 → 0.3.106
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/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/inbox.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// What is waiting for a member of a session: mentions of them in chat, direct
|
|
2
|
+
// messages to them, and tasks handed to them. Agents wake on these: Claude Code
|
|
3
|
+
// gets them pushed as channel events by `quilt mcp`, every tool can read them
|
|
4
|
+
// with quilt_inbox, and the Claude Code hooks show them while Claude works.
|
|
5
|
+
//
|
|
6
|
+
// `scanInbox` is pure (state in, state out) so the local daemon, the hosted
|
|
7
|
+
// relay MCP and tests all find the same events the same way.
|
|
8
|
+
import { assignedToReader } from './tasks.js'
|
|
9
|
+
|
|
10
|
+
export const INBOX_CAP = 100
|
|
11
|
+
|
|
12
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which of `names` are mentioned as @Name in `text`. Case-insensitive; the @
|
|
16
|
+
* must start a word (so an email address is not a mention) and the name must
|
|
17
|
+
* end one (so @Dan is not a mention of Dan inside @Daniel). @Daniel's AI is not a mention of Daniel.
|
|
18
|
+
*/
|
|
19
|
+
export function mentioned (text, names) {
|
|
20
|
+
const t = String(text || '')
|
|
21
|
+
const out = []
|
|
22
|
+
for (const name of names || []) {
|
|
23
|
+
const n = String(name || '').trim()
|
|
24
|
+
if (!n) continue
|
|
25
|
+
// "@Daniel's AI" is for Daniel's AI, not for Daniel.
|
|
26
|
+
const re = new RegExp(`(^|[^\\w@])@${escapeRe(n)}(?![\\w-])(?!'s AI(?![\\w-]))`, 'iu')
|
|
27
|
+
if (re.test(t)) out.push(n)
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Typing @Agents in chat mentions every agent in the session at once. */
|
|
33
|
+
export const ALL_AGENTS = 'Agents'
|
|
34
|
+
|
|
35
|
+
/** Whether `text` @mentions anyone at all (an email address is not a mention). */
|
|
36
|
+
export const mentionsAnyone = (text) => /(^|[^\w@])@[\p{L}\p{N}]/u.test(String(text || ''))
|
|
37
|
+
|
|
38
|
+
// A room message that names nobody, this soon after its sender @mentioned an agent, carries
|
|
39
|
+
// on with that agent ("can you retry that?"): it wakes the agent like the mention did.
|
|
40
|
+
export const FOLLOW_UP_MS = 10 * 60 * 1000
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether `text` mentions `me`: @me, or @Agents when `agent` (the reader joined as an agent).
|
|
44
|
+
* A member actually named "Agents" is still mentioned by name, like anyone else.
|
|
45
|
+
*/
|
|
46
|
+
export function mentionsMe (text, me, { agent = false } = {}) {
|
|
47
|
+
return mentioned(text, agent ? [me, ALL_AGENTS] : [me]).length > 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Events for `reader` that `state` has not seen. Pure: returns { events, state }.
|
|
52
|
+
* - messages: chat the reader can see, oldest first ({ id, by, to, text, ts }).
|
|
53
|
+
* - tasks: the board ({ id, title, column, by, assignee, forAi, tool, files }).
|
|
54
|
+
* - reader: { name, asAi, agent, aliases, of }: agent means the reader joined as an
|
|
55
|
+
* agent, so @Agents mentions it too. asAi means the reader is that person's AI, so
|
|
56
|
+
* tasks for "their AI" are the reader's and tasks for the person are not. One of
|
|
57
|
+
* several AI sessions working through a person's app (persona.js) has its own name,
|
|
58
|
+
* the names it had before (`aliases`), and `of`, its person: tasks for "their AI" are its.
|
|
59
|
+
* `own`: names of the reader's own AI sessions. What they say never wakes the reader: a
|
|
60
|
+
* session writing to its own person is talking to the person, not to their other sessions.
|
|
61
|
+
* The first scan (no state) only takes stock: nothing that is already there
|
|
62
|
+
* wakes anyone. A task fires once when it becomes the reader's while open;
|
|
63
|
+
* handing it away and back fires again.
|
|
64
|
+
* A direct message carries `re`, the reader's message it answers, when it has one. For an agent
|
|
65
|
+
* reader, a room message naming nobody within FOLLOW_UP_MS of its sender's last @mention of the
|
|
66
|
+
* reader is a mention too (`follows`: that message's id).
|
|
67
|
+
*/
|
|
68
|
+
export function scanInbox ({ messages = [], tasks = [], reader, now = Date.now() }, state = null) {
|
|
69
|
+
const me = reader && reader.name
|
|
70
|
+
const names = me ? [me, ...((reader && reader.aliases) || [])] : []
|
|
71
|
+
const own = new Set((reader && reader.own) || [])
|
|
72
|
+
const seed = !state
|
|
73
|
+
const seenMsgs = new Set(state?.messages || [])
|
|
74
|
+
const assigned = new Set(state?.assigned || [])
|
|
75
|
+
const events = []
|
|
76
|
+
const msgIds = []
|
|
77
|
+
const lastAsk = new Map() // sender -> their latest room message that @mentions someone: when, and was it for me
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || typeof m.id !== 'string' || !m.id) continue
|
|
80
|
+
msgIds.push(m.id)
|
|
81
|
+
const text = typeof m.text === 'string' ? m.text : ''
|
|
82
|
+
let follows = ''
|
|
83
|
+
if (!m.to && !m.kind && reader && reader.agent && me) {
|
|
84
|
+
if (mentionsAnyone(text)) lastAsk.set(m.by, { id: m.id, ts: m.ts || 0, mine: names.some((n) => mentionsMe(text, n, { agent: true })) })
|
|
85
|
+
else {
|
|
86
|
+
const ask = lastAsk.get(m.by)
|
|
87
|
+
if (ask && ask.mine && (m.ts || 0) - ask.ts <= FOLLOW_UP_MS) follows = ask.id
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (seed || seenMsgs.has(m.id) || !me || names.includes(m.by) || own.has(m.by)) continue
|
|
91
|
+
// File queue messages (a request for a file the reader holds, or a file handed to them) wake
|
|
92
|
+
// them like a direct message, but ask for a handoff rather than a reply (duties.js).
|
|
93
|
+
const queue = m.kind === 'queue' || m.kind === 'handoff' ? { queue: m.kind, file: typeof m.path === 'string' ? m.path : '' } : {}
|
|
94
|
+
// A note that work the reader asked to commit was committed (commit.js): it wakes them, and asks for no reply.
|
|
95
|
+
if (m.kind === 'commit') queue.commit = typeof m.commit === 'string' ? m.commit : true
|
|
96
|
+
// A reminder from Quilt (uncommitted work): it wakes the reader, and asks for no reply.
|
|
97
|
+
if (m.kind === 'nudge') queue.nudge = true
|
|
98
|
+
const re = typeof m.re === 'string' && m.re ? { re: m.re } : {}
|
|
99
|
+
if (names.includes(m.to)) events.push({ id: m.id, kind: 'dm', by: m.by, text, ts: m.ts, ...re, ...queue })
|
|
100
|
+
else if (!m.to && names.some((n) => mentionsMe(text, n, { agent: !!reader.agent }))) events.push({ id: m.id, kind: 'mention', by: m.by, text, ts: m.ts })
|
|
101
|
+
else if (follows) events.push({ id: m.id, kind: 'mention', by: m.by, text, ts: m.ts, follows })
|
|
102
|
+
}
|
|
103
|
+
const mine = []
|
|
104
|
+
for (const t of tasks) {
|
|
105
|
+
// An AI session's tasks: given to it by name (as a member), or to its person's AI.
|
|
106
|
+
const its = assignedToReader(t, reader) || names.slice(1).some((n) => assignedToReader(t, { ...reader, name: n })) ||
|
|
107
|
+
(reader && reader.of && (t.assignee === me || assignedToReader(t, { name: reader.of, asAi: true })))
|
|
108
|
+
if (!t || typeof t.id !== 'string' || !its) continue
|
|
109
|
+
mine.push(t.id)
|
|
110
|
+
if (seed || assigned.has(t.id) || t.column === 'done' || t.archived) continue
|
|
111
|
+
events.push({
|
|
112
|
+
id: t.id,
|
|
113
|
+
kind: 'task',
|
|
114
|
+
by: t.by,
|
|
115
|
+
text: t.title,
|
|
116
|
+
ts: now,
|
|
117
|
+
task: { id: t.id, title: t.title, column: t.column, assignee: t.assignee, forAi: !!t.forAi, tool: t.tool || '', files: t.files || [] }
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
// A task that is done, archived, or no longer the reader's, can wake them again later.
|
|
121
|
+
const open = new Set(tasks.filter((t) => t && t.column !== 'done' && !t.archived).map((t) => t.id))
|
|
122
|
+
return { events, state: { messages: msgIds.slice(-500), assigned: mine.filter((id) => open.has(id)) } }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// How much of each message a wake's context shows (conversation.js picks the messages).
|
|
126
|
+
export const CONTEXT_MESSAGE_CHARS = 600
|
|
127
|
+
|
|
128
|
+
const lowerNames = (names) => new Set((names || []).filter(Boolean).map((n) => String(n).toLowerCase()))
|
|
129
|
+
const clip = (text, max) => {
|
|
130
|
+
const t = String(text || '').trim()
|
|
131
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ago = (ts, now) => {
|
|
135
|
+
const m = Math.max(0, Math.round((now - (ts || now)) / 60000))
|
|
136
|
+
if (m < 1) return 'just now'
|
|
137
|
+
if (m < 60) return `${m}m ago`
|
|
138
|
+
const h = Math.round(m / 60)
|
|
139
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One message as a line: "[id] 3h ago Daniel → you: text". `me`: the reader's names, shown as "you". */
|
|
143
|
+
export function messageLine (m, { me = [], now = Date.now(), max = 2000 } = {}) {
|
|
144
|
+
const mine = lowerNames(me)
|
|
145
|
+
const who = (n) => mine.has(String(n || '').toLowerCase()) ? 'you' : n
|
|
146
|
+
const to = m.to ? ` → ${who(m.to)}` : ''
|
|
147
|
+
return `- [${m.id}] ${ago(m.ts, now)} ${who(m.by)}${to}: ${clip(m.text, max).replace(/\n+/g, ' ⏎ ')}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The context an event carries, as lines under it; '' when there is none. */
|
|
151
|
+
export function renderContext (e, { me = [], now = Date.now() } = {}) {
|
|
152
|
+
if (!e || !Array.isArray(e.context) || !e.context.length) return ''
|
|
153
|
+
const head = `Earlier between you and ${e.by} (oldest first${e.earlier ? `; ${e.earlier} older: quilt_conversation with "${e.by}"` : ''}):`
|
|
154
|
+
return [head, ...e.context.map((m) => ' ' + messageLine(m, { me, now, max: CONTEXT_MESSAGE_CHARS }))].join('\n')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A message's id, which quilt_inbox no_reply takes to settle it.
|
|
158
|
+
const idOf = (e) => e.id ? ` (id ${e.id})` : ''
|
|
159
|
+
|
|
160
|
+
/** One event as a line an agent can act on. */
|
|
161
|
+
export function describeEvent (e) {
|
|
162
|
+
const who = e.by || 'someone'
|
|
163
|
+
if (e.queue === 'queue') return `${who} asked for ${e.file} in its file queue: ${e.text} Finish what you are doing in it, then hand it off with quilt_handoff and your context.`
|
|
164
|
+
if (e.queue === 'handoff') return `${who} handed you ${e.file}, which you asked for: it is yours to edit now. ${e.text}`
|
|
165
|
+
if (e.nudge) return `Quilt reminds you (no reply needed): ${e.text}`
|
|
166
|
+
if (typeof e.commit === 'string') return `${who} asked you for a commit (request ${e.commit}; it waits here until the request is done, no reply needed): ${e.text}`
|
|
167
|
+
if (e.commit) return `${who} committed work you asked for (no reply needed): ${e.text}`
|
|
168
|
+
if (e.kind === 'dm') return `${who} sent you a direct message${e.id ? ` (id ${e.id}${e.re ? `, answering your message ${e.re}` : ''})` : ''}: ${e.text}`
|
|
169
|
+
if (e.kind === 'mention') return `${who} ${e.follows ? 'wrote again in chat after mentioning you' : 'mentioned you in chat'}${idOf(e)}: ${e.text}`
|
|
170
|
+
if (e.kind === 'task') {
|
|
171
|
+
const t = e.task || {}
|
|
172
|
+
const files = t.files && t.files.length ? ` Files: ${t.files.join(', ')}.` : ''
|
|
173
|
+
return `${who} handed you a task: "${e.text}" (id ${e.id}).${files} Pick it up with quilt_move_task (doing) when you start.`
|
|
174
|
+
}
|
|
175
|
+
return `${who}: ${e.text || ''}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// What "that" means: the rule every wake carries, so no agent asks a person to repeat themselves.
|
|
179
|
+
export const REFERS_HOW = '"That", "it", "again" and "retry" in a message mean the latest thing in the conversation shown under it: act on that. ' +
|
|
180
|
+
'Never ask what they meant, and never say you do not know what they refer to before reading back with quilt_conversation.'
|
|
181
|
+
|
|
182
|
+
export const INBOX_HOW = 'Answer a message that asks something of you with quilt_message (re: its id, or set "to", or start with @their name), once. ' +
|
|
183
|
+
'One that needs nothing back (thanks, a greeting, an FYI, a status report) gets no reply: settle it with quilt_inbox (no_reply: [its id]). Take a task with quilt_move_task. ' + REFERS_HOW
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Several events as the text a tool returns, or '' when there are none. Each sender's
|
|
187
|
+
* conversation before their first event here is shown once, under it (`me`: the reader's names).
|
|
188
|
+
*/
|
|
189
|
+
export function renderInbox (events, opts = {}) {
|
|
190
|
+
if (!events || !events.length) return ''
|
|
191
|
+
return `Waiting for you:\n${eventLines(events, opts).join('\n')}\n${INBOX_HOW}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Each event as a line an agent can act on, with its sender's conversation under their first one. */
|
|
195
|
+
export function eventLines (events, { me = [], now = Date.now() } = {}) {
|
|
196
|
+
const ids = new Set(events.map((e) => e.id))
|
|
197
|
+
const shown = new Set()
|
|
198
|
+
const lines = []
|
|
199
|
+
for (const e of events) {
|
|
200
|
+
lines.push(`- ${describeEvent(e)}`)
|
|
201
|
+
if (shown.has(e.by) || !Array.isArray(e.context)) continue
|
|
202
|
+
shown.add(e.by)
|
|
203
|
+
const ctx = renderContext({ ...e, context: e.context.filter((m) => !ids.has(m.id)) }, { me, now })
|
|
204
|
+
if (ctx) lines.push(ctx.replace(/^/gm, ' '))
|
|
205
|
+
}
|
|
206
|
+
return lines
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Keeps the events a member has not been shown yet, each with a sequence number. */
|
|
210
|
+
export class Inbox {
|
|
211
|
+
constructor () {
|
|
212
|
+
this.state = null
|
|
213
|
+
this.events = []
|
|
214
|
+
this.seq = 0
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Scans for new events. `quiet` takes stock without waking anyone (our own changes). */
|
|
218
|
+
scan (input, { quiet = false } = {}) {
|
|
219
|
+
const r = scanInbox(input, this.state)
|
|
220
|
+
this.state = r.state
|
|
221
|
+
if (quiet) return []
|
|
222
|
+
for (const e of r.events) this.events.push({ ...e, seq: ++this.seq })
|
|
223
|
+
if (this.events.length > INBOX_CAP) this.events.splice(0, this.events.length - INBOX_CAP)
|
|
224
|
+
return r.events
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Forgets that task `id` was already the reader's, so the next scan wakes them for it (a task they took quietly). */
|
|
228
|
+
forget (id) {
|
|
229
|
+
if (this.state && Array.isArray(this.state.assigned)) this.state = { ...this.state, assigned: this.state.assigned.filter((x) => x !== id) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Events after sequence number `after`, and the latest number to pass next time. */
|
|
233
|
+
since (after = 0) {
|
|
234
|
+
const from = Number(after) || 0
|
|
235
|
+
return { events: this.events.filter((e) => e.seq > from), seq: this.seq }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Wires Quilt into every AI tool on this computer, with nothing for the person to do.
|
|
2
|
+
// Tools that run hooks from their user settings (Gemini CLI) get Quilt's hooks there too, so
|
|
3
|
+
// its rules are applied by the tool itself (see hooks.js); Claude Code and Cursor get theirs
|
|
4
|
+
// from each session folder's .claude/settings.local.json (setup.js installHooks).
|
|
5
|
+
//
|
|
6
|
+
// Each tool that reads MCP servers from a user-level config gets Quilt's server there,
|
|
7
|
+
// pointed at this install by absolute path: GUI tools started from the Dock or Start menu
|
|
8
|
+
// don't see the shell's PATH, and project files can't hold one computer's paths (they
|
|
9
|
+
// sync to everyone). A tool is only touched when it looks installed (its config folder
|
|
10
|
+
// exists), only Quilt's own entry is written, and a file Quilt can't read safely (JSON
|
|
11
|
+
// with comments, say) is left alone and reported. Run whenever the app or a session
|
|
12
|
+
// starts: it's idempotent, and it follows the app when it moves or updates.
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { geminiHookSettings, isQuiltHook } from './hooks.js'
|
|
18
|
+
|
|
19
|
+
const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'quilt.js')
|
|
20
|
+
|
|
21
|
+
/** How to run this Quilt: { command, args, env }. The desktop app's Electron runs it as Node. */
|
|
22
|
+
export function quiltLaunch ({ execPath = process.execPath, electron = !!process.versions.electron, bin = BIN } = {}) {
|
|
23
|
+
return { command: execPath, args: [bin], env: electron ? { ELECTRON_RUN_AS_NODE: '1' } : {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same, as one shell command line (for Claude Code's hooks), with `extra` arguments. */
|
|
27
|
+
export function quiltShellCommand (extra = [], launch = quiltLaunch(), platform = process.platform) {
|
|
28
|
+
const q = (s) => `"${platform === 'win32' ? String(s).replace(/\\/g, '/') : String(s)}"`
|
|
29
|
+
const env = Object.entries(launch.env).map(([k, v]) => `${k}=${v} `).join('')
|
|
30
|
+
return `${env}${[launch.command, ...launch.args].map(q).join(' ')}${extra.length ? ' ' + extra.join(' ') : ''}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The MCP server entry most tools take: { command, args, env }. */
|
|
34
|
+
export function mcpServer (launch = quiltLaunch()) {
|
|
35
|
+
return { command: launch.command, args: [...launch.args, 'mcp'], ...(Object.keys(launch.env).length ? { env: launch.env } : {}) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------- files --
|
|
39
|
+
|
|
40
|
+
/** Reads JSON, tolerating none. `null` means "there, but not plain JSON": not ours to rewrite. */
|
|
41
|
+
function readJson (file) {
|
|
42
|
+
let text
|
|
43
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { return { json: {}, text: '' } }
|
|
44
|
+
if (!text.trim()) return { json: {}, text }
|
|
45
|
+
try {
|
|
46
|
+
const json = JSON.parse(text)
|
|
47
|
+
return json && typeof json === 'object' && !Array.isArray(json) ? { json, text } : { json: null, text }
|
|
48
|
+
} catch { return { json: null, text } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeAtomic (file, text) {
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
53
|
+
const tmp = `${file}.quilt-${process.pid}.tmp`
|
|
54
|
+
fs.writeFileSync(tmp, text)
|
|
55
|
+
fs.renameSync(tmp, file)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
59
|
+
|
|
60
|
+
/** Sets json[key...] = value in a JSON file. Returns 'added' | 'updated' | 'unchanged' | 'skipped'. */
|
|
61
|
+
export function upsertJsonKey (file, keys, value) {
|
|
62
|
+
const { json, text } = readJson(file)
|
|
63
|
+
if (json === null) return 'skipped'
|
|
64
|
+
let at = json
|
|
65
|
+
for (const k of keys.slice(0, -1)) {
|
|
66
|
+
if (!at[k] || typeof at[k] !== 'object' || Array.isArray(at[k])) at[k] = {}
|
|
67
|
+
at = at[k]
|
|
68
|
+
}
|
|
69
|
+
const last = keys[keys.length - 1]
|
|
70
|
+
if (same(at[last], value)) return 'unchanged'
|
|
71
|
+
const was = at[last] !== undefined
|
|
72
|
+
at[last] = value
|
|
73
|
+
// Keep the file's indentation when we can tell it.
|
|
74
|
+
const indent = (text.match(/\n([ \t]+)"/) || [null, ' '])[1]
|
|
75
|
+
writeAtomic(file, JSON.stringify(json, null, indent) + '\n')
|
|
76
|
+
return was ? 'updated' : 'added'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Codex's config.toml: Quilt's [mcp_servers.quilt] table between markers, replaced in place.
|
|
81
|
+
* A quilt table someone wrote themselves is left alone (TOML refuses a table defined twice).
|
|
82
|
+
*/
|
|
83
|
+
export function upsertCodexToml (file, server) {
|
|
84
|
+
let text = ''
|
|
85
|
+
try { text = fs.readFileSync(file, 'utf8') } catch {}
|
|
86
|
+
const START = '# quilt:start (written by Quilt; edits here are replaced)'
|
|
87
|
+
const END = '# quilt:end'
|
|
88
|
+
const str = (s) => JSON.stringify(String(s)) // TOML basic strings take JSON escapes
|
|
89
|
+
const env = server.env && Object.keys(server.env).length ? `env = { ${Object.entries(server.env).map(([k, v]) => `${k} = ${str(v)}`).join(', ')} }\n` : ''
|
|
90
|
+
const block = `${START}\n[mcp_servers.quilt]\ncommand = ${str(server.command)}\nargs = [${server.args.map(str).join(', ')}]\n${env}${END}`
|
|
91
|
+
const re = /# quilt:start[^\n]*\n[\s\S]*?# quilt:end/
|
|
92
|
+
if (re.test(text)) {
|
|
93
|
+
const next = text.replace(re, block)
|
|
94
|
+
if (next === text) return 'unchanged'
|
|
95
|
+
writeAtomic(file, next)
|
|
96
|
+
return 'updated'
|
|
97
|
+
}
|
|
98
|
+
if (/^\s*\[mcp_servers\.(quilt|"quilt")\]/m.test(text)) return 'unchanged'
|
|
99
|
+
writeAtomic(file, (text ? text.replace(/\s*$/, '\n\n') : '') + block + '\n')
|
|
100
|
+
return 'added'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Zed's settings.json usually has comments. When it isn't plain JSON and has no
|
|
105
|
+
* context_servers yet, Quilt's entry goes in right after the opening brace, leaving every
|
|
106
|
+
* comment where it was.
|
|
107
|
+
*/
|
|
108
|
+
function upsertZed (file, entry) {
|
|
109
|
+
const r = upsertJsonKey(file, ['context_servers', 'quilt'], entry)
|
|
110
|
+
if (r !== 'skipped') return r
|
|
111
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
112
|
+
if (/"context_servers"/.test(text)) return 'skipped'
|
|
113
|
+
const at = text.indexOf('{')
|
|
114
|
+
if (at < 0) return 'skipped'
|
|
115
|
+
const body = JSON.stringify({ quilt: entry }, null, 2).replace(/\n/g, '\n ')
|
|
116
|
+
writeAtomic(file, `${text.slice(0, at + 1)}\n "context_servers": ${body},${text.slice(at + 1)}`)
|
|
117
|
+
return 'added'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Quilt's entries in a JSON settings file's `hooks` (Claude Code's shape, which Gemini CLI shares):
|
|
122
|
+
* earlier Quilt entries replaced, everyone else's left alone. Returns like upsertJsonKey.
|
|
123
|
+
*/
|
|
124
|
+
export function upsertHooks (file, ours) {
|
|
125
|
+
const { json } = readJson(file)
|
|
126
|
+
if (json === null) return 'skipped'
|
|
127
|
+
const hooks = json.hooks && typeof json.hooks === 'object' && !Array.isArray(json.hooks) ? { ...json.hooks } : {}
|
|
128
|
+
for (const [event, entries] of Object.entries(ours)) {
|
|
129
|
+
const kept = (Array.isArray(hooks[event]) ? hooks[event] : [])
|
|
130
|
+
.map((e) => (e && Array.isArray(e.hooks) ? { ...e, hooks: e.hooks.filter((h) => !isQuiltHook(h)) } : e))
|
|
131
|
+
.filter((e) => e && (!Array.isArray(e.hooks) || e.hooks.length))
|
|
132
|
+
hooks[event] = [...kept, ...entries]
|
|
133
|
+
}
|
|
134
|
+
return upsertJsonKey(file, ['hooks'], hooks)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Both of two writes: 'unchanged' only when both were, the first problem otherwise. */
|
|
138
|
+
const both = (a, b) => ['skipped', 'busy', 'failed'].find((r) => r === a || r === b) || (a === 'unchanged' && b === 'unchanged' ? 'unchanged' : a === 'added' ? 'added' : 'updated')
|
|
139
|
+
|
|
140
|
+
// ------------------------------------------------------------- tools --
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every AI tool Quilt knows how to wire up: where it keeps MCP servers, and in what shape.
|
|
144
|
+
* `home` and `platform` are injectable for tests.
|
|
145
|
+
*/
|
|
146
|
+
export function knownTools ({ home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
147
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
148
|
+
const appSupport = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : platform === 'win32' ? appData : (env.XDG_CONFIG_HOME || path.join(home, '.config'))
|
|
149
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config')
|
|
150
|
+
const json = (keys, shape = (s) => s) => (file, s) => upsertJsonKey(file, keys, shape(s))
|
|
151
|
+
const vscodeLike = (product) => {
|
|
152
|
+
const user = path.join(appSupport, product, 'User')
|
|
153
|
+
return [
|
|
154
|
+
{ name: product === 'Code' ? 'VS Code (GitHub Copilot)' : product, dir: user, file: path.join(user, 'mcp.json'), write: json(['servers', 'quilt'], (s) => ({ type: 'stdio', ...s })) },
|
|
155
|
+
{ name: `Cline (${product})`, dir: path.join(user, 'globalStorage', 'saoudrizwan.claude-dev'), file: path.join(user, 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), write: json(['mcpServers', 'quilt']) },
|
|
156
|
+
{ name: `Roo Code (${product})`, dir: path.join(user, 'globalStorage', 'rooveterinaryinc.roo-cline'), file: path.join(user, 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), write: json(['mcpServers', 'quilt']) }
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
const codexHome = env.CODEX_HOME || path.join(home, '.codex')
|
|
160
|
+
return [
|
|
161
|
+
{ name: 'Claude Code', dir: path.join(home, '.claude'), file: path.join(home, '.claude.json'), write: json(['mcpServers', 'quilt'], (s) => ({ type: 'stdio', ...s })), busy: (f) => fs.existsSync(`${f}.lock`) },
|
|
162
|
+
{ name: 'Claude Desktop', dir: path.join(appSupport, 'Claude'), file: path.join(appSupport, 'Claude', 'claude_desktop_config.json'), write: json(['mcpServers', 'quilt']) },
|
|
163
|
+
{ name: 'Cursor', dir: path.join(home, '.cursor'), file: path.join(home, '.cursor', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
|
|
164
|
+
{ name: 'Windsurf', dir: path.join(home, '.codeium', 'windsurf'), file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), write: json(['mcpServers', 'quilt']) },
|
|
165
|
+
{ name: 'Codex', dir: codexHome, file: path.join(codexHome, 'config.toml'), write: upsertCodexToml },
|
|
166
|
+
{ name: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), write: (f, s, launch) => { const r = json(['mcpServers', 'quilt'])(f, s); return r === 'skipped' ? r : both(r, upsertHooks(f, geminiHookSettings(quiltShellCommand(['hook', 'gemini'], launch)))) } },
|
|
167
|
+
{ name: 'GitHub Copilot CLI', dir: path.join(home, '.copilot'), file: path.join(home, '.copilot', 'mcp-config.json'), write: json(['mcpServers', 'quilt'], (s) => ({ type: 'local', ...s, tools: ['*'] })) },
|
|
168
|
+
{ name: 'Zed', dir: platform === 'win32' ? path.join(appData, 'Zed') : path.join(xdg, 'zed'), file: platform === 'win32' ? path.join(appData, 'Zed', 'settings.json') : path.join(xdg, 'zed', 'settings.json'), write: (f, s) => upsertZed(f, { source: 'custom', command: s.command, args: s.args, env: s.env || {} }) },
|
|
169
|
+
{ name: 'opencode', dir: path.join(xdg, 'opencode'), file: path.join(xdg, 'opencode', 'opencode.json'), write: json(['mcp', 'quilt'], (s) => ({ type: 'local', command: [s.command, ...s.args], enabled: true, ...(s.env ? { environment: s.env } : {}) })) },
|
|
170
|
+
{ name: 'Kiro', dir: path.join(home, '.kiro'), file: path.join(home, '.kiro', 'settings', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
|
|
171
|
+
{ name: 'Amp', dir: path.join(xdg, 'amp'), file: path.join(xdg, 'amp', 'settings.json'), write: json(['amp.mcpServers', 'quilt']) },
|
|
172
|
+
{ name: 'Junie', dir: path.join(home, '.junie'), file: path.join(home, '.junie', 'mcp', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
|
|
173
|
+
{ name: 'Continue', dir: path.join(home, '.continue'), file: path.join(home, '.continue', 'mcpServers', 'quilt.json'), write: json(['mcpServers', 'quilt']) },
|
|
174
|
+
...vscodeLike('Code'),
|
|
175
|
+
...vscodeLike('Code - Insiders')
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Registers Quilt's MCP server with every AI tool installed here. Never throws.
|
|
181
|
+
* Returns [{ name, file, result }] for every tool found ('added' | 'updated' |
|
|
182
|
+
* 'unchanged' | 'skipped' | 'busy' | 'failed', with `error` for failed).
|
|
183
|
+
*/
|
|
184
|
+
export function registerEverywhere ({ launch = quiltLaunch(), ...where } = {}) {
|
|
185
|
+
const server = mcpServer(launch)
|
|
186
|
+
const out = []
|
|
187
|
+
for (const t of knownTools(where)) {
|
|
188
|
+
if (!fs.existsSync(t.dir)) continue
|
|
189
|
+
try {
|
|
190
|
+
if (t.busy && t.busy(t.file)) { out.push({ name: t.name, file: t.file, result: 'busy' }); continue }
|
|
191
|
+
out.push({ name: t.name, file: t.file, result: t.write(t.file, server, launch) })
|
|
192
|
+
} catch (err) {
|
|
193
|
+
out.push({ name: t.name, file: t.file, result: 'failed', error: err.message })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One line for logs: which tools just got Quilt, and which need a look. Empty when nothing changed. */
|
|
200
|
+
export function describeRegistration (results) {
|
|
201
|
+
const done = results.filter((r) => r.result === 'added' || r.result === 'updated').map((r) => r.name)
|
|
202
|
+
const left = results.filter((r) => r.result === 'skipped' || r.result === 'failed').map((r) => `${r.name} (${r.file})`)
|
|
203
|
+
const parts = []
|
|
204
|
+
if (done.length) parts.push(`🔌 Quilt is now available to ${done.join(', ')}; restart a tool that was open to pick it up`)
|
|
205
|
+
if (left.length) parts.push(`could not safely edit: ${left.join(', ')}`)
|
|
206
|
+
return parts.join('. ')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* What the app, a session and `quilt login` run as they start: register, and log what changed.
|
|
211
|
+
* Skipped under the test runner (it would write to the developer's own tool settings) unless
|
|
212
|
+
* QUILT_REGISTER_TOOLS=1 asks for it.
|
|
213
|
+
*/
|
|
214
|
+
export function registerOnStart (log = () => {}, opts = {}) {
|
|
215
|
+
if (process.env.NODE_TEST_CONTEXT && process.env.QUILT_REGISTER_TOOLS !== '1') return []
|
|
216
|
+
let results = []
|
|
217
|
+
try { results = registerEverywhere(opts) } catch { return [] }
|
|
218
|
+
const line = describeRegistration(results)
|
|
219
|
+
if (line) log(line)
|
|
220
|
+
return results
|
|
221
|
+
}
|
package/src/key-cli.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The quilt commands in a sandbox: no `quilt join` runs there, so with a QUILT_AGENT_KEY each
|
|
2
|
+
// command is one call to the hosted tools (hosted-client.js), as the agent, in the key's session.
|
|
3
|
+
// bin/quilt.js comes here when no session runs in the folder and the key is set.
|
|
4
|
+
|
|
5
|
+
/** Commands that work with the key. tasks, task and tool exist only here. */
|
|
6
|
+
export const KEY_COMMANDS = new Set(['status', 'messages', 'inbox', 'say', 'history', 'claim', 'release', 'focus', 'commits', 'commit-request', 'tasks', 'task', 'tool'])
|
|
7
|
+
|
|
8
|
+
export const KEY_USAGE = `With QUILT_AGENT_KEY (an agent in a sandbox, no quilt join running):
|
|
9
|
+
quilt sync Sync this folder with the session once (start and end of every turn)
|
|
10
|
+
quilt status | inbox | tasks | commits What is going on, what waits for you, the board, commit requests
|
|
11
|
+
quilt messages [--with name] [--grep text] [--before id] [-n 30]
|
|
12
|
+
quilt say [@name] <message> [--everyone] [--also]
|
|
13
|
+
quilt task <id> One task in full, with its comments
|
|
14
|
+
quilt history [path] [--by name] [--since 2h] [--task id] [--diff] [-n 30]
|
|
15
|
+
quilt claim <path|glob> [note] | quilt release [path|glob]
|
|
16
|
+
quilt focus <what you are doing> Shared with everyone (quilt_share)
|
|
17
|
+
quilt commit-request <message> [--files a,b] [--task id]
|
|
18
|
+
quilt tool <quilt_tool_name> [json] Any Quilt tool, e.g. quilt tool quilt_move_task '{"id":"t1","column":"doing"}'
|
|
19
|
+
quilt tool Lists the tools
|
|
20
|
+
quilt mcp The same tools as a stdio MCP server`
|
|
21
|
+
|
|
22
|
+
/** --flag value pairs and positionals, for the few flags these commands take. */
|
|
23
|
+
function parse (argv, flags, bools = []) {
|
|
24
|
+
const values = {}
|
|
25
|
+
const rest = []
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i]
|
|
28
|
+
const name = a.replace(/^--?/, '')
|
|
29
|
+
if (a.startsWith('-') && flags.includes(name)) { values[name] = argv[++i]; continue }
|
|
30
|
+
if (a.startsWith('--') && bools.includes(name)) { values[name] = true; continue }
|
|
31
|
+
rest.push(a)
|
|
32
|
+
}
|
|
33
|
+
return { values, rest }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const num = (v, d) => (v === undefined ? d : Math.max(1, Math.min(200, Number(v) || d)))
|
|
37
|
+
|
|
38
|
+
/** The tool and arguments a command stands for, or { list: true } for `quilt tool` alone. */
|
|
39
|
+
export function toolFor (cmd, argv, { stdin = '' } = {}) {
|
|
40
|
+
switch (cmd) {
|
|
41
|
+
case 'status': return ['quilt_status', {}]
|
|
42
|
+
case 'inbox': return ['quilt_inbox', {}]
|
|
43
|
+
case 'commits': return ['quilt_commit_status', {}]
|
|
44
|
+
case 'tasks': return ['quilt_tasks', parse(argv, [], ['archived']).values.archived ? { archived: true } : {}]
|
|
45
|
+
case 'task': {
|
|
46
|
+
if (!argv[0]) throw new Error('usage: quilt task <id>')
|
|
47
|
+
return ['quilt_task', { id: argv[0] }]
|
|
48
|
+
}
|
|
49
|
+
case 'messages': {
|
|
50
|
+
const { values } = parse(argv, ['with', 'grep', 'before', 'n'], ['all'])
|
|
51
|
+
if (values.with || values.grep || values.before) return ['quilt_conversation', { ...(values.with ? { with: values.with } : {}), ...(values.grep ? { q: values.grep } : {}), ...(values.before ? { before: values.before } : {}), limit: num(values.n, 30) }]
|
|
52
|
+
return ['quilt_read_messages', { limit: Math.min(100, num(values.n, 50)) }]
|
|
53
|
+
}
|
|
54
|
+
case 'say': {
|
|
55
|
+
const { values, rest } = parse(argv, ['to', 're'], ['everyone', 'also'])
|
|
56
|
+
let to = values.to
|
|
57
|
+
// "@bob text" is a direct message to bob, as with a running session (bin/quilt.js splitRecipient); @Agents is everyone's.
|
|
58
|
+
if (!to && rest.length > 1 && /^@[^@\s]+$/.test(rest[0]) && !/^@agents$/i.test(rest[0])) to = rest.shift().slice(1)
|
|
59
|
+
const text = rest.join(' ').trim()
|
|
60
|
+
if (!text) throw new Error('usage: quilt say [@name] <message> (a message names who it is for with @Name, or add --everyone)')
|
|
61
|
+
return ['quilt_message', { text, ...(to ? { to } : {}), ...(values.everyone ? { everyone: true } : {}), ...(values.also ? { also: true } : {}), ...(values.re ? { re: values.re } : {}) }]
|
|
62
|
+
}
|
|
63
|
+
case 'history': {
|
|
64
|
+
const { values, rest } = parse(argv, ['by', 'since', 'task', 'n'], ['diff'])
|
|
65
|
+
return ['quilt_history', { ...(rest[0] ? { path: rest[0] } : {}), ...(values.by ? { by: values.by } : {}), ...(values.since ? { since: values.since } : {}), ...(values.task ? { task: values.task } : {}), ...(values.diff ? { with_diff: true } : {}), limit: num(values.n, 30) }]
|
|
66
|
+
}
|
|
67
|
+
case 'claim': {
|
|
68
|
+
if (!argv[0]) throw new Error('usage: quilt claim <path|glob> [note]')
|
|
69
|
+
return ['quilt_claim', { pattern: argv[0], ...(argv.length > 1 ? { note: argv.slice(1).join(' ') } : {}) }]
|
|
70
|
+
}
|
|
71
|
+
case 'release': return ['quilt_release', argv[0] ? { pattern: argv[0] } : {}]
|
|
72
|
+
case 'focus': {
|
|
73
|
+
if (!argv.length) throw new Error('usage: quilt focus <what you are doing>')
|
|
74
|
+
return ['quilt_share', { summary: argv.join(' ') }]
|
|
75
|
+
}
|
|
76
|
+
case 'commit-request': {
|
|
77
|
+
const { values, rest } = parse(argv, ['files', 'task'])
|
|
78
|
+
const files = values.files ? values.files.split(',').map((f) => f.trim()).filter(Boolean) : undefined
|
|
79
|
+
return ['quilt_request_commit', { ...(rest.length ? { message: rest.join(' ') } : {}), ...(files ? { files } : {}), ...(values.task ? { task: values.task } : {}) }]
|
|
80
|
+
}
|
|
81
|
+
case 'tool': {
|
|
82
|
+
if (!argv[0]) return { list: true }
|
|
83
|
+
const raw = argv[1] === '-' ? stdin : argv.slice(1).join(' ')
|
|
84
|
+
let args = {}
|
|
85
|
+
if (raw && raw.trim()) {
|
|
86
|
+
try { args = JSON.parse(raw) } catch { throw new Error(`The arguments must be JSON, e.g. quilt tool ${argv[0]} '{"key":"value"}'`) }
|
|
87
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('The arguments must be a JSON object.')
|
|
88
|
+
}
|
|
89
|
+
return [argv[0], args]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`quilt ${cmd} isn't a command here.\n\n${KEY_USAGE}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Runs one command with the key's client: prints the answer, resolves to the exit code. */
|
|
96
|
+
export async function runKeyCommand (cmd, argv, { client, out = console.log, err = console.error, stdin = '' } = {}) {
|
|
97
|
+
let what
|
|
98
|
+
try { what = toolFor(cmd, argv, { stdin }) } catch (e) { err(e.message); return 1 }
|
|
99
|
+
try {
|
|
100
|
+
if (what.list) {
|
|
101
|
+
const r = await client.rpc('tools/list', {})
|
|
102
|
+
out((r.tools || []).map((t) => `${t.name}: ${String(t.description || '').split('. ')[0]}`).join('\n'))
|
|
103
|
+
return 0
|
|
104
|
+
}
|
|
105
|
+
const [name, args] = what
|
|
106
|
+
const r = await client.callRaw(name, args)
|
|
107
|
+
const text = client.textOf(r)
|
|
108
|
+
if (r && r.isError) { err(text || `${name} was refused.`); return 1 }
|
|
109
|
+
out(text)
|
|
110
|
+
return 0
|
|
111
|
+
} catch (e) {
|
|
112
|
+
err(e.message)
|
|
113
|
+
return 1
|
|
114
|
+
}
|
|
115
|
+
}
|