lattris 0.1.3 → 0.2.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.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/dist/build.json +4 -1
  3. package/dist/datasets/capture.js +118 -0
  4. package/dist/datasets/page.js +64 -0
  5. package/dist/datasets/pieces.js +78 -0
  6. package/dist/dispatch-form.js +181 -0
  7. package/dist/dispatch-words.js +137 -0
  8. package/dist/dispatch.js +67 -0
  9. package/dist/entry/session.js +219 -0
  10. package/dist/entry/set.js +130 -0
  11. package/dist/entry/setup-boot.js +151 -0
  12. package/dist/entry/setup.js +145 -0
  13. package/dist/entry/use.js +127 -0
  14. package/dist/help.js +65 -0
  15. package/dist/main.js +143 -6877
  16. package/dist/memory/capture-common.js +67 -0
  17. package/dist/memory/capture-help.js +186 -0
  18. package/dist/memory/capture-note.js +190 -0
  19. package/dist/memory/capture-session.js +166 -0
  20. package/dist/memory/delete.js +88 -0
  21. package/dist/memory/erase.js +67 -0
  22. package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
  23. package/dist/memory/get-help.js +63 -0
  24. package/dist/memory/get.js +213 -0
  25. package/dist/memory/history.js +134 -0
  26. package/dist/memory/recording-update.js +128 -0
  27. package/dist/memory/recording.js +249 -0
  28. package/dist/memory/resend.js +82 -0
  29. package/dist/memory/restore.js +73 -0
  30. package/dist/memory/save-session-chain.js +59 -0
  31. package/dist/memory/transcript.js +85 -0
  32. package/dist/memory/update-help.js +126 -0
  33. package/dist/memory/update.js +220 -0
  34. package/dist/memory/views.js +138 -0
  35. package/dist/nouns/consent.js +29 -0
  36. package/dist/nouns/contact.js +142 -0
  37. package/dist/nouns/datasets.js +109 -0
  38. package/dist/nouns/edges.js +106 -0
  39. package/dist/nouns/form.js +2 -0
  40. package/dist/nouns/index.js +34 -0
  41. package/dist/nouns/invite.js +98 -0
  42. package/dist/nouns/questions.js +116 -0
  43. package/dist/nouns/share.js +105 -0
  44. package/dist/nouns/words.js +76 -0
  45. package/dist/registry/datasets.js +15 -0
  46. package/dist/registry/entry.js +51 -0
  47. package/dist/registry/index.js +17 -0
  48. package/dist/registry/memory.js +110 -0
  49. package/dist/render/datasets.js +77 -0
  50. package/dist/render/door.js +39 -0
  51. package/dist/render/output.js +19 -0
  52. package/dist/render/prompt.js +4 -0
  53. package/dist/render/rows.js +49 -0
  54. package/dist/render/text.js +21 -0
  55. package/dist/request/access-token.js +67 -0
  56. package/dist/request/api-fetch.js +76 -0
  57. package/dist/request/callback.js +86 -0
  58. package/dist/request/door.js +74 -0
  59. package/dist/request/flags.js +38 -0
  60. package/dist/request/io.js +2 -0
  61. package/dist/request/paste-and-device.js +99 -0
  62. package/dist/request/stdin.js +22 -0
  63. package/dist/request/token.js +66 -0
  64. package/dist/request/upload.js +6 -0
  65. package/dist/request/version-check.js +79 -0
  66. package/dist/store/chains.js +25 -0
  67. package/dist/store/drafts.js +48 -0
  68. package/dist/store/environment.js +57 -0
  69. package/dist/store/exit.js +13 -0
  70. package/dist/store/io.js +2 -0
  71. package/dist/store/json-file.js +9 -0
  72. package/dist/store/session-home.js +189 -0
  73. package/dist/store/session-lock.js +169 -0
  74. package/dist/store/session.js +70 -0
  75. package/dist/store/write-identity.js +153 -0
  76. package/dist/version.js +16 -0
  77. package/package.json +4 -5
  78. package/dist/save-session-chain.js +0 -136
package/README.md CHANGED
@@ -41,13 +41,13 @@ sign you up.
41
41
  ## What it does
42
42
 
