lattris 0.1.0 → 0.1.3

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 CHANGED
@@ -57,22 +57,32 @@ Run `lattris help <command>` for any of them, or `lattris --help` for the full l
57
57
 
58
58
  ## Where it keeps things
59
59
 
60
- Credentials live in your user config directory and are readable only by you. `lattris
61
- logout` removes them. Nothing is written to the directory you run it from.
60
+ Credentials live at `~/lattris-machines/<account>/<env>/session.json` and are readable
61
+ only by you. `lattris logout` removes them. Nothing is written to the directory you run
62
+ it from.
62
63
 
63
- ## Verifying what you installed
64
+ ## Release
64
65
 
65
- Releases published through CI carry [npm provenance](https://docs.npmjs.com/generating-provenance-statements):
66
- the registry records an attestation linking the tarball to the commit on `main` it was
67
- built from and the workflow run that built it. The first published version was published
68
- by hand to claim the name and carries no attestation, so do not assume one — check:
66
+ Releasing has two steps. First, open a normal version-bump PR from a branch:
69
67
 
70
68
  ```bash
71
- npm audit signatures
69
+ cd apps/lattris/cli
70
+ npm version patch --no-git-tag-version
71
+ git add package.json
72
+ git commit -m "chore(lattris-cli): bump package version"
72
73
  ```
73
74
 
74
- That is a statement about the version you installed from the registry. It says nothing
75
- about a copy obtained any other way.
75
+ After that PR merges, whoever holds npm publish rights updates a clean local `main` and runs:
76
+
77
+ ```bash
78
+ cd apps/lattris/cli
79
+ pnpm release
80
+ ```
81
+
82
+ The release script fetches `origin/main` and refuses unless the current branch is `main`,
83
+ `HEAD` equals `origin/main`, and the whole working tree is clean. It builds the CLI, writes
84
+ the release commit to `dist/build.json`, publishes to npm, and pushes the annotated
85
+ `lattris-v<version>` tag. `lattris --version` then names that release commit.
76
86
 
77
87
  ## Source, terms and reporting
78
88
 
@@ -82,5 +92,4 @@ Nothing here grants you rights beyond installing and running the tool, and no
82
92
  permission should be inferred from the absence of a licence file.
83
93
 
84
94
  There is no public issue tracker, so this package names none — a link you cannot
85
- open is worse than no link. For terms, faults or anything else, write to
86
- series@lattris.com.
95
+ open is worse than no link.
@@ -0,0 +1 @@
1
+ {"commit":"cecf7e60eb450bce092dc992155ff77002fc1379","builtAt":"2026-09-15T22:27:41Z"}
package/dist/main.js CHANGED
@@ -14,10 +14,11 @@
14
14
  // deputy / scoped-grant machinery here. It reuses the exact SPA-minted-Hydra-JWT
15
15
  // path the API already validates; zero server-side auth changes.
16
16
  //
17
- // Zero runtime dependencies — Node ≥ 24 built-ins only (global fetch, node:http,
18
- // node:crypto). Runnable directly via `node --experimental-strip-types
19
- // src/main.ts <cmd>` (no build step) or as the built `lattris` bin. Functions are
20
- // exported for unit tests; `main()` runs only when this file is the entry point.
17
+ // Zero runtime dependencies — the compiled bin runs on Node ≥ 22 built-ins only
18
+ // (global fetch, node:http, node:crypto). Running the TypeScript source directly
19
+ // still uses the repo's Node 24 toolchain via `node --experimental-strip-types
20
+ // src/main.ts <cmd>`. Functions are exported for unit tests; `main()` runs only
21
+ // when this file is the entry point.
21
22
  // Strip-only mode erases TYPES, never TS-only syntax: no constructor parameter
22
23
  // properties, enums, or namespaces anywhere in src/ — a parameter property
23
24
  // crashes the no-build run outright (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
@@ -30,11 +31,11 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
30
31
  import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
31
32
  import { createServer } from 'node:http';
32
33
  import { homedir } from 'node:os';
33
- import { dirname, join, resolve, sep } from 'node:path';
34
+ import { basename, dirname, join, resolve, sep } from 'node:path';
34
35
  import { createInterface } from 'node:readline';
35
36
  import { fileURLToPath } from 'node:url';
36
- import { extractChain } from './extract-claude-jsonl.js';
37
- import { ChainSaveError, driveChain, loadReceipts, saveReceipts } from './save-session-chain.js';
37
+ import { extractChain } from "./extract-claude-jsonl.js";
38
+ import { ChainSaveError, driveChain, loadReceipts, saveReceipts } from "./save-session-chain.js";
38
39
  // ─── exit codes ────────────────────────────────────────────────────────────────
39
40
  // Stable, meaningful exit codes so a script can tell failure kinds apart without
40
41
  // parsing prose (CLI client-contract spec §3). Code 4 is the machine twin of a
@@ -48,6 +49,18 @@ export const EXIT = {
48
49
  DOOR: 4 // a door must be walked first (e.g. person:uninitiated)
49
50
  };
50
51
  const stripTrailingSlash = (s) => s.replace(/\/+$/, '');
52
+ // A published package contains only the runtime files allow-listed in
53
+ // package.json. A worktree build also contains installed-check.js, whose whole
54
+ // purpose is to judge the repo-backed symlink install. That absent/present seam
55
+ // lets a clean npm install go to the product by default while source and worktree
56
+ // development keep their existing local default. Explicit --env, .lattris and
57
+ // LATTRIS_ENV always win over this fallback.
58
+ export const defaultEnvironmentForEntry = (entryUrl = import.meta.url) => {
59
+ const entryPath = fileURLToPath(entryUrl);
60
+ const entryDir = dirname(entryPath);
61
+ const isBuiltMain = basename(entryPath) === 'main.js' && basename(entryDir) === 'dist';
62
+ return isBuiltMain && !existsSync(join(entryDir, 'installed-check.js')) ? 'prod' : 'local';
63
+ };
51
64
  // The environments registry — a JSON file at a well-known location. Missing
52
65
  // file or missing key → the built-in localdev defaults below, so the CLI
53
66
  // works out of the box with no registry (the existing behavior).
@@ -646,6 +659,7 @@ export const CLI_COMMAND_WORDS = new Set([
646
659
  'invite',
647
660
  'share',
648
661
  'unshare',
662
+ 'consent',
649
663
  'restore',
650
664
  'erase',
651
665
  'resend',
@@ -1005,6 +1019,18 @@ export const validAccessToken = async (cfg) => {
1005
1019
  throw new Error('not-logged-in');
1006
1020
  }
1007
1021
  };
1022
+ // An absent or unrefreshable session sends no Authorization header, since the
1023
+ // API refuses any present invalid bearer by its own rule.
1024
+ export const optionalAccessToken = async (cfg) => {
1025
+ try {
1026
+ return await validAccessToken(cfg);
1027
+ }
1028
+ catch (error) {
1029
+ if (error instanceof Error && error.message === 'not-logged-in')
1030
+ return undefined;
1031
+ throw error;
1032
+ }
1033
+ };
1008
1034
  // Render a door to STDERR (stdout stays clean for piped data):
1009
1035
  // <message>
1010
1036
  // Run: lattris <next.action>
@@ -1160,10 +1186,13 @@ export const edgeRefusal = (status, headers, bytes) => {
1160
1186
  // ALREADY rendered the door and the caller just propagates the exit code. 204 (the
1161
1187
  // delete route's success) has no body to parse.
1162
1188
  const HTTP_NO_CONTENT = 204;
1163
- export const apiFetch = async (accessToken, url, init = {}) => {
1189
+ export const apiFetch = async (accessToken, url, init = {}, addressKind = 'api') => {
1190
+ const headers = { ...init.headers };
1191
+ if (accessToken !== undefined)
1192
+ headers.Authorization = `Bearer ${accessToken}`;
1164
1193
  const res = await fetch(url, {
1165
1194
  ...init,
1166
- headers: { ...init.headers, Authorization: `Bearer ${accessToken}` }
1195
+ headers
1167
1196
  });
1168
1197
  if (!res.ok) {
1169
1198
  // Bytes, not text: the edge excerpt is cut by byte before decoding, and
@@ -1189,11 +1218,8 @@ export const apiFetch = async (accessToken, url, init = {}) => {
1189
1218
  data = JSON.parse(rawText);
1190
1219
  }
1191
1220
  catch {
1192
- // A 200 that is not JSON is not the API — almost always a gateway
1193
- // falling through to the SPA because the environment's `api` URL is
1194
- // wrong; without this branch it surfaces as "Unexpected token '<'"
1195
- // AFTER a successful login.
1196
- process.stderr.write('The server answered with a web page, not the API — check the environment\u2019s `api` URL.\n');
1221
+ // A gateway fallthrough can be 200 HTML; it must not pass as a JSON read.
1222
+ process.stderr.write(`The server answered with a web page, not the API — check the environment\u2019s \`${addressKind}\` URL.\n`);
1197
1223
  return { ok: false, body: {}, exit: EXIT.GENERIC };
1198
1224
  }
1199
1225
  return { ok: true, data, rawText };
@@ -1201,11 +1227,10 @@ export const apiFetch = async (accessToken, url, init = {}) => {
1201
1227
  // ─── the client-makes-no-decisions output rule ──────────────────────────────────
1202
1228
  // A person reading a terminal and a program reading a pipe want different things
1203
1229
  // from the SAME response: a person wants the labelled/columnar form, a program
1204
- // wants exactly what the API sent. Piped output is the wire body VERBATIM — BYTE
1205
- // verbatim, not a re-stringify of the parsed object (which is only semantically
1206
- // equal — different key order, whitespace, or number formatting would all still
1207
- // pass a deep-equal test while lying about what actually crossed the wire) — so
1208
- // structured output can never drift from the API (deliberate).
1230
+ // wants the API's wire body without re-stringification. Piped output preserves
1231
+ // the response bytes (key order, whitespace and number formatting included) and
1232
+ // adds only a missing final newline, so successive shell output cannot run into
1233
+ // the JSON. A response that already ends in a newline is unchanged.
1209
1234
  // renderHuman picks what a person needs to see; it never changes what a machine
1210
1235
  // receives. An empty human rendering (e.g. no memories) writes nothing rather
1211
1236
  // than a stray blank line.
@@ -1216,7 +1241,7 @@ export const emit = (data, rawText, renderHuman) => {
1216
1241
  process.stdout.write(`${human}\n`);
1217
1242
  }
1218
1243
  else {
1219
- process.stdout.write(rawText);
1244
+ process.stdout.write(rawText.endsWith('\n') ? rawText : `${rawText}\n`);
1220
1245
  }
1221
1246
  };
1222
1247
  // The product word is "lattris number", never
@@ -1743,13 +1768,13 @@ export const buildCreateNoteBody = (content, flags, allowBannedWords = false) =>
1743
1768
  // receipt. Not cosmetic: capturedAt is uneditable (PATCH carries only title/content),
1744
1769
  // so this is the person's ONE immediate chance to catch a wrong-as-recorded
1745
1770
  // timestamp — the only remedy past this point is delete + recapture.
1746
- export const formatSaved = (b) => {
1771
+ export const formatSaved = (b, address) => {
1747
1772
  const lines = [`Saved memory ${b.id} (type: ${b.type})`];
1748
1773
  if (b.intent)
1749
1774
  lines.push(` intent ${b.intent}`);
1750
1775
  // title is echoed back from the caller's own --title — sanitized like every
1751
- // other rendered title (finding 2's principle: this receipt always prints as
1752
- // text, TTY or not, so it is never the wire-verbatim channel).
1776
+ // other rendered title. The machine receipt is JSON; this formatter is only
1777
+ // the terminal channel.
1753
1778
  if (b.title)
1754
1779
  lines.push(` title ${sanitizeForTerminal(b.title)}`);
1755
1780
  if (b.aiSummary)
@@ -1759,6 +1784,8 @@ export const formatSaved = (b) => {
1759
1784
  lines.push(` fingerprint ${b.fingerprint}`);
1760
1785
  if (b.revisionNo)
1761
1786
  lines.push(` revisionNo ${b.revisionNo}`);
1787
+ if (address)
1788
+ lines.push(` address ${address}`);
1762
1789
  return lines.join('\n');
1763
1790
  };
1764
1791
  // Read all of stdin. Strips a SINGLE trailing newline — the ubiquitous echo/heredoc
@@ -2304,14 +2331,22 @@ const captureMemory = async (cfg, args) => {
2304
2331
  process.exitCode = result.exit;
2305
2332
  return;
2306
2333
  }
2307
- process.stdout.write(`${formatSaved(result.data)}\n`);
2334
+ const record = result.data;
2335
+ const address = await composeAddressFor(cfg, accessToken, record);
2336
+ const { href, ...fields } = record;
2337
+ const receipt = {
2338
+ ...fields,
2339
+ ...(href === undefined ? {} : { href }),
2340
+ ...(address === undefined ? {} : { address })
2341
+ };
2342
+ emit(receipt, JSON.stringify(receipt), () => formatSaved(record, address));
2308
2343
  // Capture-and-link: a CLI-side composition of the two doors the wire
2309
2344
  // actually has (capture, then edge) — never a pretended atomicity.
2310
2345
  if (flags.linkedFrom !== undefined || flags.linksTo !== undefined) {
2311
2346
  // --linked-from: the existing node points AT the new one; --links-to:
2312
2347
  // the new node points outward.
2313
- const fromRef = flags.linkedFrom ?? result.data.id;
2314
- const toId = flags.linkedFrom !== undefined ? result.data.id : flags.linksTo;
2348
+ const fromRef = flags.linkedFrom ?? record.id;
2349
+ const toId = flags.linkedFrom !== undefined ? record.id : flags.linksTo;
2315
2350
  const edge = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fromRef)}/edges`, {
2316
2351
  method: 'POST',
2317
2352
  headers: { 'Content-Type': 'application/json' },
@@ -2343,7 +2378,9 @@ export const formatAudiences = (audiences) => audiences.length === 0 ? '(private
2343
2378
  // title is the caller's own free text — sanitized before a TTY renders it (the
2344
2379
  // piped/JSON path never calls this formatter). id/capturedAt/intent are all
2345
2380
  // API-constrained shapes, never free text.
2346
- export const formatBlockLine = (b) => `${b.id} ${b.handle} ${b.capturedAt} ${b.intent ?? '-'} ${b.visibility.public ? 'public' : '-'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`;
2381
+ export const formatBlockLine = (b) => b.ownerHandle === undefined
2382
+ ? `${b.id} ${b.handle} ${b.capturedAt} ${b.intent ?? '-'} ${b.visibility.public ? 'public' : '-'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`
2383
+ : `${b.id} ${b.handle} ${b.capturedAt} by ${sanitizeForTerminal(b.ownerDisplayName ?? b.ownerHandle)} (@${b.ownerHandle}) ${b.consented ? 'on graph' : 'off graph'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`;
2347
2384
  // ─── composing a memory's web address ──────────────────────────────────────────
2348
2385
  // The client COMPOSES an address; only the server RESOLVES one, and it resolves
2349
2386
  // on the block handle alone. That split is why carrying these rules here is
@@ -2365,10 +2402,30 @@ export const composeAddress = (webUrl, personHandle, block) => {
2365
2402
  const ref = slug ? `${slug}-${block.handle}` : block.handle;
2366
2403
  return `${webUrl}/by/${personHandle}/memories/${ref}`;
2367
2404
  };
2368
- // The caller's own handle, fetched quietly for the address line. Every failure
2369
- // — no session, a 500, a shape that is not what we expect — resolves to "no
2370
- // address", never to an error the person did not ask for.
2371
- const composeAddressFor = async (cfg, accessToken, block) => {
2405
+ export const parseWebsiteMemoryAddress = (webUrl, value) => {
2406
+ try {
2407
+ const base = new URL(webUrl);
2408
+ const address = new URL(value);
2409
+ if (address.origin !== base.origin || address.username || address.password)
2410
+ return undefined;
2411
+ const basePath = stripTrailingSlash(base.pathname);
2412
+ const route = address.pathname.slice(basePath.length);
2413
+ if (!address.pathname.startsWith(`${basePath}/by/`))
2414
+ return undefined;
2415
+ const parts = route.split('/');
2416
+ if (parts.length !== 5 || parts[1] !== 'by' || parts[3] !== 'memories')
2417
+ return undefined;
2418
+ const ownerHandle = decodeURIComponent(parts[2]);
2419
+ const ref = decodeURIComponent(parts[4]);
2420
+ if (!ownerHandle || !ref || ownerHandle.includes('/') || ref.includes('/'))
2421
+ return undefined;
2422
+ return { ownerHandle, ref };
2423
+ }
2424
+ catch {
2425
+ return undefined;
2426
+ }
2427
+ };
2428
+ const ownHandleFor = async (cfg, accessToken) => {
2372
2429
  try {
2373
2430
  const res = await fetch(`${cfg.apiUrl}/person/me`, {
2374
2431
  headers: { Authorization: `Bearer ${accessToken}` }
@@ -2376,12 +2433,17 @@ const composeAddressFor = async (cfg, accessToken, block) => {
2376
2433
  if (!res.ok)
2377
2434
  return undefined;
2378
2435
  const { handle } = (await res.json());
2379
- return handle ? composeAddress(cfg.webUrl, handle, block) : undefined;
2436
+ return typeof handle === 'string' && handle.length > 0 ? handle : undefined;
2380
2437
  }
2381
2438
  catch {
2382
2439
  return undefined;
2383
2440
  }
2384
2441
  };
2442
+ // A missing convenience address must not fail the record already in hand.
2443
+ const composeAddressFor = async (cfg, accessToken, block) => {
2444
+ const handle = await ownHandleFor(cfg, accessToken);
2445
+ return handle ? composeAddress(cfg.webUrl, handle, block) : undefined;
2446
+ };
2385
2447
  // title/content are the caller's own free text — sanitized before a TTY ever
2386
2448
  // renders them (piped JSON stays wire-verbatim; that channel is machine data by
2387
2449
  // contract, sanitization is a rendering concern, not a storage one). id/type/
@@ -2509,8 +2571,8 @@ export const formatNodeWalk = (w, address) => {
2509
2571
  // no other lever restores the bare pre-walk read, byte-identical on the
2510
2572
  // wire. Lever values ride through verbatim — the API owns validation (depth
2511
2573
  // range, depth-requires-links); the client makes no decisions.
2512
- export const buildGetMemoryUrl = (apiUrl, idOrPrefix, flags) => {
2513
- const url = new URL(`${apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
2574
+ export const buildGetMemoryUrl = (webUrl, ownerHandle, ref, flags) => {
2575
+ const url = new URL(`${webUrl}/by/${encodeURIComponent(ownerHandle)}/memories/${encodeURIComponent(ref)}`);
2514
2576
  const links = flags.links ?? '1';
2515
2577
  const bare = links === '0' && flags.depth === undefined && flags.content === undefined;
2516
2578
  if (!bare) {
@@ -2522,51 +2584,63 @@ export const buildGetMemoryUrl = (apiUrl, idOrPrefix, flags) => {
2522
2584
  }
2523
2585
  return url;
2524
2586
  };
2525
- const getMemory = async (cfg, idOrPrefix, flags) => {
2587
+ const getMemory = async (cfg, idOrAddress, flags, by) => {
2588
+ const isAddress = /^https?:\/\//i.test(idOrAddress);
2589
+ const address = isAddress ? parseWebsiteMemoryAddress(cfg.webUrl, idOrAddress) : undefined;
2590
+ if (isAddress && !address) {
2591
+ process.stderr.write(`The memory address must be on ${cfg.webUrl}/by/<owner>/memories/<ref>.\n`);
2592
+ process.exitCode = EXIT.USAGE;
2593
+ return;
2594
+ }
2595
+ if (address && by && address.ownerHandle !== by) {
2596
+ process.stderr.write('--by must name the same owner as the memory address.\n');
2597
+ process.exitCode = EXIT.USAGE;
2598
+ return;
2599
+ }
2600
+ const storedSession = loadSession();
2601
+ let ownerHandle = address?.ownerHandle ?? by ?? storedSession?.handle;
2526
2602
  let accessToken;
2527
- try {
2528
- accessToken = await validAccessToken(cfg);
2603
+ if (!ownerHandle && storedSession) {
2604
+ accessToken = await optionalAccessToken(cfg);
2605
+ if (accessToken) {
2606
+ ownerHandle = await ownHandleFor(cfg, accessToken);
2607
+ if (ownerHandle) {
2608
+ const currentSession = loadSession();
2609
+ if (currentSession && currentSession.handle !== ownerHandle) {
2610
+ saveSession({ ...currentSession, handle: ownerHandle });
2611
+ }
2612
+ }
2613
+ }
2529
2614
  }
2530
- catch {
2531
- renderNotLoggedIn();
2532
- process.exitCode = EXIT.NOAUTH;
2615
+ if (!ownerHandle) {
2616
+ process.stderr.write('A memory id needs its wall: use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
2617
+ process.exitCode = EXIT.USAGE;
2533
2618
  return;
2534
2619
  }
2535
- const url = buildGetMemoryUrl(cfg.apiUrl, idOrPrefix, flags);
2620
+ accessToken ??= await optionalAccessToken(cfg);
2621
+ const url = buildGetMemoryUrl(cfg.webUrl, ownerHandle, address?.ref ?? idOrAddress, flags);
2536
2622
  const enveloped = url.search !== '';
2537
- const result = await apiFetch(accessToken, url);
2623
+ const result = await apiFetch(accessToken, url, {
2624
+ headers: { Accept: 'application/json' }
2625
+ }, 'web');
2538
2626
  if (!result.ok) {
2539
2627
  renderAmbiguousIfPresent(result.body);
2628
+ if (result.body.code === 'memory:not-found' && !address && !by) {
2629
+ process.stderr.write('For another wall, use get memory <website-address> or get memory <id> --by <owner-handle>.\n');
2630
+ }
2540
2631
  process.exitCode = result.exit;
2541
2632
  return;
2542
2633
  }
2543
- // The address needs the caller's OWN handle, which this response does not
2544
- // carry — so it costs a second request. Paid ONLY at a TTY: piped output is
2545
- // the wire verbatim and gains nothing from an address, so a script never
2546
- // pays for a line it will not receive. Fetched rather than cached: caching
2547
- // would be safe (a handle is assigned once and never reassigned, so it
2548
- // cannot go stale), but it would mean writing state from a read path and
2549
- // carrying a miss-path for sessions that predate it — for one extra request
2550
- // on an interactive command that is already making one. Cache later if it
2551
- // ever matters; the immutability that makes it safe is not going anywhere.
2552
- //
2553
- // Deliberately NOT through apiFetch: that renders a door on failure and hands
2554
- // back an exit code, which is right for a call the caller asked for and wrong
2555
- // for this one. The record is already in hand; a convenience that cannot be
2556
- // built should vanish quietly, not print "Session invalid or expired" over a
2557
- // memory that printed perfectly well.
2558
- // Bare or enveloped, the block whose address we'd compose is the same
2559
- // record — the envelope's anchor carries handle+title either way.
2560
2634
  const record = enveloped ? result.data.node : result.data;
2561
- let address;
2562
- if (process.stdout.isTTY) {
2563
- address = await composeAddressFor(cfg, accessToken, record);
2564
- }
2635
+ const displayAddress = process.stdout.isTTY
2636
+ ? composeAddress(cfg.webUrl, ownerHandle, record)
2637
+ : undefined;
2565
2638
  emit(result.data, result.rawText, () => enveloped
2566
- ? formatNodeWalk(result.data, address)
2567
- : formatMemoryDetail(result.data, address));
2639
+ ? formatNodeWalk(result.data, displayAddress)
2640
+ : formatMemoryDetail(result.data, displayAddress));
2568
2641
  };
2569
2642
  const GET_FLAG_NAMES = {
2643
+ '--by': 'by',
2570
2644
  '--revision': 'revision',
2571
2645
  '--type': 'type',
2572
2646
  '--intent': 'intent',
@@ -2580,7 +2654,7 @@ const GET_FLAG_NAMES = {
2580
2654
  };
2581
2655
  // Which side of get each flag belongs to — the applicability refusals in the
2582
2656
  // dispatcher read these, so a new flag must pick a side to compile at all.
2583
- const LIST_ONLY_FLAGS = ['type', 'intent', 'limit', 'cursor', 'q', 'with'];
2657
+ const LIST_ONLY_FLAGS = ['type', 'intent', 'limit', 'cursor', 'q', 'with', 'sharedWithMe'];
2584
2658
  // --content is the shape lever on BOTH reads: on the one memory it drops the
2585
2659
  // words, on the list it drops the excerpts. The rest of these are the walk's.
2586
2660
  const WALK_ONLY_FLAGS = ['links', 'depth', 'revision'];
@@ -2614,16 +2688,15 @@ const getMemories = async (cfg, flags) => {
2614
2688
  url.searchParams.set('audience', flags.with);
2615
2689
  if (flags.content !== undefined)
2616
2690
  url.searchParams.set('content', flags.content);
2691
+ if (flags.sharedWithMe)
2692
+ url.searchParams.set('sharedWithMe', '1');
2617
2693
  const result = await apiFetch(accessToken, url);
2618
2694
  if (!result.ok) {
2619
2695
  process.exitCode = result.exit;
2620
2696
  return;
2621
2697
  }
2622
2698
  emit(result.data, result.rawText, formatMemoryPage);
2623
- if (result.data.items.length === 0) {
2624
- process.stderr.write('No memories yet.\n');
2625
- }
2626
- else if (result.data.nextCursor) {
2699
+ if (result.data.nextCursor) {
2627
2700
  // Reconstruct the FULL continuation — active filters + the new cursor — so
2628
2701
  // copy-pasting the hint never silently drops back to an unfiltered page.
2629
2702
  // ARGV elements through the one renderer, never a preformed string: a
@@ -2637,6 +2710,35 @@ const getMemories = async (cfg, flags) => {
2637
2710
  argv.push('--q', flags.q);
2638
2711
  if (flags.with !== undefined)
2639
2712
  argv.push('--with', flags.with);
2713
+ if (flags.limit !== undefined)
2714
+ argv.push('--limit', flags.limit);
2715
+ if (flags.content !== undefined)
2716
+ argv.push('--content', flags.content);
2717
+ if (flags.sharedWithMe)
2718
+ argv.push('--shared-with-me');
2719
+ argv.push('--cursor', result.data.nextCursor);
2720
+ process.stderr.write(`More: ${renderRunHint(argv)}\n`);
2721
+ }
2722
+ };
2723
+ const getWallMemories = async (cfg, ownerHandle, flags) => {
2724
+ const accessToken = await optionalAccessToken(cfg);
2725
+ const url = new URL(`${cfg.webUrl}/by/${encodeURIComponent(ownerHandle)}/memories`);
2726
+ if (flags.limit !== undefined)
2727
+ url.searchParams.set('limit', flags.limit);
2728
+ if (flags.cursor !== undefined)
2729
+ url.searchParams.set('cursor', flags.cursor);
2730
+ if (flags.content !== undefined)
2731
+ url.searchParams.set('content', flags.content);
2732
+ const result = await apiFetch(accessToken, url, {
2733
+ headers: { Accept: 'application/json' }
2734
+ }, 'web');
2735
+ if (!result.ok) {
2736
+ process.exitCode = result.exit;
2737
+ return;
2738
+ }
2739
+ emit(result.data, result.rawText, formatMemoryPage);
2740
+ if (result.data.nextCursor) {
2741
+ const argv = ['get', 'memories', '--by', ownerHandle];
2640
2742
  if (flags.limit !== undefined)
2641
2743
  argv.push('--limit', flags.limit);
2642
2744
  if (flags.content !== undefined)
@@ -2650,18 +2752,19 @@ const getMemories = async (cfg, flags) => {
2650
2752
  // capture uses, one level: get has no separate "form" to go deeper into, so the
2651
2753
  // nouns, the positional, and the flags all live at this one level.
2652
2754
  export const GET_HELP = [
2653
- 'get — read your memories back.',
2755
+ 'get — read your memories or a public wall.',
2654
2756
  '',
2655
2757
  'Nouns:',
2656
- ' memories list, newest first',
2657
- ' memory one, by full id or a >=4-char prefix \u2014 its edges ride along',
2758
+ ' memories your list, or one wall with --by <owner-handle>',
2759
+ ' memory one, by website address or id with --by <owner-handle> \u2014 its edges ride along',
2658
2760
  ' history one note\u2019s history: every change, newest first, and the head fingerprint',
2659
2761
  ' deleted your deleted items, with the fingerprint restore and erase need',
2660
2762
  ' questions the questions left on your tree, open first',
2661
2763
  '',
2662
2764
  'Usage:',
2663
- ' lattris get memories [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
2664
- ' lattris get memory <id-or-prefix> [--links 0|1] [--depth <n>] [--content 0|1]',
2765
+ ' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
2766
+ ' lattris get memories --by <owner-handle> [--limit <n>] [--cursor <token>] [--content 0|1]',
2767
+ ' lattris get memory <website-address|id> [--by <owner-handle>] [--links 0|1] [--depth <n>] [--content 0|1]',
2665
2768
  ' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
2666
2769
  ' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
2667
2770
  ' lattris get deleted [--limit <n>] [--cursor <token>]',
@@ -2678,6 +2781,10 @@ export const GET_HELP = [
2678
2781
  ' (private — no grant at all)',
2679
2782
  ' --limit <n> page size, 1-100 (default 50)',
2680
2783
  ' --cursor <token> the nextCursor from a previous page',
2784
+ ' --by <handle> one wall on the website address; an id without a',
2785
+ ' session needs it, while a full address carries it',
2786
+ ' --shared-with-me list memories other people granted to you; each row',
2787
+ ' names its owner and whether it is on your graph',
2681
2788
  '',
2682
2789
  ' --links 0|1 edges on the one memory (default 1): direction, verb,',
2683
2790
  ' reason, and each neighbour\u2019s finding aid. --links 0',
@@ -2698,11 +2805,11 @@ export const GET_HELP = [
2698
2805
  '',
2699
2806
  'Every read of a note prints its fingerprint (the head of its history) and',
2700
2807
  'revisionNo; every read prints structureHash, a hash of the block\u2019s links and',
2701
- 'grants as YOU see them. An edit sends the fingerprint back as --base-fingerprint.',
2808
+ 'grants as this reader sees them. An edit sends the fingerprint back as --base-fingerprint.',
2702
2809
  '',
2703
- "A memory's web address is <site>/by/<your-handle>/memories/<memory-handle> —",
2704
- 'the handle column above, after your own from whoami. `get memory` prints the',
2705
- 'finished address; the site adds the readable title itself, so the bare form lands.',
2810
+ "A memory's web address is <site>/by/<owner-handle>/memories/<slug>-<memory-handle>.",
2811
+ 'A /by read asks for JSON with Accept: application/json; it sends a bearer',
2812
+ 'only when the session has a live one. `get memory` prints the address at a TTY.',
2706
2813
  '',
2707
2814
  'Aliases: read, show, view, fetch → get'
2708
2815
  ].join('\n');
@@ -2729,21 +2836,113 @@ const GET_NOUNS = new Set([
2729
2836
  // when the build wrote one, and is ABSENT rather than guessed when it did not —
2730
2837
  // `tsc` alone does not write that stamp, the install path does, so a package
2731
2838
  // built by CI carries it only if CI writes it.
2839
+ const runtimeJson = (path) => {
2840
+ try {
2841
+ return JSON.parse(readFileSync(path, 'utf8'));
2842
+ }
2843
+ catch {
2844
+ return undefined;
2845
+ }
2846
+ };
2847
+ const currentPackageVersion = () => {
2848
+ const here = dirname(fileURLToPath(import.meta.url));
2849
+ const pkg = runtimeJson(join(here, '..', 'package.json'));
2850
+ return typeof pkg?.version === 'string' ? pkg.version : 'unknown';
2851
+ };
2732
2852
  const printVersion = () => {
2733
2853
  const here = dirname(fileURLToPath(import.meta.url));
2734
- const readJson = (path) => {
2854
+ const build = runtimeJson(join(here, 'build.json'));
2855
+ const version = currentPackageVersion();
2856
+ const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
2857
+ process.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
2858
+ };
2859
+ const VERSION_CHECK_URL = 'https://registry.npmjs.org/lattris/latest';
2860
+ const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
2861
+ const VERSION_CHECK_TIMEOUT_MS = 1500;
2862
+ const parseVersion = (version) => {
2863
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
2864
+ if (!match)
2865
+ return undefined;
2866
+ return {
2867
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
2868
+ prerelease: match[4] === undefined ? null : match[4].split('.')
2869
+ };
2870
+ };
2871
+ // SemVer precedence without adding a runtime dependency to the zero-dependency
2872
+ // CLI. Returns negative when left is older, positive when it is newer.
2873
+ const compareVersions = (left, right) => {
2874
+ const a = parseVersion(left);
2875
+ const b = parseVersion(right);
2876
+ if (!a || !b)
2877
+ return undefined;
2878
+ for (let i = 0; i < a.core.length; i++) {
2879
+ if (a.core[i] < b.core[i])
2880
+ return -1;
2881
+ if (a.core[i] > b.core[i])
2882
+ return 1;
2883
+ }
2884
+ if (a.prerelease === null && b.prerelease === null)
2885
+ return 0;
2886
+ if (a.prerelease === null)
2887
+ return 1;
2888
+ if (b.prerelease === null)
2889
+ return -1;
2890
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
2891
+ for (let i = 0; i < length; i++) {
2892
+ const av = a.prerelease[i];
2893
+ const bv = b.prerelease[i];
2894
+ if (av === undefined)
2895
+ return -1;
2896
+ if (bv === undefined)
2897
+ return 1;
2898
+ if (av === bv)
2899
+ continue;
2900
+ const aNumber = /^\d+$/.test(av);
2901
+ const bNumber = /^\d+$/.test(bv);
2902
+ if (aNumber && bNumber)
2903
+ return BigInt(av) < BigInt(bv) ? -1 : 1;
2904
+ if (aNumber !== bNumber)
2905
+ return aNumber ? -1 : 1;
2906
+ return av < bv ? -1 : 1;
2907
+ }
2908
+ return 0;
2909
+ };
2910
+ // A courtesy, never a gate: every read, request, parse and write failure is
2911
+ // swallowed, and this function never changes the command's exit code. Startup
2912
+ // launches it without awaiting it, so the command begins while the registry
2913
+ // request is in flight; the bounded request cannot hold a quick command open
2914
+ // indefinitely.
2915
+ export const checkForNewVersion = async ({ currentVersion, stateDir, now = Date.now(), fetchImpl = fetch }) => {
2916
+ try {
2917
+ const cachePath = join(stateDir, 'version-check.json');
2918
+ const cache = runtimeJson(cachePath);
2919
+ if (typeof cache?.checkedAt === 'number' && now - cache.checkedAt <= VERSION_CHECK_TTL_MS) {
2920
+ return;
2921
+ }
2922
+ const response = await fetchImpl(VERSION_CHECK_URL, {
2923
+ headers: { Accept: 'application/json' },
2924
+ signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS)
2925
+ });
2926
+ if (!response.ok)
2927
+ return;
2928
+ const body = (await response.json());
2929
+ if (typeof body.version !== 'string' || parseVersion(body.version) === undefined)
2930
+ return;
2735
2931
  try {
2736
- return JSON.parse(readFileSync(path, 'utf8'));
2932
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
2933
+ writeFileSync(cachePath, `${JSON.stringify({ checkedAt: now, latestVersion: body.version })}\n`, { mode: 0o600 });
2737
2934
  }
2738
2935
  catch {
2739
- return undefined;
2936
+ // An unwritable cache means a later startup may retry; it never makes
2937
+ // this command fail or hides an update the registry already answered.
2740
2938
  }
2741
- };
2742
- const pkg = readJson(join(here, '..', 'package.json'));
2743
- const build = readJson(join(here, 'build.json'));
2744
- const version = typeof pkg?.version === 'string' ? pkg.version : 'unknown';
2745
- const commit = typeof build?.commit === 'string' ? build.commit.slice(0, 12) : undefined;
2746
- process.stdout.write(commit ? `lattris ${version} (${commit})\n` : `lattris ${version}\n`);
2939
+ if (compareVersions(currentVersion, body.version) === -1) {
2940
+ process.stderr.write(`lattris ${currentVersion} → ${body.version} available: npm update -g lattris\n`);
2941
+ }
2942
+ }
2943
+ catch {
2944
+ // Offline, timed out, malformed response, unreadable cache: all silent.
2945
+ }
2747
2946
  };
2748
2947
  const HELP_TOKENS = new Set(['--help', '-h', 'help']);
2749
2948
  export const LOGIN_HELP = [
@@ -2792,6 +2991,8 @@ export const commandHelpText = (command, args) => {
2792
2991
  return ERASE_HELP;
2793
2992
  case 'contact':
2794
2993
  return CONTACT_HELP;
2994
+ case 'consent':
2995
+ return CONSENT_HELP;
2795
2996
  case 'capture': {
2796
2997
  // The form's own help when a form was named — mirroring the
2797
2998
  // sub-dispatcher's routing (positional 'session' OR --type session);
@@ -2871,7 +3072,15 @@ const get = async (cfg, noun, rest) => {
2871
3072
  await getDeleted(cfg, rest);
2872
3073
  return;
2873
3074
  }
2874
- const { flags, positionals, unknown } = parseArgs(GET_FLAG_NAMES, rest);
3075
+ const sharedWithMeCount = rest.filter((arg) => arg === '--shared-with-me').length;
3076
+ if (sharedWithMeCount > 1) {
3077
+ process.stderr.write('--shared-with-me was given more than once. Send it once.\n');
3078
+ process.exitCode = EXIT.USAGE;
3079
+ return;
3080
+ }
3081
+ const { flags, positionals, unknown } = parseArgs(GET_FLAG_NAMES, rest.filter((arg) => arg !== '--shared-with-me'));
3082
+ if (sharedWithMeCount === 1)
3083
+ flags.sharedWithMe = '1';
2875
3084
  if (unknown) {
2876
3085
  process.stderr.write(`Unknown flag: ${unknown}\n`);
2877
3086
  process.exitCode = EXIT.USAGE;
@@ -2905,6 +3114,11 @@ const get = async (cfg, noun, rest) => {
2905
3114
  return;
2906
3115
  }
2907
3116
  if (flags.revision !== undefined) {
3117
+ if (flags.by !== undefined || /^https?:\/\//i.test(id)) {
3118
+ process.stderr.write('A revision read uses your own id or prefix; --by and website addresses do not apply.\n');
3119
+ process.exitCode = EXIT.USAGE;
3120
+ return;
3121
+ }
2908
3122
  const walkFlag = ['links', 'depth', 'content'].find((k) => flags[k] !== undefined);
2909
3123
  if (walkFlag !== undefined) {
2910
3124
  process.stderr.write(`--${walkFlag} does not apply to a revision read.\n`);
@@ -2914,7 +3128,7 @@ const get = async (cfg, noun, rest) => {
2914
3128
  await getRevision(cfg, id, flags.revision);
2915
3129
  return;
2916
3130
  }
2917
- await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content });
3131
+ await getMemory(cfg, id, { links: flags.links, depth: flags.depth, content: flags.content }, flags.by);
2918
3132
  }
2919
3133
  else if (noun === 'memories') {
2920
3134
  // The symmetric refusal: a walk lever on a list would be parsed clean and
@@ -2925,10 +3139,22 @@ const get = async (cfg, noun, rest) => {
2925
3139
  process.exitCode = EXIT.USAGE;
2926
3140
  return;
2927
3141
  }
2928
- await getMemories(cfg, flags);
3142
+ if (flags.by !== undefined) {
3143
+ const ownerOnly = ['type', 'intent', 'q', 'with', 'sharedWithMe'].find((key) => flags[key] !== undefined);
3144
+ if (ownerOnly !== undefined) {
3145
+ const flagName = ownerOnly === 'sharedWithMe' ? 'shared-with-me' : ownerOnly;
3146
+ process.stderr.write(`--${flagName} does not apply to a wall listing.\n`);
3147
+ process.exitCode = EXIT.USAGE;
3148
+ return;
3149
+ }
3150
+ await getWallMemories(cfg, flags.by, flags);
3151
+ }
3152
+ else {
3153
+ await getMemories(cfg, flags);
3154
+ }
2929
3155
  }
2930
3156
  else {
2931
- process.stderr.write('An id (or unique prefix) is required for a single memory. To list them all: lattris get memories\n');
3157
+ process.stderr.write('A website address or id is required for a single memory. To list your own: lattris get memories\n');
2932
3158
  process.exitCode = EXIT.USAGE;
2933
3159
  }
2934
3160
  };
@@ -2936,6 +3162,10 @@ const get = async (cfg, noun, rest) => {
2936
3162
  // like a note's content. Everything else is API-constrained shape.
2937
3163
  export const formatQuestion = (q) => {
2938
3164
  const lines = [`id ${q.id}`, `createdAt ${q.createdAt}`];
3165
+ if (q.kind === 'share') {
3166
+ lines.push(`shared by @${sanitizeForTerminal(q.sharerHandle ?? '(unknown)')}`, `memory ${q.aboutBlockId ?? '(missing)'}`, `seen ${q.seenAt ?? '(unseen)'}`);
3167
+ return lines.join('\n');
3168
+ }
2939
3169
  if (q.aboutBlockId)
2940
3170
  lines.push(`about ${q.aboutBlockId}`);
2941
3171
  lines.push(q.closedAs === null
@@ -2945,7 +3175,7 @@ export const formatQuestion = (q) => {
2945
3175
  // here, but the same sanitize-before-TTY law applies to any rendered field.
2946
3176
  if (q.closeNote)
2947
3177
  lines.push(`note ${sanitizeForTerminal(q.closeNote, Infinity)}`);
2948
- lines.push('', sanitizeForTerminal(q.body, Infinity));
3178
+ lines.push('', sanitizeForTerminal(q.body ?? '', Infinity));
2949
3179
  return lines.join('\n');
2950
3180
  };
2951
3181
  export const formatQuestionList = (qs) => qs.map((q) => formatQuestion(q)).join('\n\n');
@@ -2965,9 +3195,6 @@ const getQuestions = async (cfg, mine) => {
2965
3195
  return;
2966
3196
  }
2967
3197
  emit(result.data, result.rawText, formatQuestionList);
2968
- if (result.data.length === 0) {
2969
- process.stderr.write(mine ? 'No questions asked yet.\n' : 'No questions.\n');
2970
- }
2971
3198
  };
2972
3199
  // ask — POST /by/:handle/questions. THE BODY IS THE GRANT (v1 doctrine): the
2973
3200
  // prose from stdin is exactly what crosses to the tree's owner — no
@@ -4170,9 +4397,9 @@ const update = async (cfg, noun, rest) => {
4170
4397
  // touches it. So a link is not admission, and the CLI says so rather than
4171
4398
  // letting a person discover it at the door.
4172
4399
  //
4173
- // Minting is unrestricted: once you are in, you can mint. There is no quota, and
4174
- // revoking one link does not stop you minting another — the link is dead, the
4175
- // person is not.
4400
+ // Minting is open only on accounts with the server-side entitlement. The
4401
+ // server's refusal message rides the shared API error path unchanged. There is
4402
+ // no quota once open, and revoking one link does not close the door.
4176
4403
  export const INVITE_HELP = [
4177
4404
  'invite — bring one person in.',
4178
4405
  '',
@@ -4185,13 +4412,14 @@ export const INVITE_HELP = [
4185
4412
  'when you send it.',
4186
4413
  '',
4187
4414
  'Usage:',
4188
- ' lattris invite mints, prints the link',
4189
- ' lattris invite --for "Josh" the same, with your own label',
4415
+ ' lattris invite <contact-name-or-id> [--yes]',
4416
+ ' lattris invite --for <name-or-id> [--yes] compatibility spelling',
4417
+ '',
4418
+ 'The contact must be one of yours. A name is matched exactly, ignoring case.',
4419
+ 'If that name does not exist, a person contact is created before minting.',
4420
+ 'Duplicate names are never guessed: use the contact id shown by',
4421
+ '`lattris contact list`.',
4190
4422
  '',
4191
- ' --for optional; YOUR label for who this is for, so you can tell your',
4192
- ' outstanding links apart. It is shown on the invitation page and',
4193
- ' to you. It is not their name and not contact information — their',
4194
- ' real name comes from them, at signup.',
4195
4423
  ' --yes required when not at a terminal (there is no prompt to answer',
4196
4424
  ' there); at a terminal you are always asked.',
4197
4425
  '',
@@ -4218,6 +4446,22 @@ const formatInvite = (cfg, i) => {
4218
4446
  return `revoked${label}\n minted ${i.createdAt}`;
4219
4447
  return `live${label}\n ${inviteLink(cfg, i.token)}\n minted ${i.createdAt}`;
4220
4448
  };
4449
+ // Names are labels, so exact duplicate labels must never be guessed. UUIDs are
4450
+ // ids, never names: an unknown UUID refuses instead of creating a strangely
4451
+ // named contact that only looks like an existing id.
4452
+ export const resolveInviteContact = (contacts, selector) => {
4453
+ if (isUuidKey(selector)) {
4454
+ const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
4455
+ return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
4456
+ }
4457
+ const folded = selector.toLowerCase();
4458
+ const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
4459
+ if (matches.length === 1)
4460
+ return { kind: 'found', contact: matches[0] };
4461
+ if (matches.length > 1)
4462
+ return { kind: 'ambiguous', matches };
4463
+ return { kind: 'missing-name', displayName: selector };
4464
+ };
4221
4465
  const invite = async (cfg, rest) => {
4222
4466
  if (rest.some((t) => HELP_TOKENS.has(t))) {
4223
4467
  process.stdout.write(`${INVITE_HELP}\n`);
@@ -4230,25 +4474,25 @@ const invite = async (cfg, rest) => {
4230
4474
  process.exitCode = EXIT.USAGE;
4231
4475
  return;
4232
4476
  }
4233
- if (positionals.length > 0) {
4234
- process.stderr.write(`Unexpected argument: ${positionals[0]}\n`);
4477
+ if (positionals.length > 1 || (positionals.length === 1 && flags.for !== undefined)) {
4478
+ process.stderr.write(`${INVITE_HELP}\n`);
4479
+ process.exitCode = EXIT.USAGE;
4480
+ return;
4481
+ }
4482
+ const selector = positionals[0] ?? flags.for;
4483
+ if (selector === undefined || selector.trim().length === 0) {
4484
+ process.stderr.write('A contact name or id is required.\n');
4235
4485
  process.exitCode = EXIT.USAGE;
4236
4486
  return;
4237
4487
  }
4238
4488
  // Same law as delete and born-public capture: an AI proposes, it cannot
4239
- // commit. A TTY is asked; a non-TTY caller must pass --yes. Minting is not
4240
- // destructive, but it spends something real — a slot a person will hand to
4241
- // someone — and it should never happen because a model thought it helpful.
4489
+ // commit. A non-TTY caller must make the person's approval explicit.
4242
4490
  if (!process.stdin.isTTY && !yes) {
4243
4491
  process.stderr.write('Refusing to mint without confirmation. Pass --yes once the person has ' +
4244
4492
  'asked you to invite someone.\n');
4245
4493
  process.exitCode = EXIT.USAGE;
4246
4494
  return;
4247
4495
  }
4248
- if (process.stdin.isTTY && !(await confirm('Mint an invitation to hand to one person?'))) {
4249
- process.stdout.write('Not minted.\n');
4250
- return;
4251
- }
4252
4496
  let accessToken;
4253
4497
  try {
4254
4498
  accessToken = await validAccessToken(cfg);
@@ -4258,16 +4502,64 @@ const invite = async (cfg, rest) => {
4258
4502
  process.exitCode = EXIT.NOAUTH;
4259
4503
  return;
4260
4504
  }
4505
+ const contactsResult = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
4506
+ if (!contactsResult.ok) {
4507
+ process.exitCode = contactsResult.exit;
4508
+ return;
4509
+ }
4510
+ const resolution = resolveInviteContact(contactsResult.data, selector.trim());
4511
+ if (resolution.kind === 'missing-id') {
4512
+ process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
4513
+ process.exitCode = EXIT.GENERIC;
4514
+ return;
4515
+ }
4516
+ if (resolution.kind === 'ambiguous') {
4517
+ process.stderr.write(`${sanitizeForTerminal(selector)} matches more than one of your contacts:\n${resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n')}\nUse a contact id instead.\n`);
4518
+ process.exitCode = EXIT.USAGE;
4519
+ return;
4520
+ }
4521
+ const displayName = resolution.kind === 'found' ? resolution.contact.displayName : resolution.displayName;
4522
+ if (resolution.kind === 'found' && resolution.contact.type !== 'person') {
4523
+ process.stderr.write(`${sanitizeForTerminal(displayName)} is an AI contact. An invitation must go to a person contact.\n`);
4524
+ process.exitCode = EXIT.USAGE;
4525
+ return;
4526
+ }
4527
+ const action = resolution.kind === 'missing-name'
4528
+ ? `Create person contact "${sanitizeForTerminal(displayName)}" and mint their invitation?`
4529
+ : `Mint an invitation for ${sanitizeForTerminal(displayName)}?`;
4530
+ if (process.stdin.isTTY && !(await confirm(action))) {
4531
+ process.stdout.write('Not minted.\n');
4532
+ return;
4533
+ }
4534
+ let contact;
4535
+ let createdContact = false;
4536
+ if (resolution.kind === 'missing-name') {
4537
+ const created = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`, {
4538
+ method: 'POST',
4539
+ headers: { 'Content-Type': 'application/json' },
4540
+ body: JSON.stringify({ displayName, type: 'person' })
4541
+ });
4542
+ if (!created.ok) {
4543
+ process.exitCode = created.exit;
4544
+ return;
4545
+ }
4546
+ contact = created.data;
4547
+ createdContact = true;
4548
+ }
4549
+ else {
4550
+ contact = resolution.contact;
4551
+ }
4261
4552
  const result = await apiFetch(accessToken, `${cfg.apiUrl}/invites`, {
4262
4553
  method: 'POST',
4263
4554
  headers: { 'Content-Type': 'application/json' },
4264
- body: JSON.stringify(flags.for ? { targetName: flags.for } : {})
4555
+ body: JSON.stringify({ contactId: contact.id })
4265
4556
  });
4266
4557
  if (!result.ok) {
4267
4558
  process.exitCode = result.exit;
4268
4559
  return;
4269
4560
  }
4270
- emit(result.data, result.rawText, (i) => `Invitation minted${i.targetName ? ` for ${i.targetName}` : ''}.\n\n` +
4561
+ emit(result.data, result.rawText, (i) => `${createdContact ? `Recorded person contact ${formatContact(contact)}.\n\n` : ''}` +
4562
+ `Invitation minted${i.targetName ? ` for ${i.targetName}` : ''}.\n\n` +
4271
4563
  ` ${inviteLink(cfg, i.token)}\n\n` +
4272
4564
  `Hand it to ONE person — it is spent when someone signs up with it.\n` +
4273
4565
  `It is not permission to join; whether they may register is decided separately.\n` +
@@ -4289,7 +4581,7 @@ const getInvites = async (cfg) => {
4289
4581
  return;
4290
4582
  }
4291
4583
  emit(result.data, result.rawText, (list) => list.length === 0
4292
- ? 'No invitations yet. `lattris invite` mints one.'
4584
+ ? 'No invitations yet. `lattris invite <contact>` mints one.'
4293
4585
  : list.map((i) => formatInvite(cfg, i)).join('\n\n'));
4294
4586
  };
4295
4587
  // ─── share (memory <id> --with <audience>) ─────────────────────────────────────
@@ -4305,17 +4597,36 @@ const describeAudience = (audience) => audience === 'public' ? 'public — anyon
4305
4597
  const SHARE_FLAG_NAMES = {
4306
4598
  '--with': 'with'
4307
4599
  };
4600
+ const resolveExistingPersonContact = async (cfg, accessToken, selector) => {
4601
+ const result = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
4602
+ if (!result.ok) {
4603
+ process.exitCode = result.exit;
4604
+ return undefined;
4605
+ }
4606
+ const resolution = resolveInviteContact(result.data, selector.trim());
4607
+ if (resolution.kind === 'ambiguous') {
4608
+ process.stderr.write(`${sanitizeForTerminal(selector)} matches more than one of your contacts:\n${resolution.matches.map((c) => ` ${formatContact(c)}`).join('\n')}\nUse a contact id instead.\n`);
4609
+ process.exitCode = EXIT.USAGE;
4610
+ return undefined;
4611
+ }
4612
+ if (resolution.kind !== 'found') {
4613
+ process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
4614
+ process.exitCode = EXIT.GENERIC;
4615
+ return undefined;
4616
+ }
4617
+ if (resolution.contact.type !== 'person' || resolution.contact.claimedPersonId == null) {
4618
+ process.stderr.write('This person has no account yet — invite them first.\n');
4619
+ process.exitCode = EXIT.USAGE;
4620
+ return undefined;
4621
+ }
4622
+ return { ...resolution.contact, claimedPersonId: resolution.contact.claimedPersonId };
4623
+ };
4308
4624
  const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4309
4625
  // ALL local preconditions settle before any network/token touch — same law
4310
4626
  // as delete: a question that was always going to be refused must never
4311
4627
  // spend a token refresh first.
4312
4628
  if (audience === undefined) {
4313
- process.stderr.write(`--with is required. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
4314
- process.exitCode = EXIT.USAGE;
4315
- return;
4316
- }
4317
- if (!SHARE_AUDIENCES.includes(audience)) {
4318
- process.stderr.write(`Unknown audience: ${audience}. Accepted: ${SHARE_AUDIENCES.join(', ')}\n`);
4629
+ process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
4319
4630
  process.exitCode = EXIT.USAGE;
4320
4631
  return;
4321
4632
  }
@@ -4337,6 +4648,11 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4337
4648
  // the confirm fetch and share by the FULL id from then on — not the raw
4338
4649
  // (possibly a prefix) argument again.
4339
4650
  let shareId = idOrPrefix;
4651
+ const contact = audience === 'public'
4652
+ ? undefined
4653
+ : await resolveExistingPersonContact(cfg, accessToken, audience);
4654
+ if (audience !== 'public' && !contact)
4655
+ return;
4340
4656
  if (process.stdin.isTTY) {
4341
4657
  const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
4342
4658
  if (!fetched.ok) {
@@ -4345,7 +4661,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4345
4661
  return;
4346
4662
  }
4347
4663
  const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
4348
- const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(audience)}? [y/N] `);
4664
+ const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(contact?.displayName ?? audience)}? [y/N] `);
4349
4665
  if (confirmed === 'interrupted') {
4350
4666
  process.stderr.write('Cancelled.\n');
4351
4667
  process.exitCode = EXIT.GENERIC;
@@ -4360,7 +4676,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4360
4676
  const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(shareId)}/grants`, {
4361
4677
  method: 'POST',
4362
4678
  headers: { 'Content-Type': 'application/json' },
4363
- body: JSON.stringify({ audience })
4679
+ body: JSON.stringify(contact ? { kind: 'person', contactId: contact.id } : { audience: 'public' })
4364
4680
  });
4365
4681
  if (!result.ok) {
4366
4682
  renderAmbiguousIfPresent(result.body);
@@ -4373,15 +4689,15 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4373
4689
  // wire, byte-verbatim (emit's non-TTY branch); a TTY gets the human line
4374
4690
  // instead. shareId (not the response body, which never echoes an id) is
4375
4691
  // what makes the human line locatable — closed over, not a second field.
4376
- emit(result.data, result.rawText, (grant) => `Shared memory ${shareId} with ${grant.kind}`);
4692
+ emit(result.data, result.rawText, (grant) => `Shared memory ${shareId} with ${contact?.displayName ?? grant.kind}`);
4377
4693
  };
4378
4694
  export const SHARE_HELP = [
4379
- 'share — grant an audience access to a memory.',
4695
+ 'share — grant public or a person contact access to a memory.',
4380
4696
  '',
4381
4697
  'Usage:',
4382
- ' lattris share memory <id-or-prefix> --with <audience> [--yes]',
4698
+ ' lattris share memory <id-or-prefix> --with <public-or-contact> [--yes]',
4383
4699
  '',
4384
- ' --with <audience> required. v1 accepts: public',
4700
+ ' --with public, or an exact contact name or id from `lattris contact list`.',
4385
4701
  ' --yes required when not at a terminal; at a terminal you',
4386
4702
  ' are always asked, --yes or not.',
4387
4703
  '',
@@ -4415,29 +4731,10 @@ const share = async (cfg, noun, rest) => {
4415
4731
  }
4416
4732
  await shareMemory(cfg, positionals[0], flags.with, yes);
4417
4733
  };
4418
- // ─── unshare (memory <id> --with <audience>) ───────────────────────────────────
4419
- // Withdraws an audience — v1 accepts only "public". No confirmation: spec
4420
- // rules withdrawal the safe direction (recoverable by re-sharing). The
4421
- // record is ALWAYS resolved first — needed either way: to withdraw by the
4422
- // FULL id (never re-resolve a prefix, same law as share/delete), or to echo
4423
- // its current audiences + title in the bare-unshare refusal (owner-only
4424
- // operation, so the echo leaks nothing this caller couldn't already see via
4425
- // a plain get).
4426
- export const UNSHARE_AUDIENCES = ['public'];
4427
4734
  const UNSHARE_FLAG_NAMES = {
4428
4735
  '--with': 'with'
4429
4736
  };
4430
4737
  const unshareMemory = async (cfg, idOrPrefix, audience) => {
4431
- // A PRESENT but unknown audience is a pure value check — it needs nothing
4432
- // fetched, so it settles before any network/token touch like every other
4433
- // local precondition in this file. A MISSING audience is different: its
4434
- // refusal echoes the record's current state, which only the fetch below
4435
- // can supply, so that one stays gated behind it.
4436
- if (audience !== undefined && !UNSHARE_AUDIENCES.includes(audience)) {
4437
- process.stderr.write(`Unknown audience: ${audience}. Accepted: ${UNSHARE_AUDIENCES.join(', ')}\n`);
4438
- process.exitCode = EXIT.USAGE;
4439
- return;
4440
- }
4441
4738
  let accessToken;
4442
4739
  try {
4443
4740
  accessToken = await validAccessToken(cfg);
@@ -4447,6 +4744,11 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
4447
4744
  process.exitCode = EXIT.NOAUTH;
4448
4745
  return;
4449
4746
  }
4747
+ const contact = audience === undefined || audience === 'public'
4748
+ ? undefined
4749
+ : await resolveExistingPersonContact(cfg, accessToken, audience);
4750
+ if (audience !== undefined && audience !== 'public' && !contact)
4751
+ return;
4450
4752
  const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
4451
4753
  if (!fetched.ok) {
4452
4754
  renderAmbiguousIfPresent(fetched.body);
@@ -4455,26 +4757,28 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
4455
4757
  }
4456
4758
  if (audience === undefined) {
4457
4759
  const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
4458
- process.stderr.write(`--with is required. Accepted: ${UNSHARE_AUDIENCES.join(', ')}\n`);
4760
+ process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
4459
4761
  process.stderr.write(`"${title}" (${fetched.data.id}) is currently shared with: ${formatAudiences(fetched.data.audiences)}\n`);
4460
4762
  process.exitCode = EXIT.USAGE;
4461
4763
  return;
4462
4764
  }
4463
- const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/${audience}`, { method: 'DELETE' });
4765
+ const result = await apiFetch(accessToken, contact
4766
+ ? `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/person/${contact.claimedPersonId}`
4767
+ : `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/public`, { method: 'DELETE' });
4464
4768
  if (!result.ok) {
4465
4769
  process.exitCode = result.exit;
4466
4770
  return;
4467
4771
  }
4468
- process.stderr.write(`Unshared memory ${fetched.data.id} from ${audience}\n`);
4772
+ process.stderr.write(`Unshared memory ${fetched.data.id} from ${contact?.displayName ?? 'public'}\n`);
4469
4773
  };
4470
4774
  export const UNSHARE_HELP = [
4471
4775
  'unshare — withdraw an audience from a memory. No confirmation — withdrawal',
4472
4776
  'is the safe direction.',
4473
4777
  '',
4474
4778
  'Usage:',
4475
- ' lattris unshare memory <id-or-prefix> --with <audience>',
4779
+ ' lattris unshare memory <id-or-prefix> --with <public-or-contact>',
4476
4780
  '',
4477
- ' --with <audience> required. v1 accepts: public',
4781
+ ' --with public, or an exact contact name or id from `lattris contact list`.',
4478
4782
  '',
4479
4783
  'Bare (no --with): refuses and shows what the memory is currently shared',
4480
4784
  'with, so you can see the state and the fix in one line.',
@@ -4505,6 +4809,58 @@ const unshare = async (cfg, noun, rest) => {
4505
4809
  }
4506
4810
  await unshareMemory(cfg, positionals[0], flags.with);
4507
4811
  };
4812
+ // ─── consent (<shared-memory-id> [--withdraw]) ─────────────────────────────
4813
+ export const CONSENT_HELP = [
4814
+ 'consent — choose whether a memory shared with you appears on your graph.',
4815
+ '',
4816
+ 'Usage:',
4817
+ ' lattris consent <id-or-prefix>',
4818
+ ' lattris consent <id-or-prefix> --withdraw',
4819
+ '',
4820
+ 'The choice is yours alone. Withdrawing a share hides the block regardless;',
4821
+ 'if its owner shares it again later, your previous choice still stands.'
4822
+ ].join('\n');
4823
+ const consent = async (cfg, rest) => {
4824
+ if (rest.some((arg) => HELP_TOKENS.has(arg))) {
4825
+ process.stdout.write(`${CONSENT_HELP}\n`);
4826
+ return;
4827
+ }
4828
+ const withdrawCount = rest.filter((arg) => arg === '--withdraw').length;
4829
+ const args = rest.filter((arg) => arg !== '--withdraw');
4830
+ const unknown = args.find((arg) => arg.startsWith('--'));
4831
+ if (unknown !== undefined) {
4832
+ process.stderr.write(`Unknown flag: ${unknown}\n`);
4833
+ process.exitCode = EXIT.USAGE;
4834
+ return;
4835
+ }
4836
+ if (withdrawCount > 1 || args.length !== 1) {
4837
+ process.stderr.write(`Usage: lattris consent <id-or-prefix> [--withdraw]\n`);
4838
+ process.exitCode = EXIT.USAGE;
4839
+ return;
4840
+ }
4841
+ let accessToken;
4842
+ try {
4843
+ accessToken = await validAccessToken(cfg);
4844
+ }
4845
+ catch {
4846
+ renderNotLoggedIn();
4847
+ process.exitCode = EXIT.NOAUTH;
4848
+ return;
4849
+ }
4850
+ const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(args[0])}/consent`, {
4851
+ method: 'PUT',
4852
+ headers: { 'Content-Type': 'application/json' },
4853
+ body: JSON.stringify({ consented: withdrawCount === 0 })
4854
+ });
4855
+ if (!result.ok) {
4856
+ renderAmbiguousIfPresent(result.body);
4857
+ process.exitCode = result.exit;
4858
+ return;
4859
+ }
4860
+ emit(result.data, result.rawText, (state) => state.consented
4861
+ ? `Memory ${args[0]} may appear on your graph.`
4862
+ : `Memory ${args[0]} will stay off your graph.`);
4863
+ };
4508
4864
  // ─── capture recording (train 8) ────────────────────────────────────────────
4509
4865
  // The API meets ONE recording shape; vendor transcript formats translate HERE,
4510
4866
  // CLI-side, so the door never grows a per-vendor arm. A recording is one door,
@@ -5889,7 +6245,7 @@ const usage = () => {
5889
6245
  ' lattris capture session <transcript> Save an AI conversation — the whole chain from its file',
5890
6246
  ' lattris get memories List your memories, newest first, with filters',
5891
6247
  ' lattris contact create "<name>" Record a person or an AI you work with (--ai); list, get, update',
5892
- ' lattris invite Mint an invitation to hand to one person',
6248
+ ' lattris invite <contact> Mint an invitation for one of your person contacts',
5893
6249
  ' lattris get invites List the invitations you have minted',
5894
6250
  ' lattris get memory <id> Read one memory back \u2014 words and edges (--links/--depth/--content)',
5895
6251
  ' lattris ask <handle> Leave a question on someone\u2019s tree \u2014 `echo "\u2026" | lattris ask <handle>`',
@@ -5897,6 +6253,7 @@ const usage = () => {
5897
6253
  ' lattris close question <id> Close a question (--close-as updated|declined|already-answered)',
5898
6254
  ' lattris share memory <id> Grant an audience access (--with public)',
5899
6255
  ' lattris unshare memory <id> Withdraw an audience (--with public)',
6256
+ ' lattris consent <id> Put a memory shared with you on your graph (--withdraw reverses)',
5900
6257
  ' lattris link <from> <to> State that one memory leads to another (--verb, --reason)',
5901
6258
  ' lattris unlink <from> <to> Remove that claim (--verb; the blocks stay)',
5902
6259
  ' lattris update memory <id> Correct a memory — `echo "…" | lattris update memory <id> --base-fingerprint <fp>`',
@@ -6420,11 +6777,13 @@ export const main = async () => {
6420
6777
  process.env.LATTRIS_HOME = asResolvedDir;
6421
6778
  }
6422
6779
  }
6423
- // Resolve the environment name: --env > .lattris env= > LATTRIS_ENV > "local".
6780
+ // Resolve the environment name: --env > .lattris env= > LATTRIS_ENV > the
6781
+ // install-aware fallback (published package = prod; worktree/source = local).
6424
6782
  // Must run AFTER --as (which sets LATTRIS_HOME, affecting resolveSessionHome
6425
6783
  // which findBinding reads for the env= field).
6426
6784
  const home = resolveSessionHome();
6427
- resolvedEnvName = envName ?? home.bindingEnv ?? process.env.LATTRIS_ENV ?? 'local';
6785
+ resolvedEnvName =
6786
+ envName ?? home.bindingEnv ?? process.env.LATTRIS_ENV ?? defaultEnvironmentForEntry();
6428
6787
  envWasExplicit =
6429
6788
  envName !== undefined || home.bindingEnv !== undefined || process.env.LATTRIS_ENV !== undefined;
6430
6789
  const forceFlag = argv.includes('--force');
@@ -6445,10 +6804,14 @@ export const main = async () => {
6445
6804
  ? VERB_ALIASES[rawCommand]
6446
6805
  : rawCommand
6447
6806
  : rawCommand;
6448
- // HELP BEFORE EVERYTHING: a help token in the subcommand's args means
6449
- // help — never a session check, never a network call, exit 0. Commands
6450
- // whose own handler already treats help first return null and fall
6451
- // through unchanged.
6807
+ void checkForNewVersion({
6808
+ currentVersion: currentPackageVersion(),
6809
+ stateDir: join(stateHome(), 'lattris')
6810
+ });
6811
+ // HELP BEFORE COMMAND WORK: a help token in the subcommand's args means
6812
+ // help — never a session or API call, exit 0. The best-effort registry
6813
+ // courtesy above is startup work rather than command work. Commands whose
6814
+ // own handler already treats help first return null and fall through unchanged.
6452
6815
  const subArgs = argv.slice(3);
6453
6816
  if (command !== undefined && subArgs.some((t) => HELP_TOKENS.has(t))) {
6454
6817
  const help = commandHelpText(command, subArgs);
@@ -6525,6 +6888,9 @@ export const main = async () => {
6525
6888
  case 'unshare':
6526
6889
  await unshare(cfg, argv[3], argv.slice(4));
6527
6890
  break;
6891
+ case 'consent':
6892
+ await consent(cfg, argv.slice(3));
6893
+ break;
6528
6894
  case 'contact':
6529
6895
  await contact(cfg, argv[3], argv.slice(4));
6530
6896
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattris",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "First-party CLI for Lattris — sign in, capture notes and sessions, and read your own tree from the terminal.",
6
6
  "keywords": [
@@ -11,6 +11,11 @@
11
11
  ],
12
12
  "license": "UNLICENSED",
13
13
  "homepage": "https://lattris.com",
14
+ "scripts": {
15
+ "test": "node ../../../node_modules/vitest/vitest.mjs run --config vite.config.ts",
16
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && node ../../../node_modules/typescript/bin/tsc -p tsconfig.json",
17
+ "release": "node scripts/release.mjs"
18
+ },
14
19
  "bin": {
15
20
  "lattris": "./dist/main.js"
16
21
  },