connection-centric 0.1.0 → 0.3.0
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/GUIDE.md +8 -6
- package/README.md +2 -2
- package/centric.mjs +26 -14
- package/commands.mjs +75 -32
- package/package.json +1 -1
package/GUIDE.md
CHANGED
|
@@ -8,7 +8,7 @@ Connection-Centric is where a construction firm, its site staff and its subcontr
|
|
|
8
8
|
|
|
9
9
|
centric connect --name "Claude Code on the site laptop"
|
|
10
10
|
|
|
11
|
-
This prints
|
|
11
|
+
This prints an 8-character code. The person types it on their own phone under Me, Connected tools, Connect a tool, and approves the tool by name, which they may change. The code works once, for 10 minutes. There is no link to open instead: a link can be sent to anyone, and one tap would connect the sender's tool. The credential is kept in the person's own config directory, readable by them alone. For a script on a server, `CENTRIC_CREDENTIAL` and `CENTRIC_SERVER` take its place; `--server` names another server for one command.
|
|
12
12
|
|
|
13
13
|
A tool lapses after 90 days unused. The person disconnects it from Me, or with `centric connected-tool disconnect --this`; Centric can too. Rotating the person's key, or putting them to sleep, disconnects every tool they have. A Company's admin cannot disconnect one.
|
|
14
14
|
|
|
@@ -20,7 +20,7 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
|
|
|
20
20
|
- A target is an id, or an exact name that only one thing the person can see has, in any case. Two things with that name, and the command is refused, listing both with their ids. Output always carries ids: pass ids.
|
|
21
21
|
- A person is named by identity id, by phone number in E.164 (`+` and the country code, then the number, no spaces: `+91` and ten digits in India), or by exact display name.
|
|
22
22
|
- `--in <project|company>` says where, for anything inside a container, and narrows a name to that container.
|
|
23
|
-
- The audience is never assumed. Making a channel
|
|
23
|
+
- The audience is never assumed. Making a channel or document in a Project needs `--externals visible` or `--externals hidden`. In a Company everyone sees it, and the flag is refused. A task takes its audience from where it is talked about: posted in a channel (`task make --channel`), that channel's; raised from a message (`--from-message`), that message's channel's, or staff only with `--externals hidden`.
|
|
24
24
|
- A mention is only ever a flag: `--mention <identity>`, again for each, or `--mention-all`. Writing `@Name` in the text reaches nobody.
|
|
25
25
|
- `--text -` reads the words from standard input.
|
|
26
26
|
- `--path` names a file on this computer. Over MCP it is a data: URL instead: `data:<type>;name=<file name>;base64,<bytes>`.
|
|
@@ -32,8 +32,9 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
|
|
|
32
32
|
- **company**: the one container a firm holds for work that belongs to no site. Never archived; nobody makes one but Centric, when an Operator approves its Application.
|
|
33
33
|
- **channel**: a named conversation inside a container, and the only place a container's messages live. In a Project it is either visible to externals or staff-only. A Company channel may be private: read only by the people its admins name.
|
|
34
34
|
- **message**: one message in a channel or a Direct. A reply quotes the message it answers. Files sent in a channel are kept as documents.
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
35
|
+
- **pinned-message**: a message kept at the top of its channel, Direct or conversation, for everyone in it, at most 50 in one place. In a Project only staff pin; anyone who may pin may unpin. A channel's pins are on the record; a Direct's and a conversation's are not.
|
|
36
|
+
- **direct**: a conversation between two people who share a container, or a person's own with themselves, for notes and what they keep to file later. Name your own `me` (`direct send me`); `direct list` marks it `you`. Nothing in a Direct is part of the record: no task, no issuance, no deleting, and the assistant never reads or corrects one.
|
|
37
|
+
- **task**: an open piece of work in one container, which anyone who can see it may close or reopen. Every task is part of one channel's conversation: made on its own, it is posted there as your message; what is said about it is a reply to that message (`message send --task`). It may carry details, a due day, a priority and a zone. Open past its due day, or with an ask escalated, it is overdue.
|
|
37
38
|
- **ask**: one person's request to another, on a task, closed by closing the task. The person asked may answer not yet, saying why and when to be asked again.
|
|
38
39
|
- **issuance**: a document revision or an instruction put to a named person, who must acknowledge it. Acknowledging is deliberate and is the record; reading or downloading is never one.
|
|
39
40
|
- **document**: a file kept as a series of revisions; a new revision never replaces an old one. Every photo or file sent in a channel is one.
|
|
@@ -54,7 +55,7 @@ A tool lapses after 90 days unused. The person disconnects it from Me, or with `
|
|
|
54
55
|
## What is refused, and why
|
|
55
56
|
|
|
56
57
|
- Anything the person could not do in the app. An external never sees a staff-only channel, task or document; a private channel is read only by the people named in it; only an admin changes who belongs, and only staff delete.
|
|
57
|
-
- **The cap.** What reaches someone else's phone (asks, issuances, mentions, `@all`, Directs) is capped at 30 an hour for each tool. Past it, the command is refused with `retry_after`, the seconds until it may try again. Reading and closing are never capped.
|
|
58
|
+
- **The cap.** What reaches someone else's phone (asks, issuances, mentions, `@all`, Directs with someone else) is capped at 30 an hour for each tool. Past it, the command is refused with `retry_after`, the seconds until it may try again. Reading and closing are never capped.
|
|
58
59
|
- A tool is never connected from a tool: approving one is the person's own act, on their phone.
|
|
59
60
|
- `--dry-run`, on the acts the app warns about before doing them, says what would change and changes nothing: `channel edit --externals` (how many messages change who can see them), `channel private` and `channel open-to-company` (who gains it and who loses it), `membership revoke` (what they lose), and `identity dormant` and `identity rotate-key` (every firm they belong to, and every tool that would disconnect).
|
|
60
61
|
|
|
@@ -74,7 +75,8 @@ Fetching never marks anything read. A sender told their message was read must ha
|
|
|
74
75
|
|
|
75
76
|
- Text for a person by default. With `--json`, exactly one JSON value on stdout: the object for `show`, `make`, `edit` and every act, or `{"items": […], "more": <cursor or null>}` for `list` and the places. Field names are the API's own.
|
|
76
77
|
- Days are `YYYY-MM-DD` in India's time zone; moments are ISO 8601 with `+05:30`.
|
|
77
|
-
- `
|
|
78
|
+
- A message has `id`, `at`, `sender`, `text` and where it was said, `channel` or `direct`, among its fields. One that shows a photo or file has `document` too, its `id`, `name` and `revision_n`: `document download <id> --revision <n>` fetches what the message showed. Among a channel's messages, a deleted one stands where it was as `id` and `tombstone` (`deleted_at`, `deleted_by_name`), with no `at`, `sender` or `text`.
|
|
79
|
+
- `0` done. `1` refused: not allowed, not valid, over a limit, or an ambiguous target. `2` broken: the network, the server, or a command line older than the server accepts, which says how to update. The command line asks a read again once before calling it broken; over MCP, that is the client's to do. A write is never asked again; broken on the way, it says it may have been done, so look before doing it twice.
|
|
78
80
|
- A refusal under `--json` prints `{"error": {"kind": "refused" | "broken", "message": …, "retry_after"?: …, "candidates"?: […]}}` on stderr.
|
|
79
81
|
|
|
80
82
|
## Over MCP
|
package/README.md
CHANGED
|
@@ -5,11 +5,11 @@ The command line for [Connection-Centric](https://app.centric.business): what on
|
|
|
5
5
|
npm install --global connection-centric
|
|
6
6
|
centric connect --name "Claude Code on the site laptop"
|
|
7
7
|
|
|
8
|
-
`connect` prints a
|
|
8
|
+
`connect` prints a code. Type it on your own phone, in the app under Me, Connected tools, Connect a tool, and approve the tool. Never type a code someone sent you: it would connect their tool as you. From then on:
|
|
9
9
|
|
|
10
10
|
centric home # what needs you now
|
|
11
11
|
centric round # what has something unread, addressed to you first
|
|
12
|
-
centric task make --
|
|
12
|
+
centric task make --channel "Structural works" --title "Check level 5 props"
|
|
13
13
|
centric guide # everything else: every command, what is refused and why
|
|
14
14
|
|
|
15
15
|
Everything the tool does carries its name, for everyone who sees it. It is disconnected from Me in the app, and lapses after 90 days unused. Node 22 or later; no dependencies. The same commands are tools of the MCP server at `https://app.centric.business/api/mcp`.
|
package/centric.mjs
CHANGED
|
@@ -54,14 +54,22 @@ function caller(server, credential) {
|
|
|
54
54
|
// JSON both ways; or a file's bytes up (file), or down (bytes: true).
|
|
55
55
|
const call = async (method, path, body, { anon = false, raw = false, file = null, bytes = false } = {}) => {
|
|
56
56
|
if (!anon && !credential) throw new Refused("this machine is not connected; run centric connect");
|
|
57
|
+
const headers = { "x-centric-cli": CLI_VERSION, ...(anon ? {} : { authorization: `Bearer ${credential}` }), ...(file ? { "content-type": TYPES[extname(file.name).toLowerCase()] ?? "application/octet-stream", "x-centric-name": encodeURIComponent(file.name) } : body ? { "content-type": "application/json" } : {}) };
|
|
58
|
+
// A read lost to a restart (a deploy's is under a second: the connection drops, or the proxy answers 502 to 504) is
|
|
59
|
+
// asked again once; a write never is, since it may have been done, and an agent told so looks before doing it twice.
|
|
60
|
+
const lost = (status) => status >= 502 && status <= 504, unsure = method === "GET" ? "" : "; whether it was done is not known, so look before doing it again";
|
|
57
61
|
let r;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
for (let tries = method === "GET" ? 2 : 1; ; await sleep(2000)) {
|
|
63
|
+
try { r = await fetch(new URL(path, server), { method, headers, body: file ? file.bytes : body ? JSON.stringify(body) : undefined }); } catch (e) {
|
|
64
|
+
if (--tries) continue;
|
|
65
|
+
throw new Broken(`could not reach ${server}: ${e.cause?.code ?? e.message}${unsure}`);
|
|
66
|
+
}
|
|
67
|
+
if (!lost(r.status) || !--tries) break;
|
|
68
|
+
await r.body?.cancel(); // its connection goes back, not held for an answer nobody reads
|
|
69
|
+
}
|
|
62
70
|
if (bytes && r.ok) return Buffer.from(await r.arrayBuffer());
|
|
63
71
|
const j = await r.json().catch(() => null);
|
|
64
|
-
if (r.status === 426 || r.status >= 500) throw new Broken(j?.error ?? `the server answered ${r.status}`);
|
|
72
|
+
if (r.status === 426 || r.status >= 500) throw new Broken(`${j?.error ?? `the server answered ${r.status}`}${lost(r.status) ? unsure : ""}`);
|
|
65
73
|
if (r.status >= 400) throw new Refused(j?.error ?? `refused (${r.status})`, { ...(j?.retry_after != null && { retry_after: j.retry_after }) });
|
|
66
74
|
return raw ? { status: r.status, body: j } : j;
|
|
67
75
|
};
|
|
@@ -81,14 +89,15 @@ function caller(server, credential) {
|
|
|
81
89
|
return c;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
// The pairing (ADR 0008): a
|
|
92
|
+
// The pairing (ADR 0008): a code the person types on their phone, then the credential once they approve. Never a
|
|
93
|
+
// link: one can be sent to anyone, and a tap would connect the sender's tool.
|
|
85
94
|
async function connect(c, a, server) {
|
|
86
95
|
const name = a.name ?? hostname().slice(0, 60);
|
|
87
96
|
let got;
|
|
88
97
|
if (a["dev-phone"]) got = await c.call("POST", "/api/connected-tools/dev", { phone: a["dev-phone"], name }, { anon: true });
|
|
89
98
|
else {
|
|
90
99
|
const p = await c.call("POST", "/api/connected-tools", { name }, { anon: true });
|
|
91
|
-
process.stderr.write(`To connect "${p.name}",
|
|
100
|
+
process.stderr.write(`To connect "${p.name}", type ${p.code} in the app, under Me, Connected tools, Connect a tool.\nThe code works once, for ${Math.round(p.expires_in / 60)} minutes. Type it on your own phone; never send it to anyone.\n`);
|
|
92
101
|
for (;;) {
|
|
93
102
|
const r = await c.call("POST", `/api/connected-tools/${p.id}/collect`, { poll: p.poll }, { anon: true, raw: true });
|
|
94
103
|
if (r.status !== 202) { got = r.body; break; }
|
|
@@ -96,17 +105,18 @@ async function connect(c, a, server) {
|
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
99
|
-
writeFileSync(file(), JSON.stringify({ server, credential: got.credential
|
|
108
|
+
writeFileSync(file(), JSON.stringify({ server, credential: got.credential }, null, 2), { mode: 0o600 });
|
|
100
109
|
chmodSync(file(), 0o600); // a file there already keeps its old mode otherwise
|
|
101
110
|
return { tool: got.tool, identity: got.identity, server };
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
// ── printing ──
|
|
105
|
-
const clip = (s
|
|
114
|
+
const clip = (s) => { s = String(s).replace(/\s+/g, " "); return s.length > 70 ? `${s.slice(0, 69)}…` : s; };
|
|
106
115
|
const label = (x) => x.name ?? x.title ?? x.topic_name ?? x.task_title ?? x.document_name ?? x.display_name ?? x.firm_name
|
|
107
|
-
?? (x.text != null ? `${x.sender?.display_name ?? "?"}${x.via ? ` (via ${x.via.name})` : ""}: ${clip(x.text
|
|
116
|
+
?? (x.text != null ? `${x.sender?.display_name ?? "?"}${x.via ? ` (via ${x.via.name})` : ""}: ${clip(x.text)}` : (x.tombstone ?? x).deleted_by_name ? `deleted by ${(x.tombstone ?? x).deleted_by_name}` : null);
|
|
108
117
|
const where = (x) => x.container_name ?? (x.channel ? `${x.channel.name} · ${x.channel.container_name}` : x.direct ? `Direct with ${x.direct.display_name}` : null);
|
|
109
|
-
|
|
118
|
+
// A tombstone in channel show is named by the message it stands for; a message with a file, by the document to download.
|
|
119
|
+
const row = (x) => (x && typeof x === "object" ? [x.kind && `[${x.kind}]`, x.id ?? x.zulip_message_id, label(x), where(x), x.document && `document ${x.document.id}`, x.due_on && `due ${x.due_on}`, x.this && "(this one)"].filter((v) => v != null && v !== "").join(" ") : String(x));
|
|
110
120
|
// Words others wrote, on a terminal: control characters (escape sequences among them) go, but a line and a tab stay.
|
|
111
121
|
const plain = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
112
122
|
// JSON escapes the first 32 itself; the C1 ones a terminal may also obey are escaped too, so nothing is lost.
|
|
@@ -127,12 +137,14 @@ async function main(argv) {
|
|
|
127
137
|
let json = argv.includes("--json");
|
|
128
138
|
try {
|
|
129
139
|
const { cmd, given, opts, lead } = parse(argv);
|
|
130
|
-
json
|
|
140
|
+
json ||= opts.json === true || opts.json === "true"; // --json=true too, which argv alone does not show
|
|
141
|
+
// Help is one JSON value too under --json (rule 9): a program reading stdout gets what it asked for, or an error.
|
|
142
|
+
const say = (h) => { process.stdout.write(`${json ? asJson({ help: h }) : h}\n`); return 0; };
|
|
131
143
|
if (!cmd) {
|
|
132
|
-
if (!lead.length || lead[0] === "help")
|
|
144
|
+
if (!lead.length || lead[0] === "help") return say(help(lead[0] === "help" && (commandOf(lead[1], null) ?? commandOf(lead[1], lead[2]))));
|
|
133
145
|
throw new Refused(`no command "${lead.slice(0, 2).join(" ")}"; centric help lists them`);
|
|
134
146
|
}
|
|
135
|
-
if (opts.help)
|
|
147
|
+
if (opts.help) return say(help(cmd));
|
|
136
148
|
const file = saved(), server = opts.server || process.env.CENTRIC_SERVER || file.server || DEFAULT_SERVER; // set but empty is not set
|
|
137
149
|
// The credential crosses a network only encrypted: an http:// server is this machine's, for development.
|
|
138
150
|
if (!/^https:\/\//i.test(server) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(server)) throw new Refused(`${server} is not https; a credential is sent only over https, or to this machine`);
|
package/commands.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export const CLI_VERSION = JSON.parse(readFileSync(new URL("./package.json", imp
|
|
|
16
16
|
|
|
17
17
|
// Rule 2: the verbs, each meaning one thing on every noun. A new verb is added here, and to the spec, before it is used.
|
|
18
18
|
export const VERBS = ["list", "show", "make", "edit", "delete", "archive", "unarchive", "close", "reopen", "not-yet", "grant", "revoke", "appoint", "remove",
|
|
19
|
-
"issue", "acknowledge", "add", "revise", "restore", "download", "messages", "mark-read", "send", "react", "unreact", "history", "readers",
|
|
19
|
+
"issue", "acknowledge", "add", "revise", "restore", "download", "messages", "mark-read", "send", "forward", "react", "unreact", "history", "readers",
|
|
20
20
|
"pin", "unpin", "mute", "unmute", "private", "open-to-company", "set-picture", "remove-picture", "set-photo", "remove-photo",
|
|
21
21
|
"approve", "reject", "withdraw", "rotate-key", "dormant", "reactivate", "report", "record-pack", "audit", "question", "correct", "disconnect"];
|
|
22
22
|
|
|
@@ -96,13 +96,14 @@ export async function findTarget(get, kind, ref, within) {
|
|
|
96
96
|
refuse(rows.length ? `${rows.length} things you can see are called "${ref}"; name one by its id` : `nothing called "${ref}" that you can see is a ${kind}${within ? " there" : ""}`, rows.length ? { candidates: rows } : {});
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
const IN = { kind: "container", need: true, about: "the Project or Company
|
|
99
|
+
const IN = { kind: "container", need: true, about: "the Project or Company" };
|
|
100
100
|
const EXTERNALS = { kind: "choice", of: ["visible", "hidden"], about: "in a Project, whether its externals see it; never in a Company" };
|
|
101
101
|
const PRIORITY = { kind: "choice", of: ["high", "normal", "low"] };
|
|
102
102
|
const TEXT = { kind: "text", need: true, about: "the words, or - to read them from standard input" };
|
|
103
103
|
const PATH = { kind: "path", need: true, about: "a file on this computer" };
|
|
104
104
|
const NOTE = { kind: "text", about: "what it is, or what changed" };
|
|
105
105
|
const DRY = { kind: "bool", about: "say what would change, and change nothing" };
|
|
106
|
+
const SEARCH = { kind: "words" }; // only the messages holding these words (Features 523)
|
|
106
107
|
const PAGE = { kind: "path", about: "write it as a page, a file ending .html" };
|
|
107
108
|
// A page the server draws (a report, a record pack), written to this computer: never over a file already there.
|
|
108
109
|
const page = async (c, path, out) => { if (!/\.html?$/i.test(out)) refuse("--out is a page: name it something.html"); const b = await c.bytes(path); await c.save(out, b); return { path: out, bytes: b.length }; };
|
|
@@ -130,11 +131,24 @@ export const COMMANDS = [
|
|
|
130
131
|
...named.items.map((x) => ({ kind: "mention", ...x })), ...directs.items.map((x) => ({ kind: "direct", ...x })),
|
|
131
132
|
]);
|
|
132
133
|
} },
|
|
133
|
-
|
|
134
|
+
// Search (CONTEXT.md Search, ADR 0009): the record by its words, every version of a message, the words inside a PDF, and
|
|
135
|
+
// your own Directs; narrowed by who, where, when, a document's kind and a state.
|
|
136
|
+
{ noun: "search", verb: null, target: { name: "words", kind: "words", optional: true },
|
|
137
|
+
flags: { from: { kind: "identity", about: "only what this person did" },
|
|
138
|
+
in: { kind: "container" }, channel: { kind: "channel" }, task: { kind: "task", about: "only in its conversation" },
|
|
139
|
+
after: { kind: "day", about: "from this day" }, before: { kind: "day", about: "to this day" },
|
|
140
|
+
kind: { kind: "choice", of: ["photo", "pdf", "video", "voice", "spreadsheet", "other"] },
|
|
141
|
+
state: { kind: "choice", of: ["open", "closed", "unacknowledged"], about: "tasks open or closed; issuances not acknowledged" },
|
|
142
|
+
oldest: { kind: "bool", about: "oldest first, not best first" } },
|
|
143
|
+
about: "The record by its words: messages (earlier versions too), documents and their text, tasks, issuances, deletions, channels, people, your own Directs.",
|
|
134
144
|
async run(c, a) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
if (a.channel && a.task) refuse("--channel or --task, not both");
|
|
146
|
+
const by = { from: a.from, in: a.channel ?? a.task ?? a.in, after: a.after, before: a.before, kind: a.kind, state: a.state };
|
|
147
|
+
if (!a.words && !Object.values(by).some(Boolean)) refuse("search needs the words, or one of --from, --in, --channel, --task, --after, --before, --kind and --state");
|
|
148
|
+
const r = await c.get(`/api/search${qs({ q: a.words, ...by, order: a.oldest && "oldest" })}`), revised = new Set(r.revisions.map((x) => x.document_id));
|
|
149
|
+
return listed([...r.messages.map((x) => ({ kind: "message", ...x })), ...r.directs.map((x) => ({ kind: "direct", ...x })), ...r.revisions.map((x) => ({ kind: "revision", ...x })),
|
|
150
|
+
...r.documents.filter((x) => !revised.has(x.id)).map((x) => ({ kind: "document", ...x })), ...r.tasks.map((x) => ({ kind: "task", ...x })), ...r.issuances.map((x) => ({ kind: "issuance", ...x })),
|
|
151
|
+
...r.tombstones.map((x) => ({ kind: "tombstone", ...x })), ...r.channels.map((x) => ({ kind: "channel", ...x })), ...r.zones.map((x) => ({ kind: "zone", ...x })), ...r.people.map((x) => ({ kind: "identity", ...x }))]);
|
|
138
152
|
} },
|
|
139
153
|
{ noun: "guide", verb: null, flags: { as: { kind: "choice", of: ["skill", "agents"], about: "as a Claude Code skill (a SKILL.md), or as a section an AGENTS.md can include" } },
|
|
140
154
|
about: "The instructions for a program using this command: what each noun means, what is refused and why, the mark, the cap, --json, the exit codes, and every command.",
|
|
@@ -153,9 +167,13 @@ export const COMMANDS = [
|
|
|
153
167
|
} },
|
|
154
168
|
|
|
155
169
|
// ── work ──
|
|
156
|
-
{ noun: "task", verb: "list", flags: { in: IN, state: { kind: "choice", of: ["open", "closed"] }, zone: { kind: "zone" } },
|
|
170
|
+
{ noun: "task", verb: "list", flags: { in: IN, state: { kind: "choice", of: ["open", "closed"] }, zone: { kind: "zone" }, before: { kind: "words", about: "with --state closed: the next page, from the more: of the one before" } },
|
|
157
171
|
about: "A container's tasks: open ones first, soonest due, then the most important; done ones newest first, a page at a time.",
|
|
158
|
-
run: async (c, a) =>
|
|
172
|
+
run: async (c, a) => {
|
|
173
|
+
if (a.before && a.state !== "closed") refuse("--before pages done tasks: with --state closed");
|
|
174
|
+
const l = await c.get(`/api/containers/${a.in}/tasks${qs({ state: a.state, zone_id: a.zone, before: a.before })}`);
|
|
175
|
+
return listed(l, a.state === "closed" && l.length >= 100 ? l.at(-1).done_key : null); // the server's page of done ones (DONE_PAGE)
|
|
176
|
+
} },
|
|
159
177
|
{ noun: "task", verb: "show", target: { name: "task", kind: "task" }, flags: {}, about: "One task in full: its state, details, due day, priority, zone, and every ask on it with what happened since.",
|
|
160
178
|
run: (c, a) => c.get(`/api/tasks/${a.task}`) },
|
|
161
179
|
{ noun: "ask", verb: "list", flags: { "to-me": { kind: "bool", about: "only asks of you" }, "from-me": { kind: "bool", about: "only your own asks" }, state: { kind: "choice", of: ["open", "closed"], about: "open (the default) or done" } },
|
|
@@ -163,9 +181,9 @@ export const COMMANDS = [
|
|
|
163
181
|
run: (c, a) => (a["to-me"] && a["from-me"] ? refuse("--to-me or --from-me, not both") : c.get(`/api/asks${qs({ to_me: a["to-me"], from_me: a["from-me"], state: a.state })}`).then((x) => listed(x))) },
|
|
164
182
|
{ noun: "direct", verb: "list", flags: {}, about: "Your Directs with something in them, newest first.",
|
|
165
183
|
run: async (c) => listed((await c.get("/api/places")).filter((p) => p.kind === "direct").sort(newest)) },
|
|
166
|
-
{ noun: "direct", verb: "messages", target: { name: "identity", kind: "identity" }, flags: { before: { kind: "message", about: "older than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" } },
|
|
184
|
+
{ noun: "direct", verb: "messages", target: { name: "identity", kind: "identity" }, flags: { before: { kind: "message", about: "older than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, search: SEARCH },
|
|
167
185
|
about: "A Direct's messages, newest last. Marks nothing read.",
|
|
168
|
-
run: (c, a) => c.get(`/api/messages${qs({ direct: a.identity, before: a.before, limit: a.limit })}`) },
|
|
186
|
+
run: (c, a) => c.get(`/api/messages${qs({ direct: a.identity, before: a.before, limit: a.limit, search: a.search })}`) },
|
|
169
187
|
{ noun: "issuance", verb: "list", flags: { "to-me": { kind: "bool", about: "only what was issued to you" }, "by-me": { kind: "bool", about: "only what you issued" }, in: { kind: "container" }, state: { kind: "choice", of: ["open", "acknowledged"] } },
|
|
170
188
|
about: "What was issued to you or by you, newest first.",
|
|
171
189
|
run: (c, a) => (a["to-me"] && a["by-me"] ? refuse("--to-me or --by-me, not both") : c.get(`/api/issuances${qs({ to_me: a["to-me"], by_me: a["by-me"], in: a.in, state: a.state })}`).then((x) => listed(x))) },
|
|
@@ -186,21 +204,21 @@ export const COMMANDS = [
|
|
|
186
204
|
|
|
187
205
|
// ── places in a container ──
|
|
188
206
|
{ noun: "channel", verb: "list", flags: { in: IN, archived: { kind: "bool", about: "the archived ones instead" } }, about: "A container's channels that you read.",
|
|
189
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).channels.filter((ch) => ch.reads && !ch.archived_at === !a.archived)) },
|
|
207
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).channels.filter((ch) => ch.reads && !ch.archived_at === !a.archived)) },
|
|
190
208
|
{ noun: "channel", verb: "show", target: { name: "channel", kind: "channel" }, flags: {}, about: "One channel: its brief, flag, readers if private, and tombstones.",
|
|
191
209
|
run: (c, a) => c.get(`/api/channels/${a.channel}`) },
|
|
192
210
|
{ noun: "channel", verb: "messages", target: { name: "channel", kind: "channel" },
|
|
193
|
-
flags: { before: { kind: "message", about: "older than this message" }, after: { kind: "message", about: "newer than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, unread: { kind: "bool", about: "only what you have not read" } },
|
|
211
|
+
flags: { before: { kind: "message", about: "older than this message" }, after: { kind: "message", about: "newer than this message" }, limit: { kind: "int", about: "how many, up to 100 (50 if left out)" }, unread: { kind: "bool", about: "only what you have not read" }, search: SEARCH },
|
|
194
212
|
about: "A channel's messages, newest last, with tombstones where messages were deleted. Marks nothing read.",
|
|
195
|
-
run: (c, a) => (a.before && a.after ? refuse("--before or --after, not both") : c.get(`/api/messages${qs({ channel: a.channel, before: a.before, after: a.after, limit: a.limit, unread: a.unread })}`)) },
|
|
213
|
+
run: (c, a) => (a.before && a.after ? refuse("--before or --after, not both") : c.get(`/api/messages${qs({ channel: a.channel, before: a.before, after: a.after, limit: a.limit, unread: a.unread, search: a.search })}`)) },
|
|
196
214
|
{ noun: "zone", verb: "list", flags: { in: { ...IN, kind: "project" } }, about: "A Project's zones.",
|
|
197
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).zones) },
|
|
215
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).zones) },
|
|
198
216
|
{ noun: "document", verb: "list", flags: { in: IN, folder: { kind: "folder", about: "only what is in this folder" } }, about: "A container's documents, newest revision first.",
|
|
199
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).
|
|
217
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}/documents`)).filter((d) => !a.folder || d.folder_id === a.folder)) },
|
|
200
218
|
{ noun: "document", verb: "show", target: { name: "document", kind: "document" }, flags: {}, about: "One document: its revisions, issuances and acknowledgements.",
|
|
201
219
|
run: (c, a) => c.get(`/api/documents/${a.document}`) },
|
|
202
220
|
{ noun: "folder", verb: "list", flags: { in: IN }, about: "A container's folders that you can see.",
|
|
203
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).folders) },
|
|
221
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).folders) },
|
|
204
222
|
|
|
205
223
|
// ── containers, people and Centric ──
|
|
206
224
|
{ noun: "project", verb: "list", flags: {}, about: "The Projects you belong to.", run: async (c) => listed((await c.get("/api/me")).containers.filter((x) => x.kind === "project")) },
|
|
@@ -210,9 +228,9 @@ export const COMMANDS = [
|
|
|
210
228
|
{ noun: "company", verb: "show", target: { name: "company", kind: "company" }, flags: {}, about: "One Company in full: its brief, people, channels and documents.",
|
|
211
229
|
run: (c, a) => c.get(`/api/containers/${a.company}`) },
|
|
212
230
|
{ noun: "membership", verb: "list", flags: { in: IN }, about: "Who belongs to a container, with each one's designation.",
|
|
213
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}`)).members.map((m) => ({ id: m.membership_id, ...m }))) },
|
|
231
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.in}?documents=0`)).members.map((m) => ({ id: m.membership_id, ...m }))) },
|
|
214
232
|
{ noun: "admin", verb: "list", flags: { company: { kind: "company", need: true } }, about: "A Company's admins.",
|
|
215
|
-
run: async (c, a) => listed((await c.get(`/api/containers/${a.company}`)).members.filter((m) => m.is_admin).map((m) => ({ id: m.admin_id, identity_id: m.identity_id, display_name: m.display_name, designation: m.designation }))) },
|
|
233
|
+
run: async (c, a) => listed((await c.get(`/api/containers/${a.company}?documents=0`)).members.filter((m) => m.is_admin).map((m) => ({ id: m.admin_id, identity_id: m.identity_id, display_name: m.display_name, designation: m.designation }))) },
|
|
216
234
|
{ noun: "identity", verb: "show", target: { name: "identity", kind: "identity" }, flags: {}, about: "One person: their name, and the containers you both belong to with their designation in each.",
|
|
217
235
|
run: (c, a) => c.get(`/api/identities/${a.identity}`) },
|
|
218
236
|
{ noun: "access-link", verb: "list", flags: { in: { ...IN, kind: "project" } }, about: "A Project's access links: who each is for, and whether it was used, withdrawn or ran out.",
|
|
@@ -225,9 +243,19 @@ export const COMMANDS = [
|
|
|
225
243
|
async run(c) { const m = await c.get("/api/me"); return listed(m.operator ? await c.get("/api/applications") : m.application ? [m.application] : []); } },
|
|
226
244
|
|
|
227
245
|
// ── the day's work ──
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
246
|
+
// Every task is part of one channel's conversation (CONTEXT.md Task): posted there as your message, or raised from one.
|
|
247
|
+
{ noun: "task", verb: "make", flags: { channel: { kind: "channel", about: "the channel it is posted in, as your message; its audience is the channel's" }, "from-message": { kind: "message", about: "the message it is raised from, instead" },
|
|
248
|
+
in: { kind: "container", about: "with --from-message: the Project or Company it is in" }, title: { kind: "words", need: true }, externals: { kind: "choice", of: ["hidden"], about: "with --from-message: staff only, narrower than the message's channel" },
|
|
249
|
+
details: { kind: "text" }, due: { kind: "day" }, priority: PRIORITY, zone: { kind: "zone" }, document: { kind: "document", about: "a document it points at" } },
|
|
250
|
+
about: "Makes a task: posted in a channel as your message, which is where it is talked about; or raised from a message already said.",
|
|
251
|
+
async run(c, a) {
|
|
252
|
+
if (!a.channel === !a["from-message"]) refuse("say where it is talked about: --channel to post it there, or --from-message with --in");
|
|
253
|
+
if (a.channel && (a.in || a.externals)) refuse("a task posted in a channel is that channel's container and audience: leave --in and --externals off");
|
|
254
|
+
if (a["from-message"] && !a.in) refuse("--from-message needs --in, the Project or Company the message is in");
|
|
255
|
+
const fields = { title: a.title, details: a.details, due_on: a.due, priority: a.priority, zone_id: a.zone, document_id: a.document };
|
|
256
|
+
if (a.channel) return c.post(`/api/containers/${(await c.get(`/api/channels/${a.channel}`)).container_id}/tasks`, { ...fields, channel_id: a.channel });
|
|
257
|
+
return c.post(`/api/containers/${a.in}/tasks`, { ...fields, source_message_id: a["from-message"], ...(a.externals && { externals_visible: false }) });
|
|
258
|
+
} },
|
|
231
259
|
{ noun: "task", verb: "edit", target: { name: "task", kind: "task" }, flags: { title: { kind: "words" }, details: { kind: "text", none: true }, due: { kind: "day", none: true }, priority: PRIORITY, zone: { kind: "zone", none: true } },
|
|
232
260
|
about: "Changes the named fields of a task; only what is sent changes. none clears the details, due day or zone.",
|
|
233
261
|
async run(c, a) {
|
|
@@ -237,11 +265,11 @@ export const COMMANDS = [
|
|
|
237
265
|
...[["close", "Marks a task done: whoever can see a task may close it, and the asks on it close with it."], ["reopen", "Opens a done task again."]].map(([verb, about]) => (
|
|
238
266
|
{ noun: "task", verb, target: { name: "task", kind: "task" }, flags: {}, about, run: (c, a) => c.post(`/api/tasks/${a.task}/${verb}`) })),
|
|
239
267
|
{ noun: "task", verb: "delete", target: { name: "task", kind: "task" }, flags: {}, about: "Deletes a task, leaving a tombstone: staff, or whoever made it.", run: (c, a) => c.del(`/api/tasks/${a.task}`) },
|
|
240
|
-
{ noun: "ask", verb: "make", flags: { task: { kind: "task", about: "the task to ask about" }, to: { kind: "identity", need: true },
|
|
241
|
-
about: "Asks someone to do a task; or, given --
|
|
268
|
+
{ noun: "ask", verb: "make", flags: { task: { kind: "task", about: "the task to ask about" }, to: { kind: "identity", need: true }, channel: { kind: "channel", about: "for an ask with a task of its own: the channel the task is posted in" }, title: { kind: "words", about: "for an ask with a task of its own" }, due: { kind: "day" } },
|
|
269
|
+
about: "Asks someone to do a task; or, given --channel and --title instead of --task, makes the task too, posted in that channel, as the app does.",
|
|
242
270
|
async run(c, a) {
|
|
243
|
-
if (a.task ? a.title || a.
|
|
244
|
-
const task = a.task ?? (await c.post(`/api/containers/${a.
|
|
271
|
+
if (a.task ? a.title || a.channel || a.due : !(a.channel && a.title)) refuse("either --task, or --channel and --title for an ask with a task of its own");
|
|
272
|
+
const task = a.task ?? (await c.post(`/api/containers/${(await c.get(`/api/channels/${a.channel}`)).container_id}/tasks`, { title: a.title, channel_id: a.channel, due_on: a.due })).id;
|
|
245
273
|
return c.post(`/api/tasks/${task}/asks`, { to_id: a.to });
|
|
246
274
|
} },
|
|
247
275
|
{ noun: "ask", verb: "not-yet", target: { name: "ask", kind: "id" }, flags: { reason: { kind: "choice", of: Object.keys(REASONS), need: true }, again: { kind: "choice", of: ["today", "tomorrow", "due", "week"], about: "when to be asked again; tomorrow if left out" } },
|
|
@@ -258,15 +286,18 @@ export const COMMANDS = [
|
|
|
258
286
|
} },
|
|
259
287
|
{ noun: "message", verb: "issue", target: { name: "message", kind: "message" }, flags: { to: { kind: "identity", need: true } }, about: "Issues a channel message as an instruction to someone.",
|
|
260
288
|
run: (c, a) => c.post("/api/issuances", { zulip_message_id: a.message, issued_to: a.to }) },
|
|
261
|
-
{ noun: "message", verb: "send", flags: { channel: { kind: "channel" }, task: { kind: "task", about: "
|
|
289
|
+
{ noun: "message", verb: "send", flags: { channel: { kind: "channel" }, task: { kind: "task", about: "a reply to the task's message, in its channel" }, text: { ...TEXT, need: false }, "reply-to": { kind: "message", about: "a message of the same place to answer" },
|
|
262
290
|
mention: { kind: "identity", many: true, about: "someone who reads the channel" }, "mention-all": { kind: "bool", about: "everyone who reads the channel" }, path: { kind: "path", many: true, about: "a photo or file on this computer; each is kept as a document" } },
|
|
263
|
-
about: "Sends a message to a channel, or to a task's
|
|
291
|
+
about: "Sends a message to a channel, or a reply to a task's message where it is talked about. A mention is only ever a flag: @Name in the text reaches nobody. Files sent with --path are kept as documents, the words the last one's caption.",
|
|
264
292
|
async run(c, a) {
|
|
265
293
|
if (!a.channel === !a.task) refuse("say where: --channel or --task");
|
|
266
294
|
if (!a.text && !a.path) refuse("say something: --text, or --path for a file");
|
|
267
295
|
const uploads = a.path && (await Promise.all(a.path.map((p) => c.upload(p)))).map((u) => ({ path: u.path, name: u.name }));
|
|
268
296
|
return c.post("/api/messages", { channel: a.channel, task: a.task, text: a.text, reply_to: a["reply-to"], mention: a.mention, mention_all: a["mention-all"], uploads });
|
|
269
297
|
} },
|
|
298
|
+
{ noun: "message", verb: "forward", target: { name: "message", kind: "message" }, flags: { channel: { kind: "channel" }, direct: { kind: "identity" }, "dry-run": { ...DRY, about: "who would newly read it" } },
|
|
299
|
+
about: "Sends a message on, marked with who wrote it and the day, never where; a file as itself.",
|
|
300
|
+
run: (c, a) => c.post(`/api/messages/${a.message}/forward`, { channel: a.channel, direct: a.direct, dry_run: a["dry-run"] }) },
|
|
270
301
|
{ noun: "message", verb: "edit", target: { name: "message", kind: "message" }, flags: { text: TEXT }, about: "Changes the words of a message you sent; Zulip keeps every earlier version.",
|
|
271
302
|
run: (c, a) => c.patch(`/api/messages/${a.message}`, { text: a.text }) },
|
|
272
303
|
{ noun: "message", verb: "delete", target: { name: "message", kind: "message" }, flags: {}, about: "Deletes a channel message, leaving a tombstone where it stood: staff only.",
|
|
@@ -275,7 +306,19 @@ export const COMMANDS = [
|
|
|
275
306
|
run: (c, a) => c.post(`/api/messages/${a.message}/reactions`, { emoji: a.emoji }) },
|
|
276
307
|
{ noun: "message", verb: "unreact", target: { name: "message", kind: "message" }, flags: { emoji: EMOJI }, about: "Takes your reaction back.",
|
|
277
308
|
run: (c, a) => c.del(`/api/messages/${a.message}/reactions`, { emoji: a.emoji }) },
|
|
278
|
-
|
|
309
|
+
// A pinned message (CONTEXT.md Pinned message, #146): kept at the top of its conversation, for everyone in it. Given
|
|
310
|
+
// --conversation, the id is one of the assistant's answers there, as conversation show lists them.
|
|
311
|
+
...["pin", "unpin"].map((verb) => ({ noun: "message", verb, target: { name: "message", kind: "message" }, flags: { conversation: { kind: "id", about: "an answer in this conversation of yours" } },
|
|
312
|
+
about: verb === "pin" ? "Pins a message for everyone in its conversation. In a Project, staff pin; 50 at most." : "Takes a message's pin off.",
|
|
313
|
+
run: (c, a) => c[verb === "pin" ? "post" : "del"]("/api/pins", a.conversation ? { conversation: a.conversation, turn: a.message } : { message: a.message }) })),
|
|
314
|
+
{ noun: "pinned-message", verb: "list", flags: { channel: { kind: "channel" }, direct: { kind: "identity" }, conversation: { kind: "id" } },
|
|
315
|
+
about: "A channel's, Direct's or conversation's pinned messages, newest pinned first.",
|
|
316
|
+
run: async (c, a) => {
|
|
317
|
+
const where = ["channel", "direct", "conversation"].filter((k) => a[k]);
|
|
318
|
+
if (where.length !== 1) refuse("say where: --channel, --direct or --conversation");
|
|
319
|
+
return listed((await c.get(`/api/pins${qs({ [where[0]]: a[where[0]] })}`)).pins);
|
|
320
|
+
} },
|
|
321
|
+
{ noun: "direct", verb: "send", target: { name: "identity", kind: "identity" }, flags: { text: TEXT }, about: "Writes to someone in your Direct with them, or to yourself as me. It passes through and is kept nowhere.",
|
|
279
322
|
run: (c, a) => c.post("/api/messages", { direct: a.identity, text: a.text }) },
|
|
280
323
|
{ noun: "channel", verb: "mark-read", target: { name: "channel", kind: "channel" }, flags: { through: { kind: "message", about: "up to and including this message" } }, about: "Read, on purpose: tells its senders their messages were read.",
|
|
281
324
|
run: (c, a) => c.post("/api/messages/read", { channel: a.channel, through: a.through }) },
|
|
@@ -388,8 +431,8 @@ export const COMMANDS = [
|
|
|
388
431
|
{ noun: "identity", verb: "rotate-key", target: { name: "identity", kind: "identity" }, flags: { "dry-run": { ...DRY, about: "every firm they belong to, and every tool of theirs that would disconnect" } },
|
|
389
432
|
about: "Signs a person out everywhere: their phone signs in again, and every tool of theirs is disconnected.", run: (c, a) => c.post(`/api/identities/${a.identity}/rotate-key`, { dry_run: a["dry-run"] }) },
|
|
390
433
|
{ noun: "identity", verb: "dormant", target: { name: "identity", kind: "identity" }, flags: { in: { kind: "container", about: "where to look for their name" }, "dry-run": { ...DRY, about: "every firm they belong to, and every tool of theirs that would disconnect" } },
|
|
391
|
-
about: "Puts a person to sleep, as after their number changed hands: every membership ends, and every tool of theirs is disconnected.", run: (c, a) => c.post(`/api/identities/${a.identity}/dormant`, { dry_run: a["dry-run"] }) },
|
|
392
|
-
{ noun: "identity", verb: "reactivate", target: { name: "identity", kind: "identity" }, flags: {}, about: "Brings a person put to sleep back; an admin names them on containers again.",
|
|
434
|
+
about: "Puts a person to sleep, as after their number changed hands: every membership ends, and every tool of theirs is disconnected. Centric's Operators only, by the id a firm's admin gives them from identity show; an admin revokes the membership.", run: (c, a) => c.post(`/api/identities/${a.identity}/dormant`, { dry_run: a["dry-run"] }) },
|
|
435
|
+
{ noun: "identity", verb: "reactivate", target: { name: "identity", kind: "identity" }, flags: {}, about: "Brings a person put to sleep back; an admin names them on containers again. Centric's Operators only.",
|
|
393
436
|
run: (c, a) => c.post(`/api/identities/${a.identity}/reactivate`) },
|
|
394
437
|
{ noun: "access-link", verb: "make", flags: { in: { ...IN, kind: "project" }, phone: { kind: "phone", need: true }, designation: { kind: "words", need: true }, task: { kind: "task", about: "an ask's task, shown when the link opens" } },
|
|
395
438
|
about: "Makes an access link for someone with no account: what externals may see, and one reply. Staff only.",
|
|
@@ -423,9 +466,9 @@ export const COMMANDS = [
|
|
|
423
466
|
{ noun: "assistant", verb: "question", target: { name: "text", kind: "words" }, flags: { conversation: { kind: "id", about: "to go on from one" }, focus: { kind: "focus", about: "a Project, Company or channel for a new conversation to keep to" } },
|
|
424
467
|
about: "Asks Centric's assistant, which answers from what you can see. Only while your own switch is on; it counts against the day's share.",
|
|
425
468
|
run: (c, a) => c.post("/api/assist", { q: a.text, conversation: a.conversation, focus: a.focus }) },
|
|
426
|
-
{ noun: "assistant", verb: "correct", target: { name: "text", kind: "words" }, flags: {
|
|
469
|
+
{ noun: "assistant", verb: "correct", target: { name: "text", kind: "words" }, flags: {},
|
|
427
470
|
about: "The assistant puts a message's spelling, grammar and punctuation right; it is still yours to send.",
|
|
428
|
-
run: (c, a) => c.post("/api/assist/correct", { text: a.text
|
|
471
|
+
run: (c, a) => c.post("/api/assist/correct", { text: a.text }) },
|
|
429
472
|
{ noun: "conversation", verb: "delete", target: { name: "conversation", kind: "id" }, flags: {}, about: "Deletes a conversation with the assistant outright; it leaves no tombstone.",
|
|
430
473
|
run: (c, a) => c.del(`/api/assistant/conversations/${a.conversation}`) },
|
|
431
474
|
|
package/package.json
CHANGED