43
43
  ```
44
- lattris capture write a note or start a session
44
+ lattris capture write a note, a session or a recording, or rows of a dataset
45
45
  lattris note a single note
46
46
  lattris session a session and its turns
47
47
  lattris recording a recording and its transcript
48
48
  lattris memory read and manage blocks
49
49
  lattris link connect two blocks
50
- lattris get read a block
50
+ lattris get read your blocks, questions and invites, and your datasets' rows
51
51
  lattris ask ask a question of a tree
52
52
  lattris invite invite someone
53
53
  lattris contact manage contacts
package/dist/build.json CHANGED
@@ -1 +1,4 @@
1
- {"commit":"cecf7e60eb450bce092dc992155ff77002fc1379","builtAt":"2026-09-15T22:27:41Z"}
1
+ {
2
+ "commit": "8b66c9fde9fb6cd5cf716c08fa0e1ed1234c0651",
3
+ "builtAt": "2026-09-27T23:55:25.807Z"
4
+ }
@@ -0,0 +1,118 @@
1
+ import { gzipSync } from 'node:zlib';
2
+ import { formatCaptured } from "../render/datasets.js";
3
+ import { emit } from "../render/output.js";
4
+ import { sanitizeForTerminal } from "../render/text.js";
5
+ import { signedIn } from "../request/access-token.js";
6
+ import { refusalOf } from "../request/api-fetch.js";
7
+ import { parseArgs } from "../request/flags.js";
8
+ import { EXIT, refusal } from "../store/exit.js";
9
+ import { LINES_PER_PIECE, NO_COUNTS, REFUSALS_NAMED, isCounts, linesOf, outcomeOf, piecesOf, withPiece } from "./pieces.js";
10
+ const counted = (n) => new Intl.NumberFormat('en-US').format(n);
11
+ export const CAPTURE_ROWS_HELP = `capture rows — save rows into one of your datasets.
12
+
13
+ Usage:
14
+ lattris capture rows <name> < rows.ndjson
15
+ … | lattris capture rows <name>
16
+
17
+ A line is one row: one JSON object on one line, its fields named as the dataset
18
+ names them. The rows arrive on stdin, newline-delimited JSON, never as an
19
+ argument. Blank lines are skipped, and still counted as lines.
20
+
21
+ lattris get datasets the datasets the API takes
22
+ lattris get dataset <name> GET /datasets/{name}: its fields, their types and
23
+ bounds, its identity, and an example line to copy
24
+
25
+ A re-send replaces. A row is known by the fields the dataset names as its
26
+ identity: a line with the identity of a row you hold replaces that row, and
27
+ never adds a second.
28
+
29
+ The lines go out as they were read, never parsed or rewritten: gzipped, in
30
+ pieces of at most ${counted(LINES_PER_PIECE)} lines, one request after another. One answer comes
31
+ back for the whole input:
32
+
33
+ on a pipe the API's capture body, summed over the pieces:
34
+ {"sent":…,"added":…,"replaced":…,"refused":…,"refusals":[…]}
35
+ at a terminal one summary line, then each refusal named
36
+
37
+ A refusal names its line in the whole input, blank lines counted, with its code
38
+ and its reason. The first ${counted(REFUSALS_NAMED)} are named, as the API names them; "refused"
39
+ counts every one.
40
+
41
+ Exit 0 when every line was saved, 1 when any line was refused.
42
+
43
+ If a piece fails (a 5xx, a lost connection, a lost answer), the capture stops
44
+ there, exit 1: the counts cover the pieces saved before it, and nothing after
45
+ it is sent. Unlike a note, nothing is kept to resend and no key rides the
46
+ request: send the same input again. Rows already saved are replaced by their
47
+ identity, never duplicated.
48
+
49
+ LATTRIS_SESSION_ID is not needed: rows are not notes.`;
50
+ const CAPTURE_ROWS_USAGE = 'Usage: … | lattris capture rows <name>';
51
+ const stopNote = (piece) => {
52
+ const them = piece.lines > 1 ? 'them' : 'it';
53
+ return (`Stopped at ${linesOf(piece)}: nothing confirmed ${them}, and no line after ${them} was sent. ` +
54
+ (piece.first > 1 ? `The counts cover lines 1–${piece.first - 1}. ` : '') +
55
+ 'Sending the same input again is safe: rows are replaced by their identity, never duplicated.\n');
56
+ };
57
+ const report = (io, name, total) => emit(io, total, JSON.stringify(total), (counts) => formatCaptured(name, counts));
58
+ const lostAnswer = (piece, why) => refusal(`The answer to ${linesOf(piece)} was lost (${why}).\n`, EXIT.GENERIC);
59
+ export const captureRows = async (cfg, io, rest) => {
60
+ if (io.stdin.isTTY) {
61
+ io.stdout.write(`${CAPTURE_ROWS_HELP}\n`);
62
+ return;
63
+ }
64
+ const { positionals, unknown, notAnId } = parseArgs({}, rest, [0]);
65
+ if (unknown)
66
+ return refusal(`Unknown flag: ${unknown}\n`);
67
+ if (notAnId)
68
+ return refusal(notAnId);
69
+ if (positionals.length !== 1)
70
+ return refusal(`${CAPTURE_ROWS_USAGE}\n`);
71
+ const name = positionals[0];
72
+ let accessToken = await signedIn(io, cfg);
73
+ if (typeof accessToken !== 'string')
74
+ return accessToken;
75
+ const url = `${cfg.apiUrl}/datasets/${encodeURIComponent(name)}/rows`;
76
+ let total = NO_COUNTS;
77
+ const stopped = (piece, why) => {
78
+ report(io, name, total);
79
+ return { ...why, message: `${why.message}${stopNote(piece)}` };
80
+ };
81
+ for await (const piece of piecesOf(io.stdin.stream, LINES_PER_PIECE)) {
82
+ if (piece.first > 1) {
83
+ accessToken = await signedIn(io, cfg);
84
+ if (typeof accessToken !== 'string')
85
+ return stopped(piece, accessToken);
86
+ }
87
+ let result;
88
+ try {
89
+ result = await io.api(accessToken, url, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/x-ndjson', 'Content-Encoding': 'gzip' },
92
+ body: gzipSync(piece.bytes)
93
+ });
94
+ }
95
+ catch (e) {
96
+ const why = sanitizeForTerminal(e instanceof Error ? e.message : String(e));
97
+ return stopped(piece, lostAnswer(piece, why));
98
+ }
99
+ if (result.ok && isCounts(result.data)) {
100
+ total = withPiece(total, result.data, piece.first);
101
+ continue;
102
+ }
103
+ if (result.ok)
104
+ return stopped(piece, lostAnswer(piece, 'it carried no counts'));
105
+ const outcome = outcomeOf(result.status, result.body);
106
+ if (outcome === 'counts') {
107
+ total = withPiece(total, result.body, piece.first);
108
+ continue;
109
+ }
110
+ if (outcome === 'refused' && piece.first === 1)
111
+ return refusalOf(result);
112
+ return stopped(piece, refusalOf(result));
113
+ }
114
+ report(io, name, total);
115
+ if (total.refused > 0)
116
+ return refusal('', EXIT.GENERIC);
117
+ };
118
+ //# sourceMappingURL=capture.js.map
@@ -0,0 +1,64 @@
1
+ import { formatRowPage } from "../render/datasets.js";
2
+ import { emit } from "../render/output.js";
3
+ import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
4
+ import { signedIn } from "../request/access-token.js";
5
+ import { refusalOf } from "../request/api-fetch.js";
6
+ import { parseArgs } from "../request/flags.js";
7
+ import { refusal } from "../store/exit.js";
8
+ const GET_ROWS_USAGE = 'Usage: lattris get rows <name> [field=value …] [--from <time>] [--to <time>] [--limit <n>] [--cursor <token>]';
9
+ const PAGE_FLAGS = {
10
+ '--from': 'from',
11
+ '--to': 'to',
12
+ '--limit': 'limit',
13
+ '--cursor': 'cursor'
14
+ };
15
+ export const filtersOf = (words) => {
16
+ const filters = [];
17
+ for (const word of words) {
18
+ const at = word.indexOf('=');
19
+ if (at === -1)
20
+ return { notAFilter: word };
21
+ filters.push([word.slice(0, at), word.slice(at + 1)]);
22
+ }
23
+ return filters;
24
+ };
25
+ export const getRows = async (cfg, io, rest) => {
26
+ const { flags, positionals, unknown, notAnId } = parseArgs(PAGE_FLAGS, rest, [0]);
27
+ if (unknown)
28
+ return refusal(`Unknown flag: ${unknown}\n`);
29
+ if (notAnId)
30
+ return refusal(notAnId);
31
+ const [name, ...words] = positionals;
32
+ if (name === undefined)
33
+ return refusal(`${GET_ROWS_USAGE}\n`);
34
+ const filters = filtersOf(words);
35
+ if (!Array.isArray(filters)) {
36
+ return refusal(`A filter is field=value, and ${sanitizeForTerminal(filters.notAFilter)} has no =.\n${GET_ROWS_USAGE}\n`);
37
+ }
38
+ const accessToken = await signedIn(io, cfg);
39
+ if (typeof accessToken !== 'string')
40
+ return accessToken;
41
+ const url = new URL(`${cfg.apiUrl}/datasets/${encodeURIComponent(name)}/rows`);
42
+ for (const [field, value] of filters)
43
+ url.searchParams.append(field, value);
44
+ for (const key of Object.values(PAGE_FLAGS)) {
45
+ const value = flags[key];
46
+ if (value !== undefined)
47
+ url.searchParams.append(key, value);
48
+ }
49
+ const result = await io.api(accessToken, url);
50
+ if (!result.ok)
51
+ return refusalOf(result);
52
+ emit(io, result.data, result.rawText, formatRowPage);
53
+ const next = result.data.nextCursor;
54
+ if (typeof next === 'string' && io.stdout.isTTY) {
55
+ const again = ['get', 'rows', name, ...words];
56
+ for (const [flag, key] of Object.entries(PAGE_FLAGS)) {
57
+ const value = flags[key];
58
+ if (key !== 'cursor' && value !== undefined)
59
+ again.push(flag, value);
60
+ }
61
+ io.stderr.write(`More: ${renderRunHint([...again, '--cursor', next])}\n`);
62
+ }
63
+ };
64
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1,78 @@
1
+ export const LINES_PER_PIECE = 5000;
2
+ export const REFUSALS_NAMED = 1000;
3
+ const NEWLINE = 0x0a;
4
+ const bytesOf = (chunk) => typeof chunk === 'string'
5
+ ? Buffer.from(chunk, 'utf8')
6
+ : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
7
+ export async function* piecesOf(input, size) {
8
+ let held = [];
9
+ let heldLines = 0;
10
+ let first = 1;
11
+ let yielded = false;
12
+ for await (const chunk of input) {
13
+ const bytes = bytesOf(chunk);
14
+ let start = 0;
15
+ for (let at = bytes.indexOf(NEWLINE); at !== -1; at = bytes.indexOf(NEWLINE, at + 1)) {
16
+ heldLines += 1;
17
+ if (heldLines < size)
18
+ continue;
19
+ held.push(bytes.subarray(start, at + 1));
20
+ yield { first, lines: heldLines, bytes: Buffer.concat(held) };
21
+ yielded = true;
22
+ first += heldLines;
23
+ held = [];
24
+ heldLines = 0;
25
+ start = at + 1;
26
+ }
27
+ if (start < bytes.length)
28
+ held.push(bytes.subarray(start));
29
+ }
30
+ const rest = Buffer.concat(held);
31
+ if (rest.length === 0 && yielded)
32
+ return;
33
+ const unended = rest.length > 0 && rest[rest.length - 1] !== NEWLINE ? 1 : 0;
34
+ yield { first, lines: heldLines + unended, bytes: rest };
35
+ }
36
+ export const renumbered = (refusal, first) => {
37
+ if (first === 1 || typeof refusal.line !== 'number')
38
+ return refusal;
39
+ const line = refusal.line + first - 1;
40
+ const said = `Line ${refusal.line}`;
41
+ const message = typeof refusal.message === 'string' &&
42
+ refusal.message.startsWith(said) &&
43
+ !/^\d/.test(refusal.message.slice(said.length))
44
+ ? `Line ${line}${refusal.message.slice(said.length)}`
45
+ : refusal.message;
46
+ return { ...refusal, line, message };
47
+ };
48
+ export const NO_COUNTS = {
49
+ sent: 0,
50
+ added: 0,
51
+ replaced: 0,
52
+ refused: 0,
53
+ refusals: []
54
+ };
55
+ export const withPiece = (total, answer, first) => ({
56
+ sent: total.sent + answer.sent,
57
+ added: total.added + answer.added,
58
+ replaced: total.replaced + answer.replaced,
59
+ refused: total.refused + answer.refused,
60
+ refusals: [
61
+ ...total.refusals,
62
+ ...answer.refusals
63
+ .slice(0, Math.max(0, REFUSALS_NAMED - total.refusals.length))
64
+ .map((r) => renumbered(r, first))
65
+ ]
66
+ });
67
+ export const isCounts = (body) => {
68
+ if (typeof body !== 'object' || body === null)
69
+ return false;
70
+ const b = body;
71
+ return (['sent', 'added', 'replaced', 'refused'].every((k) => typeof b[k] === 'number') &&
72
+ Array.isArray(b.refusals));
73
+ };
74
+ export const outcomeOf = (status, body) => status === 400 && isCounts(body) ? 'counts' : status >= 500 ? 'failed' : 'refused';
75
+ export const linesOf = (piece) => piece.lines <= 1
76
+ ? `line ${piece.first}`
77
+ : `lines ${piece.first}–${piece.first + piece.lines - 1}`;
78
+ //# sourceMappingURL=pieces.js.map
@@ -0,0 +1,181 @@
1
+ import { audiencesOf } from "./nouns/index.js";
2
+ import { emit } from "./render/output.js";
3
+ import { confirmAction } from "./render/prompt.js";
4
+ import { ambiguousText, formatContact } from "./render/rows.js";
5
+ import { renderRunHint, sanitizeForTerminal } from "./render/text.js";
6
+ import { signedIn } from "./request/access-token.js";
7
+ import { refusalOf } from "./request/api-fetch.js";
8
+ import { EXIT, isRefusal } from "./store/exit.js";
9
+ import { buildBody, describeAudience, fillIn, readWords, refuse, resolveContact, urlOf } from "./dispatch-words.js";
10
+ export const runForm = async (cfg, io, form, rest) => {
11
+ const typed = readWords(form, rest);
12
+ if (isRefusal(typed))
13
+ return typed;
14
+ const steps = form.steps ?? {};
15
+ const context = {
16
+ positionals: typed.positionals,
17
+ flags: typed.flags,
18
+ on: typed.on,
19
+ webUrl: cfg.webUrl
20
+ };
21
+ let selector;
22
+ if (steps.contact) {
23
+ for (const source of steps.contact.from) {
24
+ const value = source === 'p0' ? typed.positionals[0] : typed.flags[source.slice(5)];
25
+ if (value !== undefined) {
26
+ selector = value;
27
+ break;
28
+ }
29
+ }
30
+ if (steps.contact.required && (selector === undefined || selector.trim().length === 0)) {
31
+ return refuse(steps.contact.required);
32
+ }
33
+ }
34
+ if (steps.requireFlag && typed.flags[steps.requireFlag.key] === undefined) {
35
+ return refuse(steps.requireFlag.message);
36
+ }
37
+ if (steps.pipedNeedsYes && !io.stdin.isTTY && !typed.on.has('--yes')) {
38
+ return refuse(steps.pipedNeedsYes.message);
39
+ }
40
+ let stdin;
41
+ if (form.stdinBody) {
42
+ if (io.stdin.isTTY)
43
+ return refuse(form.stdinBody.atTty);
44
+ stdin = await io.stdin.read();
45
+ if (stdin.trim() === '')
46
+ return refuse(form.stdinBody.empty);
47
+ }
48
+ let body = buildBody(form, typed, stdin, context);
49
+ if (body && form.request.emptyBodyMessage && Object.keys(body).length === 0) {
50
+ return refuse(form.request.emptyBodyMessage);
51
+ }
52
+ const accessToken = await signedIn(io, cfg);
53
+ if (typeof accessToken !== 'string')
54
+ return accessToken;
55
+ let missingName;
56
+ const wantsPublic = steps.contact?.publicWord !== undefined && selector === steps.contact.publicWord;
57
+ if (steps.contact && selector !== undefined && !wantsPublic) {
58
+ const contacts = await io.api(accessToken, `${cfg.apiUrl}/contacts`);
59
+ if (!contacts.ok)
60
+ return refusalOf(contacts);
61
+ const shown = sanitizeForTerminal(selector);
62
+ const resolution = resolveContact(contacts.data, selector.trim());
63
+ if (resolution.kind === 'ambiguous') {
64
+ const list = resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n');
65
+ return refuse(`${shown} matches more than one of your contacts:\n${list}\nUse a contact id instead.\n`);
66
+ }
67
+ if (resolution.kind === 'missing-id' ||
68
+ (resolution.kind === 'missing-name' && !steps.contact.createIfMissing)) {
69
+ return refuse(`No contact ${shown} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`, EXIT.GENERIC);
70
+ }
71
+ if (resolution.kind === 'found') {
72
+ context.contact = resolution.contact;
73
+ if (steps.contact.mustBePerson && resolution.contact.type !== 'person') {
74
+ return refuse(fillIn(steps.contact.mustBePerson, {
75
+ name: sanitizeForTerminal(resolution.contact.displayName)
76
+ }));
77
+ }
78
+ if (steps.contact.mustBeLinked &&
79
+ (resolution.contact.type !== 'person' || resolution.contact.claimedPersonId == null)) {
80
+ return refuse(steps.contact.mustBeLinked);
81
+ }
82
+ }
83
+ else {
84
+ missingName = resolution.displayName;
85
+ }
86
+ }
87
+ if (steps.memory === 'always' || (steps.memory === 'at-tty' && io.stdin.isTTY)) {
88
+ const fetched = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(typed.positionals[0])}`);
89
+ if (!fetched.ok)
90
+ return refusalOf(fetched, ambiguousText(fetched.body));
91
+ context.memory = fetched.data;
92
+ }
93
+ if (steps.noAudienceReport && typed.flags[steps.noAudienceReport.key] === undefined) {
94
+ const title = sanitizeForTerminal(context.memory?.title ?? '(untitled)');
95
+ return refuse(steps.noAudienceReport.first +
96
+ fillIn(steps.noAudienceReport.second, {
97
+ title,
98
+ 'memory.id': context.memory?.id ?? '',
99
+ audiences: context.memory ? audiencesOf(context.memory) : ''
100
+ }));
101
+ }
102
+ if (steps.confirm && io.stdin.isTTY) {
103
+ const name = sanitizeForTerminal(context.contact?.displayName ?? missingName ?? '');
104
+ const question = fillIn(missingName !== undefined && steps.confirm.whenMissing
105
+ ? steps.confirm.whenMissing
106
+ : steps.confirm.question, {
107
+ ...Object.fromEntries(typed.positionals.map((p, i) => [`p${i}`, sanitizeForTerminal(p)])),
108
+ name,
109
+ title: sanitizeForTerminal(context.memory?.title ?? '(untitled)'),
110
+ 'memory.id': context.memory?.id ?? '',
111
+ audience: context.contact?.displayName ?? describeAudience(selector ?? '')
112
+ });
113
+ const answer = await confirmAction(io, question);
114
+ if (answer === 'interrupted' && steps.confirm.onInterrupted) {
115
+ return refuse(steps.confirm.onInterrupted.text, steps.confirm.onInterrupted.exit);
116
+ }
117
+ if (answer !== 'yes') {
118
+ if (steps.confirm.onNo.to === 'stdout')
119
+ io.stdout.write(steps.confirm.onNo.text);
120
+ else
121
+ io.stderr.write(steps.confirm.onNo.text);
122
+ return;
123
+ }
124
+ }
125
+ if (missingName !== undefined) {
126
+ const created = await io.api(accessToken, `${cfg.apiUrl}/contacts`, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({ displayName: missingName, type: 'person' })
130
+ });
131
+ if (!created.ok)
132
+ return refusalOf(created);
133
+ context.contact = created.data;
134
+ context.createdContact = true;
135
+ body = buildBody(form, typed, stdin, context);
136
+ }
137
+ else if (context.contact !== undefined || wantsPublic) {
138
+ body = buildBody(form, typed, stdin, context);
139
+ }
140
+ const url = urlOf(cfg.apiUrl, form, typed, stdin, context);
141
+ const result = await io.api(accessToken, url, body === undefined
142
+ ? { method: form.request.method }
143
+ : {
144
+ method: form.request.method,
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify(body)
147
+ });
148
+ if (!result.ok) {
149
+ return refusalOf(result, form.ambiguousRefusal ? ambiguousText(result.body) : '');
150
+ }
151
+ const typedView = context;
152
+ if (form.pickFromList) {
153
+ const ref = typed.positionals[0].toLowerCase();
154
+ const rows = result.data;
155
+ const matches = rows.filter((c) => c.id.toLowerCase().startsWith(ref));
156
+ const shown = sanitizeForTerminal(typed.positionals[0]);
157
+ if (matches.length === 0) {
158
+ return refuse(fillIn(form.pickFromList.none, { typed: shown }), EXIT.GENERIC);
159
+ }
160
+ if (matches.length > 1) {
161
+ const list = matches.map((c) => ` ${formatContact(c)}`).join('\n');
162
+ return refuse(fillIn(form.pickFromList.ambiguous, { typed: shown, matches: list }));
163
+ }
164
+ emit(io, matches[0], JSON.stringify(matches[0]), (row) => form.render(row, typedView));
165
+ return;
166
+ }
167
+ if (form.renderTo === 'stderr') {
168
+ io.stderr.write(`${form.render(result.data, typedView)}\n`);
169
+ return;
170
+ }
171
+ if (form.renderAlways) {
172
+ io.stdout.write(`${form.render(result.data, typedView)}\n`);
173
+ return;
174
+ }
175
+ emit(io, result.data, result.rawText, (data) => form.render(data, typedView));
176
+ const next = form.page ? result.data[form.page.cursor] : undefined;
177
+ if (form.page && typeof next === 'string' && io.stdout.isTTY) {
178
+ io.stderr.write(`More: ${renderRunHint([...form.page.hint, '--cursor', next])}\n`);
179
+ }
180
+ };
181
+ //# sourceMappingURL=dispatch-form.js.map
@@ -0,0 +1,137 @@
1
+ import { renderRunHint } from "./render/text.js";
2
+ import { parseArgs } from "./request/flags.js";
3
+ import { EXIT, refusal } from "./store/exit.js";
4
+ import { isUuidKey } from "./store/write-identity.js";
5
+ export const refuse = (message, exit = EXIT.USAGE) => refusal(message, exit);
6
+ export const fillIn = (template, values) => template.replace(/\{([\w.]+)(?::([^}]*))?\}/g, (_m, key, arg) => key === 'hint' ? renderRunHint((arg ?? '').split(' ')) : (values[key] ?? ''));
7
+ export const readWords = (form, rest) => {
8
+ const on = new Set();
9
+ const counts = new Map();
10
+ const words = rest.filter((a) => {
11
+ if (form.switches?.includes(a)) {
12
+ on.add(a);
13
+ counts.set(a, (counts.get(a) ?? 0) + 1);
14
+ return false;
15
+ }
16
+ return true;
17
+ });
18
+ const { flags, positionals, unknown, notAnId } = parseArgs(form.flags ?? {}, words, form.idPositionals);
19
+ if (unknown !== undefined) {
20
+ return refuse(form.unknownFlagIsUsage ? `${form.usage}\n` : `Unknown flag: ${unknown}\n`);
21
+ }
22
+ if (notAnId !== undefined)
23
+ return refuse(notAnId);
24
+ const typed = { positionals, flags: flags, on };
25
+ const tooMany = form.positionalsAtMost ?? form.positionals;
26
+ if (positionals.length < form.positionals ||
27
+ positionals.length > tooMany ||
28
+ [...counts.values()].some((n) => n > 1) ||
29
+ (form.requiredFlags ?? []).some((k) => typed.flags[k] === undefined && !form.missingFlagsMessage) ||
30
+ (form.uuidPositionals ?? []).some((i) => !isUuidKey(positionals[i] ?? ''))) {
31
+ return refuse(`${form.usage}\n`);
32
+ }
33
+ if (form.missingFlagsMessage) {
34
+ const names = Object.entries(form.flags ?? {});
35
+ const missing = (form.requiredFlags ?? [])
36
+ .filter((k) => typed.flags[k] === undefined)
37
+ .map((k) => names.find(([, key]) => key === k)?.[0] ?? k);
38
+ if (missing.length > 0) {
39
+ return refuse(`Missing ${missing.join(', ')}.\n${form.usage}\n`);
40
+ }
41
+ }
42
+ for (const rule of form.uuidPositionalMessages ?? []) {
43
+ if (!isUuidKey(positionals[rule.index] ?? '')) {
44
+ return refuse(fillIn(rule.message, { value: positionals[rule.index] ?? '' }));
45
+ }
46
+ }
47
+ for (const rule of form.contradictions ?? []) {
48
+ const has = (name) => name === 'p0'
49
+ ? positionals.length === 1
50
+ : name.startsWith('--')
51
+ ? on.has(name)
52
+ : typed.flags[name] !== undefined;
53
+ if (has(rule.a) && has(rule.b)) {
54
+ return refuse(rule.message);
55
+ }
56
+ }
57
+ for (const rule of form.uuidFlags ?? []) {
58
+ const value = typed.flags[rule.key];
59
+ if (value !== undefined && !isUuidKey(value)) {
60
+ return refuse(fillIn(rule.message, { value }));
61
+ }
62
+ }
63
+ for (const rule of form.requireWhen ?? []) {
64
+ const value = typed.flags[rule.key];
65
+ if (value !== undefined &&
66
+ rule.values.includes(value) &&
67
+ typed.flags[rule.needs] === undefined) {
68
+ return refuse(fillIn(rule.message, { value }));
69
+ }
70
+ }
71
+ return typed;
72
+ };
73
+ export const valueOf = (source, typed, stdin, context) => {
74
+ if ('flag' in source)
75
+ return typed.flags[source.flag];
76
+ if ('positional' in source)
77
+ return typed.positionals[source.positional];
78
+ if ('stdin' in source)
79
+ return stdin;
80
+ if ('context' in source)
81
+ return context.contact?.id;
82
+ if (!('switch' in source))
83
+ return source.value;
84
+ return typed.on.has(source.switch) ? source.value : source.otherwise;
85
+ };
86
+ export const buildBody = (form, typed, stdin, context) => {
87
+ if (form.request.bodyWithPublic && context.contact === undefined) {
88
+ return form.request.bodyWithPublic;
89
+ }
90
+ if (!form.request.body)
91
+ return undefined;
92
+ const body = {};
93
+ for (const [key, sources] of Object.entries(form.request.body)) {
94
+ for (const source of Array.isArray(sources) ? sources : [sources]) {
95
+ const value = valueOf(source, typed, stdin, context);
96
+ if (value !== undefined)
97
+ body[key] = value;
98
+ }
99
+ }
100
+ return body;
101
+ };
102
+ export const pathOf = (form, typed, context) => {
103
+ const template = form.request.pathWithPublic && context.contact === undefined
104
+ ? form.request.pathWithPublic
105
+ : form.request.path;
106
+ return template
107
+ .replace(/\{(--[a-z-]+):([^}]*)\}/g, (_m, name, text) => typed.on.has(name) ? text : '')
108
+ .replace(/\{p(\d+)\}/g, (_m, i) => encodeURIComponent(typed.positionals[Number(i)] ?? ''))
109
+ .replace('{memory.id}', encodeURIComponent(context.memory?.id ?? typed.positionals[0] ?? ''))
110
+ .replace('{contact.claimedPersonId}', () => encodeURIComponent(String(context.contact?.claimedPersonId ?? '')));
111
+ };
112
+ export const urlOf = (apiUrl, form, typed, stdin, context) => {
113
+ const url = new URL(`${apiUrl}${pathOf(form, typed, context)}`);
114
+ for (const [key, sources] of Object.entries(form.request.query ?? {})) {
115
+ for (const source of Array.isArray(sources) ? sources : [sources]) {
116
+ const value = valueOf(source, typed, stdin, context);
117
+ if (value !== undefined)
118
+ url.searchParams.set(key, String(value));
119
+ }
120
+ }
121
+ return url;
122
+ };
123
+ export const resolveContact = (contacts, selector) => {
124
+ if (isUuidKey(selector)) {
125
+ const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
126
+ return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
127
+ }
128
+ const folded = selector.toLowerCase();
129
+ const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
130
+ if (matches.length === 1)
131
+ return { kind: 'found', contact: matches[0] };
132
+ if (matches.length > 1)
133
+ return { kind: 'ambiguous', matches };
134
+ return { kind: 'missing-name', displayName: selector };
135
+ };
136
+ export const describeAudience = (audience) => audience === 'public' ? 'public — anyone, including people without accounts' : audience;
137
+ //# sourceMappingURL=dispatch-words.js.map
@@ -0,0 +1,67 @@
1
+ import { VERB_ALIASES } from "./nouns/words.js";
2
+ import { renderRefusal } from "./render/output.js";
3
+ import { sanitizeForTerminal } from "./render/text.js";
4
+ import { HELP_TOKENS } from "./request/flags.js";
5
+ import { fillIn, refuse } from "./dispatch-words.js";
6
+ import { runForm } from "./dispatch-form.js";
7
+ const helpOf = (help, args) => typeof help === 'string' ? help : help(args);
8
+ const runVerb = async (cfg, io, verb, rest) => {
9
+ const refused = 'form' in verb ? await runForm(cfg, io, verb.form, rest) : await verb.handler(cfg, io, rest);
10
+ if (refused)
11
+ renderRefusal(io, refused);
12
+ };
13
+ const wordsOf = (n) => [
14
+ n.words.singular,
15
+ ...(n.words.aliases ?? [])
16
+ ];
17
+ export const dispatch = async (cfg, io, registry, argv) => {
18
+ const typedWord = argv[0];
19
+ if (typedWord === undefined)
20
+ return false;
21
+ const word = VERB_ALIASES[typedWord] ?? typedWord;
22
+ const rest = argv.slice(1);
23
+ const nounFirst = registry.nouns.find((n) => n.grammar === 'noun-first' && wordsOf(n).includes(word));
24
+ if (nounFirst) {
25
+ const first = rest[0];
26
+ if (first === undefined || rest.some((t) => HELP_TOKENS.has(t))) {
27
+ io.stdout.write(`${nounFirst.help}\n`);
28
+ return true;
29
+ }
30
+ const verb = nounFirst.verbs.find((v) => v.verb === first);
31
+ if (!verb) {
32
+ renderRefusal(io, refuse(fillIn(nounFirst.unknownVerb ?? nounFirst.help, { verb: sanitizeForTerminal(first) })));
33
+ return true;
34
+ }
35
+ await runVerb(cfg, io, verb, rest.slice(1));
36
+ return true;
37
+ }
38
+ const verbWord = registry.verbs[word];
39
+ const candidates = registry.nouns
40
+ .filter((n) => n.grammar !== 'noun-first')
41
+ .flatMap((n) => n.verbs.filter((v) => v.verb === word));
42
+ if (!verbWord || candidates.length === 0)
43
+ return false;
44
+ if (verbWord.help !== undefined &&
45
+ ((verbWord.bareIsHelp && rest.length === 0) || rest.some((t) => HELP_TOKENS.has(t)))) {
46
+ io.stdout.write(`${helpOf(verbWord.help, rest)}\n`);
47
+ return true;
48
+ }
49
+ if (!candidates.some((v) => v.nouns !== undefined)) {
50
+ await runVerb(cfg, io, candidates[0], rest);
51
+ return true;
52
+ }
53
+ const noun = rest[0];
54
+ const chosen = candidates.find((v) => noun !== undefined && v.nouns?.includes(noun));
55
+ if (!chosen) {
56
+ const shown = sanitizeForTerminal(noun ?? '');
57
+ if (verbWord.badNoun?.stdout)
58
+ io.stdout.write(verbWord.badNoun.stdout);
59
+ renderRefusal(io, refuse(fillIn(verbWord.badNoun?.stderr ?? `${helpOf(verbWord.help ?? '', rest)}\n`, {
60
+ noun: shown
61
+ })));
62
+ return true;
63
+ }
64
+ await runVerb(cfg, io, chosen, rest.slice(1));
65
+ return true;
66
+ };
67
+ //# sourceMappingURL=dispatch.js.map