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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { emit } from "../render/output.js";
|
|
4
|
+
import { base64url, makePkce, sanitizeForTerminal } from "../render/text.js";
|
|
5
|
+
import { AuthorizationError, openBrowser, waitForCode } from "../request/callback.js";
|
|
6
|
+
import { pollDeviceToken, requestDeviceAuth, waitForPastedCode } from "../request/paste-and-device.js";
|
|
7
|
+
import { authCodeExchangeBody, emailFromIdToken, hostedRedirectUri, loginIdentityGate, postToken, toSession } from "../request/token.js";
|
|
8
|
+
import { EXIT, refusal } from "../store/exit.js";
|
|
9
|
+
import { resolveSessionHome } from "../store/session-home.js";
|
|
10
|
+
import { clearLeftovers } from "../store/session-lock.js";
|
|
11
|
+
import { clearSession, saveSession } from "../store/session.js";
|
|
12
|
+
import { SESSION_ID_DOOR, resolveWriteIdentityDetail } from "../store/write-identity.js";
|
|
13
|
+
import { refusalOf } from "../request/api-fetch.js";
|
|
14
|
+
import { signedIn } from "../request/access-token.js";
|
|
15
|
+
export const formatPerson = (p) => {
|
|
16
|
+
const line = `${p.label} · lattris number ${p.lattrisNo} · lattris.com/by/${p.handle}`;
|
|
17
|
+
if (p.rootBlockId === undefined)
|
|
18
|
+
return line;
|
|
19
|
+
return p.rootBlockId === null
|
|
20
|
+
? `${line} · no root block set`
|
|
21
|
+
: `${line} · root ${sanitizeForTerminal(p.rootBlockId)}`;
|
|
22
|
+
};
|
|
23
|
+
export const loginGate = (args, envName) => {
|
|
24
|
+
args = args.filter((a) => a !== '--device');
|
|
25
|
+
const passwordish = args.find((a) => a.toLowerCase().includes('password'));
|
|
26
|
+
if (passwordish !== undefined && envName === 'prod') {
|
|
27
|
+
const flagOnly = passwordish.split('=')[0];
|
|
28
|
+
return {
|
|
29
|
+
proceed: true,
|
|
30
|
+
message: `prod login requires browser authentication — passwords are never accepted or ` +
|
|
31
|
+
`stored for prod. Ignoring ${flagOnly}; opening the browser (PKCE).\n`
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (args.length > 0) {
|
|
35
|
+
return {
|
|
36
|
+
proceed: false,
|
|
37
|
+
message: 'Usage: lattris login (no arguments — the browser flow signs you in).\n'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { proceed: true };
|
|
41
|
+
};
|
|
42
|
+
export const buildAuthUrl = (cfg, p) => {
|
|
43
|
+
const authUrl = new URL(`${cfg.issuer}/oauth2/auth`);
|
|
44
|
+
authUrl.search = new URLSearchParams({
|
|
45
|
+
response_type: 'code',
|
|
46
|
+
client_id: cfg.clientId,
|
|
47
|
+
redirect_uri: p.redirectUri,
|
|
48
|
+
scope: cfg.scopes,
|
|
49
|
+
state: p.state,
|
|
50
|
+
nonce: p.nonce,
|
|
51
|
+
audience: cfg.audience,
|
|
52
|
+
code_challenge: p.challenge,
|
|
53
|
+
code_challenge_method: 'S256',
|
|
54
|
+
prompt: 'consent'
|
|
55
|
+
}).toString();
|
|
56
|
+
if (cfg.rememberFor !== undefined) {
|
|
57
|
+
authUrl.searchParams.set('remember_for', String(cfg.rememberFor));
|
|
58
|
+
}
|
|
59
|
+
return authUrl;
|
|
60
|
+
};
|
|
61
|
+
const LOOPBACK_TIMEOUT_MESSAGE = 'login window expired — run lattris login again.\n';
|
|
62
|
+
const finishLogin = async (cfg, io, homeDir, token) => {
|
|
63
|
+
const tokenEmail = token.id_token ? emailFromIdToken(token.id_token) : null;
|
|
64
|
+
const identityGate = loginIdentityGate(homeDir, tokenEmail);
|
|
65
|
+
if (!identityGate.save)
|
|
66
|
+
return refusal(identityGate.message ?? '', EXIT.NOAUTH);
|
|
67
|
+
const session = toSession(token, io.store.now());
|
|
68
|
+
if (tokenEmail)
|
|
69
|
+
session.email = tokenEmail;
|
|
70
|
+
clearLeftovers(io.store);
|
|
71
|
+
saveSession(io.store, session);
|
|
72
|
+
let unrefused;
|
|
73
|
+
try {
|
|
74
|
+
const meResult = await io.api(session.accessToken, `${cfg.apiUrl}/person/me`);
|
|
75
|
+
if (meResult.ok) {
|
|
76
|
+
session.personId = meResult.data.id;
|
|
77
|
+
session.handle = meResult.data.handle;
|
|
78
|
+
saveSession(io.store, session);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
unrefused = { ...refusalOf(meResult), exit: EXIT.OK };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
io.stdout.write('Logged in.\n');
|
|
86
|
+
return unrefused;
|
|
87
|
+
};
|
|
88
|
+
const deviceLogin = async (cfg, io, homeDir) => {
|
|
89
|
+
const auth = await requestDeviceAuth(io.network, cfg);
|
|
90
|
+
const where = auth.verification_uri_complete ?? `${auth.verification_uri} and enter ${auth.user_code}`;
|
|
91
|
+
io.stderr.write(`On any device, visit:\n ${where}\n`);
|
|
92
|
+
if (auth.verification_uri_complete) {
|
|
93
|
+
io.stderr.write(`(code: ${auth.user_code})\n`);
|
|
94
|
+
}
|
|
95
|
+
io.stderr.write('Waiting for the sign-in to complete…\n');
|
|
96
|
+
let token;
|
|
97
|
+
try {
|
|
98
|
+
token = await pollDeviceToken({ network: io.network, now: io.store.now }, cfg, auth);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return refusal(`${e instanceof Error ? e.message : 'Sign-in failed.'}\n`, EXIT.NOAUTH);
|
|
102
|
+
}
|
|
103
|
+
return finishLogin(cfg, io, homeDir, token);
|
|
104
|
+
};
|
|
105
|
+
export const LOGIN_HELP = [
|
|
106
|
+
'Usage: lattris login [--device]',
|
|
107
|
+
'',
|
|
108
|
+
'Opens the browser to sign in. On the same device the sign-in completes',
|
|
109
|
+
'itself; from another device, paste the code the browser page shows into',
|
|
110
|
+
'the waiting prompt.',
|
|
111
|
+
'',
|
|
112
|
+
' --device code-based sign-in for a machine with no browser at all:',
|
|
113
|
+
' visit the printed address on any device and enter the code.'
|
|
114
|
+
].join('\n');
|
|
115
|
+
export const WHOAMI_HELP = [
|
|
116
|
+
'Usage: lattris whoami [--session]',
|
|
117
|
+
'',
|
|
118
|
+
' --session the write identity this command would name on a note write',
|
|
119
|
+
' (session and agent), with no network: lines at a terminal,',
|
|
120
|
+
' JSON on a pipe, exit 2 when there is none to name.',
|
|
121
|
+
'',
|
|
122
|
+
'Prints who this machine dir is logged in as (label, lattris number,',
|
|
123
|
+
'address). Exit 3 when not logged in.'
|
|
124
|
+
].join('\n');
|
|
125
|
+
export const login = async (cfg, io, args = []) => {
|
|
126
|
+
const gate = loginGate(args, cfg.env);
|
|
127
|
+
if (!gate.proceed)
|
|
128
|
+
return refusal(gate.message ?? '', EXIT.USAGE);
|
|
129
|
+
if (gate.message)
|
|
130
|
+
io.stderr.write(gate.message);
|
|
131
|
+
const home = resolveSessionHome(io.store);
|
|
132
|
+
if (io.store.files.existsSync(join(home.dir, 'credentials'))) {
|
|
133
|
+
io.stderr.write(`Note: ${join(home.dir, 'credentials')} exists but is ignored — the CLI never uses stored passwords.\n`);
|
|
134
|
+
}
|
|
135
|
+
if (args.includes('--device')) {
|
|
136
|
+
return deviceLogin(cfg, io, home.dir);
|
|
137
|
+
}
|
|
138
|
+
const pkce = makePkce();
|
|
139
|
+
const state = base64url(randomBytes(16));
|
|
140
|
+
const nonce = base64url(randomBytes(16));
|
|
141
|
+
const redirectUri = hostedRedirectUri(cfg);
|
|
142
|
+
const authUrl = buildAuthUrl(cfg, { state, nonce, challenge: pkce.challenge, redirectUri });
|
|
143
|
+
const listener = {};
|
|
144
|
+
const codePromise = waitForCode(cfg.redirectPort, state, undefined, listener);
|
|
145
|
+
const paste = waitForPastedCode(state, io.stdin.stream, io.stderr);
|
|
146
|
+
io.stderr.write('Opening your browser to sign in…\n');
|
|
147
|
+
io.stderr.write(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
148
|
+
io.stderr.write('…or paste the code from the browser page here and press Enter:\n');
|
|
149
|
+
openBrowser(io.store, authUrl.toString());
|
|
150
|
+
let code;
|
|
151
|
+
try {
|
|
152
|
+
code = await Promise.race([codePromise, paste.code]);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
const msg = e instanceof AuthorizationError
|
|
156
|
+
? `authorization error: ${sanitizeForTerminal(e.error)}`
|
|
157
|
+
: e instanceof Error
|
|
158
|
+
? e.message
|
|
159
|
+
: '';
|
|
160
|
+
return refusal(msg.includes('timed out') ? LOOPBACK_TIMEOUT_MESSAGE : `${msg || 'Sign-in failed.'}\n`, EXIT.NOAUTH);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
paste.close();
|
|
164
|
+
listener.close?.();
|
|
165
|
+
}
|
|
166
|
+
const token = await postToken(io.network, cfg, authCodeExchangeBody(cfg, code, pkce.verifier, redirectUri));
|
|
167
|
+
return finishLogin(cfg, io, home.dir, token);
|
|
168
|
+
};
|
|
169
|
+
export const formatWriteIdentity = (r) => r.kind === 'ok'
|
|
170
|
+
? [
|
|
171
|
+
`session ${r.identity.sessionId}`,
|
|
172
|
+
`agent ${r.identity.agentLabel ?? '(none — LATTRIS_AGENT sets one)'}`
|
|
173
|
+
].join('\n')
|
|
174
|
+
: r.kind === 'invalid'
|
|
175
|
+
? r.words
|
|
176
|
+
: 'session (none off a terminal — set LATTRIS_SESSION_ID for note writes)';
|
|
177
|
+
const whoamiSession = (io) => {
|
|
178
|
+
const r = resolveWriteIdentityDetail(io.store, io.stdin.isTTY);
|
|
179
|
+
if (io.stdout.isTTY) {
|
|
180
|
+
io.stdout.write(`${formatWriteIdentity(r)}\n`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
io.stdout.write(`${JSON.stringify(r.kind === 'ok'
|
|
184
|
+
? { sessionId: r.identity.sessionId, agentLabel: r.identity.agentLabel ?? null }
|
|
185
|
+
: {
|
|
186
|
+
sessionId: null,
|
|
187
|
+
agentLabel: null,
|
|
188
|
+
problem: r.kind === 'invalid' ? r.words : SESSION_ID_DOOR
|
|
189
|
+
})}\n`);
|
|
190
|
+
}
|
|
191
|
+
if (r.kind !== 'ok')
|
|
192
|
+
return refusal('', EXIT.USAGE);
|
|
193
|
+
};
|
|
194
|
+
export const whoami = async (cfg, io, args = []) => {
|
|
195
|
+
if (args.includes('--session')) {
|
|
196
|
+
return whoamiSession(io);
|
|
197
|
+
}
|
|
198
|
+
const accessToken = await signedIn(io, cfg);
|
|
199
|
+
if (typeof accessToken !== 'string')
|
|
200
|
+
return accessToken;
|
|
201
|
+
const result = await io.api(accessToken, `${cfg.apiUrl}/person/me`);
|
|
202
|
+
if (!result.ok)
|
|
203
|
+
return refusalOf(result);
|
|
204
|
+
emit(io, result.data, result.rawText, (p) => [
|
|
205
|
+
formatPerson(p),
|
|
206
|
+
formatWriteIdentity(resolveWriteIdentityDetail(io.store, io.stdin.isTTY))
|
|
207
|
+
].join('\n'));
|
|
208
|
+
};
|
|
209
|
+
export const logout = (io) => {
|
|
210
|
+
try {
|
|
211
|
+
clearSession(io.store);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
const code = e.code ?? 'error';
|
|
215
|
+
return refusal(`Logout failed: could not remove the session file (${code}). Your credentials are still stored.\n`, EXIT.GENERIC);
|
|
216
|
+
}
|
|
217
|
+
io.stdout.write('Logged out.\n');
|
|
218
|
+
};
|
|
219
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parseArgs } from "../request/flags.js";
|
|
2
|
+
import { emit } from "../render/output.js";
|
|
3
|
+
import { renderRunHint, sanitizeForTerminal } from "../render/text.js";
|
|
4
|
+
import { isUuidKey } from "../store/write-identity.js";
|
|
5
|
+
import { refusal } from "../store/exit.js";
|
|
6
|
+
import { refusalOf } from "../request/api-fetch.js";
|
|
7
|
+
import { signedIn } from "../request/access-token.js";
|
|
8
|
+
export const SET_HELP = [
|
|
9
|
+
'set — change something about your account.',
|
|
10
|
+
'',
|
|
11
|
+
'Fields:',
|
|
12
|
+
' name the name shown on your public page (lattris.com/by/<handle>)',
|
|
13
|
+
' root the block a walker starts from on your tree \u2014 one of your',
|
|
14
|
+
' own blocks, by its full id; `lattris whoami` shows it',
|
|
15
|
+
' banned-words words this account refuses in its OWN notes; the note door',
|
|
16
|
+
' asks you to override or rephrase when the text uses one',
|
|
17
|
+
'',
|
|
18
|
+
'Usage:',
|
|
19
|
+
' lattris set name "<value>"',
|
|
20
|
+
' lattris set root <id>',
|
|
21
|
+
'',
|
|
22
|
+
' lattris set banned-words show the current list',
|
|
23
|
+
' lattris set banned-words prose doctrine replace the WHOLE list',
|
|
24
|
+
' printf "prose\\ndoctrine\\n" | lattris set banned-words',
|
|
25
|
+
' one word per line from stdin',
|
|
26
|
+
' lattris set banned-words --clear remove them all (never gated)',
|
|
27
|
+
'',
|
|
28
|
+
' The list is replaced whole every time — the words you pass ARE the list,',
|
|
29
|
+
' not additions to it. Each is a single WORD: the gate matches whole words,',
|
|
30
|
+
' so a value with spaces can never fire and the server refuses it. Empty is',
|
|
31
|
+
' off: an account that sets none is never gated.'
|
|
32
|
+
].join('\n');
|
|
33
|
+
export const buildBannedWordsBody = (words) => ({
|
|
34
|
+
preferences: { banned_words: words }
|
|
35
|
+
});
|
|
36
|
+
export const formatBannedWords = (words) => words.length === 0
|
|
37
|
+
? 'Banned words: (none — this account is never gated)'
|
|
38
|
+
: `Banned words (${words.length}):\n${words.map((w) => ` ${sanitizeForTerminal(w)}`).join('\n')}`;
|
|
39
|
+
const setNameTo = async (cfg, io, value) => {
|
|
40
|
+
const accessToken = await signedIn(io, cfg);
|
|
41
|
+
if (typeof accessToken !== 'string')
|
|
42
|
+
return accessToken;
|
|
43
|
+
const result = await io.api(accessToken, `${cfg.apiUrl}/person/me`, {
|
|
44
|
+
method: 'PATCH',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ displayName: value })
|
|
47
|
+
});
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
return refusalOf(result);
|
|
50
|
+
emit(io, result.data, result.rawText, (p) => `Set name. Your public page: lattris.com/by/${p.handle}`);
|
|
51
|
+
};
|
|
52
|
+
export const setRoot = async (cfg, io, rest) => {
|
|
53
|
+
const { positionals, unknown } = parseArgs({}, rest);
|
|
54
|
+
if (unknown) {
|
|
55
|
+
return refusal(`Unknown flag: ${unknown}\n`);
|
|
56
|
+
}
|
|
57
|
+
if (positionals.length !== 1) {
|
|
58
|
+
return refusal(`Usage: lattris set root <id>\n`);
|
|
59
|
+
}
|
|
60
|
+
const id = positionals[0];
|
|
61
|
+
if (!isUuidKey(id)) {
|
|
62
|
+
return refusal(`set root takes the full id of one of your own blocks, never a prefix: every walker reads the root back, so it must name one block exactly.\nUsage: lattris set root <id>\n`);
|
|
63
|
+
}
|
|
64
|
+
const accessToken = await signedIn(io, cfg);
|
|
65
|
+
if (typeof accessToken !== 'string')
|
|
66
|
+
return accessToken;
|
|
67
|
+
const result = await io.api(accessToken, `${cfg.apiUrl}/person/me`, {
|
|
68
|
+
method: 'PATCH',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ rootBlockId: id })
|
|
71
|
+
});
|
|
72
|
+
if (!result.ok)
|
|
73
|
+
return refusalOf(result);
|
|
74
|
+
emit(io, result.data, result.rawText, (p) => `Set root block ${sanitizeForTerminal(p.rootBlockId ?? id)}. ${renderRunHint(['whoami'])} shows it.`);
|
|
75
|
+
};
|
|
76
|
+
export const setBannedWords = async (cfg, io, rest) => {
|
|
77
|
+
const clear = rest.includes('--clear');
|
|
78
|
+
const { positionals, unknown } = parseArgs({}, rest.filter((a) => a !== '--clear'));
|
|
79
|
+
if (unknown) {
|
|
80
|
+
return refusal(`Unknown flag: ${unknown}\n`);
|
|
81
|
+
}
|
|
82
|
+
const piped = !io.stdin.isTTY;
|
|
83
|
+
const stdinWords = piped
|
|
84
|
+
? (await io.stdin.read())
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map((w) => w.trim())
|
|
87
|
+
.filter((w) => w.length > 0)
|
|
88
|
+
: [];
|
|
89
|
+
if (positionals.length > 0 && stdinWords.length > 0) {
|
|
90
|
+
return refusal('Words on the command line and on stdin are two sources for one list — pass one.\n');
|
|
91
|
+
}
|
|
92
|
+
if (clear && (positionals.length > 0 || stdinWords.length > 0)) {
|
|
93
|
+
return refusal('--clear sets the list empty; do not also name words.\n');
|
|
94
|
+
}
|
|
95
|
+
const accessToken = await signedIn(io, cfg);
|
|
96
|
+
if (typeof accessToken !== 'string')
|
|
97
|
+
return accessToken;
|
|
98
|
+
const setting = clear || positionals.length > 0 || stdinWords.length > 0;
|
|
99
|
+
if (!setting) {
|
|
100
|
+
const result = await io.api(accessToken, `${cfg.apiUrl}/person/me`, {
|
|
101
|
+
method: 'GET'
|
|
102
|
+
});
|
|
103
|
+
if (!result.ok)
|
|
104
|
+
return refusalOf(result);
|
|
105
|
+
emit(io, result.data, result.rawText, (p) => formatBannedWords(p.preferences?.banned_words ?? []));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const words = clear ? [] : positionals.length > 0 ? positionals : stdinWords;
|
|
109
|
+
const result = await io.api(accessToken, `${cfg.apiUrl}/person/me`, {
|
|
110
|
+
method: 'PATCH',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify(buildBannedWordsBody(words))
|
|
113
|
+
});
|
|
114
|
+
if (!result.ok)
|
|
115
|
+
return refusalOf(result);
|
|
116
|
+
emit(io, result.data, result.rawText, (p) => words.length === 0
|
|
117
|
+
? 'Cleared. This account is no longer gated on its own words.'
|
|
118
|
+
: `Set. ${formatBannedWords(p.preferences?.banned_words ?? words)}`);
|
|
119
|
+
};
|
|
120
|
+
export const setName = async (cfg, io, rest) => {
|
|
121
|
+
const { positionals, unknown } = parseArgs({}, rest);
|
|
122
|
+
if (unknown) {
|
|
123
|
+
return refusal(`Unknown flag: ${unknown}\n`);
|
|
124
|
+
}
|
|
125
|
+
if (positionals.length !== 1) {
|
|
126
|
+
return refusal(`Usage: lattris set name "<value>"\n`);
|
|
127
|
+
}
|
|
128
|
+
return setNameTo(cfg, io, positionals[0]);
|
|
129
|
+
};
|
|
130
|
+
//# sourceMappingURL=set.js.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { renderRunHint } from "../render/text.js";
|
|
2
|
+
export const SETUP_HELP = [
|
|
3
|
+
'setup — transition this repo’s Claude to Lattris as its memory system.',
|
|
4
|
+
'',
|
|
5
|
+
'Usage:',
|
|
6
|
+
' lattris setup --name "<title>" [--file <path>] [--root <id>]',
|
|
7
|
+
'',
|
|
8
|
+
'What it does:',
|
|
9
|
+
' 1. Creates your ROOT node (or adopts an existing one via --root).',
|
|
10
|
+
' 2. If <path> (default ./CLAUDE.md) exists: prints the transition',
|
|
11
|
+
' instructions for the AI to execute — back up, extract into the graph',
|
|
12
|
+
' with full fidelity, then replace the file with the boot template.',
|
|
13
|
+
' The file itself is not touched; the AI performs the replacement.',
|
|
14
|
+
' 3. If it does not exist: writes the boot template there directly (a',
|
|
15
|
+
' fresh start has nothing to extract).',
|
|
16
|
+
' 4. Allows lattris commands in Claude Code: adds "Bash(lattris *)" to',
|
|
17
|
+
' .claude/settings.json (beside the memory file) under permissions.allow,',
|
|
18
|
+
' creating the file and the key if needed, keeping everything else in',
|
|
19
|
+
' it — only on a person’s word: asked on a terminal (Y/n), or passed',
|
|
20
|
+
' as --allow-lattris. Off a terminal with no flag nothing is written',
|
|
21
|
+
' and one line says how to add it. Without the rule, the first lattris',
|
|
22
|
+
' command in a repo is refused by Claude Code’s classifier before',
|
|
23
|
+
' anyone is asked.',
|
|
24
|
+
' Rerunning against an already-transitioned file prints the existing root',
|
|
25
|
+
' and changes nothing but the permission, if it is missing.',
|
|
26
|
+
'',
|
|
27
|
+
'Flags:',
|
|
28
|
+
' --name "<title>" The root node’s title (prompted for on a terminal).',
|
|
29
|
+
' --file <path> The memory file to transition (default ./CLAUDE.md).',
|
|
30
|
+
' --root <id> Adopt an existing memory as the root instead of creating.',
|
|
31
|
+
' --allow-lattris Write the Claude Code permission without a terminal to ask on.',
|
|
32
|
+
' --no-permissions Leave .claude/settings.json alone, and say nothing.'
|
|
33
|
+
].join('\n');
|
|
34
|
+
export const SETUP_FLAG_NAMES = {
|
|
35
|
+
'--name': 'name',
|
|
36
|
+
'--file': 'file',
|
|
37
|
+
'--root': 'root'
|
|
38
|
+
};
|
|
39
|
+
export const buildBootFile = (name, rootId) => [
|
|
40
|
+
`# ${name}`,
|
|
41
|
+
'',
|
|
42
|
+
'Your memory lives in **Lattris**, not in this file. Boot from the graph, act',
|
|
43
|
+
'from the graph, record to the graph.',
|
|
44
|
+
'',
|
|
45
|
+
'## Start here',
|
|
46
|
+
'',
|
|
47
|
+
'Fetch your root node — its content orients you, its links index everything else:',
|
|
48
|
+
'',
|
|
49
|
+
'```',
|
|
50
|
+
`${renderRunHint(['get', 'memory', rootId, '--links'])}`,
|
|
51
|
+
'```',
|
|
52
|
+
'',
|
|
53
|
+
'Auth is handled by the CLI — a 401 means `lattris login`. The tree is **private**',
|
|
54
|
+
'— anonymous reads 404 by design. Never read session.json or construct',
|
|
55
|
+
'Authorization headers yourself; the CLI is the credential boundary.',
|
|
56
|
+
'',
|
|
57
|
+
'## The walking discipline',
|
|
58
|
+
'',
|
|
59
|
+
'- Each link carries a **reason** saying when to follow it. Fetch a linked node',
|
|
60
|
+
' with `lattris get memory <id> --links` only when the conversation needs that',
|
|
61
|
+
' aspect. Never bulk-fetch the tree.',
|
|
62
|
+
'- Use the CLI for all reads and writes: `lattris get memory`, `lattris capture`,',
|
|
63
|
+
' `lattris update`, `lattris get memories`. The CLI handles auth, piped output',
|
|
64
|
+
' is the API wire verbatim.',
|
|
65
|
+
'- The graph is present-tense truth. What you learn that changes it, fold back',
|
|
66
|
+
' (`lattris update memory <id>`); what happened belongs in captured records,',
|
|
67
|
+
' not in doctrine prose.',
|
|
68
|
+
'- Where the graph and this repo’s own files disagree on operational detail,',
|
|
69
|
+
' the repo is the runtime truth — raise the drift, then fold the graph.',
|
|
70
|
+
''
|
|
71
|
+
].join('\n');
|
|
72
|
+
const BOOT_ROOT_RE = /(?:lattris get memory|GET\s+\S+\/memories\/)\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
73
|
+
export const parseBootRootId = (text) => BOOT_ROOT_RE.exec(text)?.[1];
|
|
74
|
+
export const buildTransitionInstructions = (file, apiUrl, rootId) => [
|
|
75
|
+
'── Transition instructions — for the AI executing this setup ──',
|
|
76
|
+
'',
|
|
77
|
+
`Your root node exists: ${rootId}.`,
|
|
78
|
+
`The file to transition: ${file}`,
|
|
79
|
+
'',
|
|
80
|
+
'Do this, in order. The laws are not stylistic — each one exists because its',
|
|
81
|
+
'violation produced a tree that misled a real session.',
|
|
82
|
+
'',
|
|
83
|
+
`1. BACK UP FIRST: copy ${file} to ${file}.bak before touching anything.`,
|
|
84
|
+
'',
|
|
85
|
+
'2. Read the original in full, then build the tree using the CLI:',
|
|
86
|
+
' - The ROOT is a high-level extraction — accurate to the original, routing',
|
|
87
|
+
' to tier-1 nodes. Write it with:',
|
|
88
|
+
` echo "<content>" | ${renderRunHint(['update', 'memory', rootId])}`,
|
|
89
|
+
' - TIER-1 nodes carry the SUBSTANCE — near-verbatim, not summaries of it:',
|
|
90
|
+
' echo "<content>" | lattris capture memory --title "<title>"',
|
|
91
|
+
` # then link: use the API for edges (the CLI doesn\u2019t have an edge command yet)`,
|
|
92
|
+
' - Never read session.json or construct Authorization headers yourself;',
|
|
93
|
+
' the CLI is the credential boundary.',
|
|
94
|
+
'',
|
|
95
|
+
'3. THE RECOGNITION LAYER IS SACRED. Never abstract away:',
|
|
96
|
+
' - routers and registries — concept→tool maps belong IN the tree, whole;',
|
|
97
|
+
' - trigger and intent phrases ("I observed …", "I want to record this");',
|
|
98
|
+
' - spec pointers and argument conventions.',
|
|
99
|
+
' Compression that drops what lets a reader DECIDE is amputation, not',
|
|
100
|
+
' summary.',
|
|
101
|
+
'',
|
|
102
|
+
'4. The user’s own words go in VERBATIM — voice is the irreplaceable signal.',
|
|
103
|
+
'',
|
|
104
|
+
'5. PRIVATE BY DEFAULT. Create nothing public; nothing becomes public without',
|
|
105
|
+
' the human’s word. Verify it: an anonymous GET of any node you created',
|
|
106
|
+
' must 404.',
|
|
107
|
+
'',
|
|
108
|
+
`6. FINISH: replace ${file} with the boot file below (the backup keeps the`,
|
|
109
|
+
' original), then verify the boot actually works — run',
|
|
110
|
+
` \`${renderRunHint(['get', 'memory', rootId, '--links'])}\` and confirm the root comes`,
|
|
111
|
+
' back with its links.',
|
|
112
|
+
'',
|
|
113
|
+
'── The boot file (write this as the file’s entire new content) ──',
|
|
114
|
+
''
|
|
115
|
+
].join('\n');
|
|
116
|
+
export const LATTRIS_PERMISSION_RULE = 'Bash(lattris *)';
|
|
117
|
+
const PERMISSION_RULES_THAT_ALLOW = new Set([LATTRIS_PERMISSION_RULE, 'Bash(lattris:*)']);
|
|
118
|
+
export const withLattrisPermission = (existing) => {
|
|
119
|
+
let doc = {};
|
|
120
|
+
if (existing !== undefined) {
|
|
121
|
+
try {
|
|
122
|
+
doc = JSON.parse(existing);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { refused: 'is not valid JSON' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
129
|
+
return { refused: 'is not a JSON object' };
|
|
130
|
+
}
|
|
131
|
+
const settings = doc;
|
|
132
|
+
const permissions = 'permissions' in settings ? settings.permissions : {};
|
|
133
|
+
if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
|
|
134
|
+
return { refused: 'has a "permissions" that is not an object' };
|
|
135
|
+
}
|
|
136
|
+
const allow = 'allow' in permissions ? permissions.allow : [];
|
|
137
|
+
if (!Array.isArray(allow))
|
|
138
|
+
return { refused: 'has a "permissions.allow" that is not an array' };
|
|
139
|
+
if (allow.some((rule) => typeof rule === 'string' && PERMISSION_RULES_THAT_ALLOW.has(rule))) {
|
|
140
|
+
return { text: existing ?? '', changed: false };
|
|
141
|
+
}
|
|
142
|
+
const next = {
|
|
143
|
+
...settings,
|
|
144
|
+
permissions: {
|
|
145
|
+
...permissions,
|
|
146
|
+
allow: [...allow, LATTRIS_PERMISSION_RULE]
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return { text: `${JSON.stringify(next, null, 2)}\n`, changed: true };
|
|
150
|
+
};
|
|
151
|
+
//# sourceMappingURL=setup-boot.js.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { promptLine } from "../render/prompt.js";
|
|
3
|
+
import { LATTRIS_PERMISSION_RULE, SETUP_FLAG_NAMES, SETUP_HELP, buildBootFile, buildTransitionInstructions, parseBootRootId, withLattrisPermission } from "../entry/setup-boot.js";
|
|
4
|
+
import { parseArgs } from "../request/flags.js";
|
|
5
|
+
import { HELP_TOKENS } from "../request/flags.js";
|
|
6
|
+
import { sanitizeForTerminal } from "../render/text.js";
|
|
7
|
+
import { refusal } from "../store/exit.js";
|
|
8
|
+
import { refusalOf } from "../request/api-fetch.js";
|
|
9
|
+
import { signedIn } from "../request/access-token.js";
|
|
10
|
+
export const promptAnswer = (answer) => {
|
|
11
|
+
if (answer === null)
|
|
12
|
+
return 'no';
|
|
13
|
+
const a = answer.trim().toLowerCase();
|
|
14
|
+
if (a === '' || a === 'y' || a === 'yes')
|
|
15
|
+
return 'yes';
|
|
16
|
+
if (a === 'n' || a === 'no')
|
|
17
|
+
return 'no';
|
|
18
|
+
return 'again';
|
|
19
|
+
};
|
|
20
|
+
const allowLattrisInClaudeCode = async (io, memoryFile, allowedByFlag) => {
|
|
21
|
+
const settingsPath = join(dirname(resolve(io.store.cwd, memoryFile)), '.claude', 'settings.json');
|
|
22
|
+
let existing;
|
|
23
|
+
try {
|
|
24
|
+
existing = io.store.files.readFileSync(settingsPath, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
const code = e.code ?? 'error';
|
|
28
|
+
if (code !== 'ENOENT') {
|
|
29
|
+
io.stderr.write(`Left ${settingsPath} alone: it could not be read (${code}). Add "${LATTRIS_PERMISSION_RULE}" to permissions.allow by hand.\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
existing = undefined;
|
|
33
|
+
}
|
|
34
|
+
const edit = withLattrisPermission(existing);
|
|
35
|
+
if ('refused' in edit) {
|
|
36
|
+
io.stderr.write(`Left ${settingsPath} alone: it ${edit.refused}. Add "${LATTRIS_PERMISSION_RULE}" to permissions.allow by hand.\n`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!edit.changed)
|
|
40
|
+
return;
|
|
41
|
+
if (!allowedByFlag) {
|
|
42
|
+
if (!io.stdin.isTTY) {
|
|
43
|
+
io.stderr.write(`Not added: "${LATTRIS_PERMISSION_RULE}" is missing from ${settingsPath} permissions.allow, and only a person adds it. ` +
|
|
44
|
+
'Rerun `lattris setup` in a terminal to be asked, or pass --allow-lattris.\n');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const question = `Allow lattris CLI commands in Claude Code? Adds "${LATTRIS_PERMISSION_RULE}" to ${settingsPath} (Y/n) `;
|
|
48
|
+
let verdict = 'again';
|
|
49
|
+
for (let asked = 0; asked < 3 && verdict === 'again'; asked++) {
|
|
50
|
+
verdict = promptAnswer(await promptLine(io, question));
|
|
51
|
+
if (verdict === 'again')
|
|
52
|
+
io.stderr.write('Answer y or n.\n');
|
|
53
|
+
}
|
|
54
|
+
if (verdict !== 'yes') {
|
|
55
|
+
io.stderr.write(`Left ${settingsPath} alone.\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
io.store.files.mkdirSync(dirname(settingsPath), { recursive: true });
|
|
60
|
+
io.store.files.writeFileSync(settingsPath, edit.text);
|
|
61
|
+
io.stderr.write(`Allowed lattris in Claude Code: "${LATTRIS_PERMISSION_RULE}" added to ${settingsPath} under permissions.allow.\n`);
|
|
62
|
+
};
|
|
63
|
+
export const setup = async (cfg, io, args) => {
|
|
64
|
+
if (args.some((a) => HELP_TOKENS.has(a))) {
|
|
65
|
+
io.stdout.write(`${SETUP_HELP}\n`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const permissions = !args.includes('--no-permissions');
|
|
69
|
+
const allowedByFlag = args.includes('--allow-lattris');
|
|
70
|
+
const { flags, positionals, unknown } = parseArgs(SETUP_FLAG_NAMES, args.filter((a) => a !== '--no-permissions' && a !== '--allow-lattris'));
|
|
71
|
+
if (unknown) {
|
|
72
|
+
return refusal(`Unknown or valueless flag: ${unknown}\n\n${SETUP_HELP}\n`);
|
|
73
|
+
}
|
|
74
|
+
if (positionals.length > 0) {
|
|
75
|
+
return refusal(`Unexpected argument: ${positionals[0]}\n\n${SETUP_HELP}\n`);
|
|
76
|
+
}
|
|
77
|
+
const file = flags.file ?? 'CLAUDE.md';
|
|
78
|
+
let existing;
|
|
79
|
+
try {
|
|
80
|
+
existing = io.store.files.readFileSync(file, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
existing = undefined;
|
|
84
|
+
}
|
|
85
|
+
if (existing !== undefined) {
|
|
86
|
+
const alreadyRoot = parseBootRootId(existing);
|
|
87
|
+
if (alreadyRoot) {
|
|
88
|
+
if (permissions)
|
|
89
|
+
await allowLattrisInClaudeCode(io, file, allowedByFlag);
|
|
90
|
+
io.stdout.write(`Already transitioned — ${file} boots from root ${alreadyRoot}. Nothing changed.\n`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let name = flags.name;
|
|
95
|
+
if (flags.root === undefined && name === undefined) {
|
|
96
|
+
if (!io.stdin.isTTY) {
|
|
97
|
+
return refusal('A root needs a title. Pass --name "<title>" (prompting needs a terminal).\n');
|
|
98
|
+
}
|
|
99
|
+
name = (await promptLine(io, 'What should your root be called? ')) ?? '';
|
|
100
|
+
if (name.length === 0) {
|
|
101
|
+
return refusal('A root needs a title.\n');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (permissions)
|
|
105
|
+
await allowLattrisInClaudeCode(io, file, allowedByFlag);
|
|
106
|
+
const accessToken = await signedIn(io, cfg);
|
|
107
|
+
if (typeof accessToken !== 'string')
|
|
108
|
+
return accessToken;
|
|
109
|
+
let rootId;
|
|
110
|
+
let rootTitle;
|
|
111
|
+
if (flags.root !== undefined) {
|
|
112
|
+
const got = await io.api(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(flags.root)}?content=0`);
|
|
113
|
+
if (!got.ok)
|
|
114
|
+
return refusalOf(got);
|
|
115
|
+
rootId = got.data.id;
|
|
116
|
+
rootTitle = got.data.title;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const created = await io.api(accessToken, `${cfg.apiUrl}/memories/notes`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
title: name,
|
|
124
|
+
content: 'Root of this memory tree. Freshly scaffolded by `lattris setup` — ' +
|
|
125
|
+
'the high-level orientation that routes to everything else lands here.'
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
if (!created.ok)
|
|
129
|
+
return refusalOf(created);
|
|
130
|
+
rootId = created.data.id;
|
|
131
|
+
rootTitle = created.data.title;
|
|
132
|
+
}
|
|
133
|
+
const bootName = name ?? rootTitle ?? 'Memory';
|
|
134
|
+
const boot = buildBootFile(bootName, rootId);
|
|
135
|
+
if (existing === undefined) {
|
|
136
|
+
io.store.files.writeFileSync(file, boot);
|
|
137
|
+
io.stdout.write(`Root ${rootId} ("${sanitizeForTerminal(bootName)}") is live.\n` +
|
|
138
|
+
`Wrote ${file} — this repo now boots its memory from Lattris.\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
io.stdout.write(`Root ${rootId} ("${sanitizeForTerminal(bootName)}") is live. ${file} detected — ` +
|
|
142
|
+
`transition instructions follow.\n\n` +
|
|
143
|
+
`${buildTransitionInstructions(file, cfg.apiUrl, rootId)}\n${boot}`);
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=setup.js.map
|