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.
- package/README.md +2 -2
- package/dist/build.json +4 -1
- package/dist/datasets/capture.js +118 -0
- package/dist/datasets/page.js +64 -0
- package/dist/datasets/pieces.js +78 -0
- package/dist/dispatch-form.js +181 -0
- package/dist/dispatch-words.js +137 -0
- package/dist/dispatch.js +67 -0
- package/dist/entry/session.js +219 -0
- package/dist/entry/set.js +130 -0
- package/dist/entry/setup-boot.js +151 -0
- package/dist/entry/setup.js +145 -0
- package/dist/entry/use.js +127 -0
- package/dist/help.js +65 -0
- package/dist/main.js +143 -6877
- package/dist/memory/capture-common.js +67 -0
- package/dist/memory/capture-help.js +186 -0
- package/dist/memory/capture-note.js +190 -0
- package/dist/memory/capture-session.js +166 -0
- package/dist/memory/delete.js +88 -0
- package/dist/memory/erase.js +67 -0
- package/dist/{extract-claude-jsonl.js → memory/extract-claude-jsonl.js} +5 -94
- package/dist/memory/get-help.js +63 -0
- package/dist/memory/get.js +213 -0
- package/dist/memory/history.js +134 -0
- package/dist/memory/recording-update.js +128 -0
- package/dist/memory/recording.js +249 -0
- package/dist/memory/resend.js +82 -0
- package/dist/memory/restore.js +73 -0
- package/dist/memory/save-session-chain.js +59 -0
- package/dist/memory/transcript.js +85 -0
- package/dist/memory/update-help.js +126 -0
- package/dist/memory/update.js +220 -0
- package/dist/memory/views.js +138 -0
- package/dist/nouns/consent.js +29 -0
- package/dist/nouns/contact.js +142 -0
- package/dist/nouns/datasets.js +109 -0
- package/dist/nouns/edges.js +106 -0
- package/dist/nouns/form.js +2 -0
- package/dist/nouns/index.js +34 -0
- package/dist/nouns/invite.js +98 -0
- package/dist/nouns/questions.js +116 -0
- package/dist/nouns/share.js +105 -0
- package/dist/nouns/words.js +76 -0
- package/dist/registry/datasets.js +15 -0
- package/dist/registry/entry.js +51 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/memory.js +110 -0
- package/dist/render/datasets.js +77 -0
- package/dist/render/door.js +39 -0
- package/dist/render/output.js +19 -0
- package/dist/render/prompt.js +4 -0
- package/dist/render/rows.js +49 -0
- package/dist/render/text.js +21 -0
- package/dist/request/access-token.js +67 -0
- package/dist/request/api-fetch.js +76 -0
- package/dist/request/callback.js +86 -0
- package/dist/request/door.js +74 -0
- package/dist/request/flags.js +38 -0
- package/dist/request/io.js +2 -0
- package/dist/request/paste-and-device.js +99 -0
- package/dist/request/stdin.js +22 -0
- package/dist/request/token.js +66 -0
- package/dist/request/upload.js +6 -0
- package/dist/request/version-check.js +79 -0
- package/dist/store/chains.js +25 -0
- package/dist/store/drafts.js +48 -0
- package/dist/store/environment.js +57 -0
- package/dist/store/exit.js +13 -0
- package/dist/store/io.js +2 -0
- package/dist/store/json-file.js +9 -0
- package/dist/store/session-home.js +189 -0
- package/dist/store/session-lock.js +169 -0
- package/dist/store/session.js +70 -0
- package/dist/store/write-identity.js +153 -0
- package/dist/version.js +16 -0
- package/package.json +4 -5
- 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
|
|
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
|
|
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
|
@@ -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
|
package/dist/dispatch.js
ADDED
|
@@ -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
|