lattris 0.1.0 → 0.1.2

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,4 @@
1
+ {
2
+ "commit": "e813390b3f7e3c6eed78e607fed6125a10c9cf2c",
3
+ "builtAt": "2026-09-12T04:33:05.921Z"
4
+ }
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',
@@ -1201,11 +1215,10 @@ export const apiFetch = async (accessToken, url, init = {}) => {
1201
1215
  // ─── the client-makes-no-decisions output rule ──────────────────────────────────
1202
1216
  // A person reading a terminal and a program reading a pipe want different things
1203
1217
  // 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).
1218
+ // wants the API's wire body without re-stringification. Piped output preserves
1219
+ // the response bytes (key order, whitespace and number formatting included) and
1220
+ // adds only a missing final newline, so successive shell output cannot run into
1221
+ // the JSON. A response that already ends in a newline is unchanged.
1209
1222
  // renderHuman picks what a person needs to see; it never changes what a machine
1210
1223
  // receives. An empty human rendering (e.g. no memories) writes nothing rather
1211
1224
  // than a stray blank line.
@@ -1216,7 +1229,7 @@ export const emit = (data, rawText, renderHuman) => {
1216
1229
  process.stdout.write(`${human}\n`);
1217
1230
  }
1218
1231
  else {
1219
- process.stdout.write(rawText);
1232
+ process.stdout.write(rawText.endsWith('\n') ? rawText : `${rawText}\n`);
1220
1233
  }
1221
1234
  };
1222
1235
  // The product word is "lattris number", never
@@ -2343,7 +2356,9 @@ export const formatAudiences = (audiences) => audiences.length === 0 ? '(private
2343
2356
  // title is the caller's own free text — sanitized before a TTY renders it (the
2344
2357
  // piped/JSON path never calls this formatter). id/capturedAt/intent are all
2345
2358
  // 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)')}`;
2359
+ export const formatBlockLine = (b) => b.ownerHandle === undefined
2360
+ ? `${b.id} ${b.handle} ${b.capturedAt} ${b.intent ?? '-'} ${b.visibility.public ? 'public' : '-'} ${sanitizeForTerminal(b.title ?? '(untitled)')}`
2361
+ : `${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
2362
  // ─── composing a memory's web address ──────────────────────────────────────────
2348
2363
  // The client COMPOSES an address; only the server RESOLVES one, and it resolves
2349
2364
  // on the block handle alone. That split is why carrying these rules here is
@@ -2580,7 +2595,7 @@ const GET_FLAG_NAMES = {
2580
2595
  };
2581
2596
  // Which side of get each flag belongs to — the applicability refusals in the
2582
2597
  // 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'];
2598
+ const LIST_ONLY_FLAGS = ['type', 'intent', 'limit', 'cursor', 'q', 'with', 'sharedWithMe'];
2584
2599
  // --content is the shape lever on BOTH reads: on the one memory it drops the
2585
2600
  // words, on the list it drops the excerpts. The rest of these are the walk's.
2586
2601
  const WALK_ONLY_FLAGS = ['links', 'depth', 'revision'];
@@ -2614,16 +2629,15 @@ const getMemories = async (cfg, flags) => {
2614
2629
  url.searchParams.set('audience', flags.with);
2615
2630
  if (flags.content !== undefined)
2616
2631
  url.searchParams.set('content', flags.content);
2632
+ if (flags.sharedWithMe)
2633
+ url.searchParams.set('sharedWithMe', '1');
2617
2634
  const result = await apiFetch(accessToken, url);
2618
2635
  if (!result.ok) {
2619
2636
  process.exitCode = result.exit;
2620
2637
  return;
2621
2638
  }
2622
2639
  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) {
2640
+ if (result.data.nextCursor) {
2627
2641
  // Reconstruct the FULL continuation — active filters + the new cursor — so
2628
2642
  // copy-pasting the hint never silently drops back to an unfiltered page.
2629
2643
  // ARGV elements through the one renderer, never a preformed string: a
@@ -2641,6 +2655,8 @@ const getMemories = async (cfg, flags) => {
2641
2655
  argv.push('--limit', flags.limit);
2642
2656
  if (flags.content !== undefined)
2643
2657
  argv.push('--content', flags.content);
2658
+ if (flags.sharedWithMe)
2659
+ argv.push('--shared-with-me');
2644
2660
  argv.push('--cursor', result.data.nextCursor);
2645
2661
  process.stderr.write(`More: ${renderRunHint(argv)}\n`);
2646
2662
  }
@@ -2660,7 +2676,7 @@ export const GET_HELP = [
2660
2676
  ' questions the questions left on your tree, open first',
2661
2677
  '',
2662
2678
  'Usage:',
2663
- ' lattris get memories [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
2679
+ ' lattris get memories [--shared-with-me] [--q <words>] [--type <name>] [--intent <name>] [--with <audience>] [--limit <n>] [--cursor <token>] [--content 0|1]',
2664
2680
  ' lattris get memory <id-or-prefix> [--links 0|1] [--depth <n>] [--content 0|1]',
2665
2681
  ' lattris get memory <id-or-prefix> --revision <n> one revision, as history kept it',
2666
2682
  ' lattris get history <id-or-prefix> [--limit <n>] [--before <operation-id>]',
@@ -2678,6 +2694,8 @@ export const GET_HELP = [
2678
2694
  ' (private — no grant at all)',
2679
2695
  ' --limit <n> page size, 1-100 (default 50)',
2680
2696
  ' --cursor <token> the nextCursor from a previous page',
2697
+ ' --shared-with-me list memories other people granted to you; each row',
2698
+ ' names its owner and whether it is on your graph',
2681
2699
  '',
2682
2700
  ' --links 0|1 edges on the one memory (default 1): direction, verb,',
2683
2701
  ' reason, and each neighbour\u2019s finding aid. --links 0',
@@ -2792,6 +2810,8 @@ export const commandHelpText = (command, args) => {
2792
2810
  return ERASE_HELP;
2793
2811
  case 'contact':
2794
2812
  return CONTACT_HELP;
2813
+ case 'consent':
2814
+ return CONSENT_HELP;
2795
2815
  case 'capture': {
2796
2816
  // The form's own help when a form was named — mirroring the
2797
2817
  // sub-dispatcher's routing (positional 'session' OR --type session);
@@ -2871,7 +2891,15 @@ const get = async (cfg, noun, rest) => {
2871
2891
  await getDeleted(cfg, rest);
2872
2892
  return;
2873
2893
  }
2874
- const { flags, positionals, unknown } = parseArgs(GET_FLAG_NAMES, rest);
2894
+ const sharedWithMeCount = rest.filter((arg) => arg === '--shared-with-me').length;
2895
+ if (sharedWithMeCount > 1) {
2896
+ process.stderr.write('--shared-with-me was given more than once. Send it once.\n');
2897
+ process.exitCode = EXIT.USAGE;
2898
+ return;
2899
+ }
2900
+ const { flags, positionals, unknown } = parseArgs(GET_FLAG_NAMES, rest.filter((arg) => arg !== '--shared-with-me'));
2901
+ if (sharedWithMeCount === 1)
2902
+ flags.sharedWithMe = '1';
2875
2903
  if (unknown) {
2876
2904
  process.stderr.write(`Unknown flag: ${unknown}\n`);
2877
2905
  process.exitCode = EXIT.USAGE;
@@ -2936,6 +2964,10 @@ const get = async (cfg, noun, rest) => {
2936
2964
  // like a note's content. Everything else is API-constrained shape.
2937
2965
  export const formatQuestion = (q) => {
2938
2966
  const lines = [`id ${q.id}`, `createdAt ${q.createdAt}`];
2967
+ if (q.kind === 'share') {
2968
+ lines.push(`shared by @${sanitizeForTerminal(q.sharerHandle ?? '(unknown)')}`, `memory ${q.aboutBlockId ?? '(missing)'}`, `seen ${q.seenAt ?? '(unseen)'}`);
2969
+ return lines.join('\n');
2970
+ }
2939
2971
  if (q.aboutBlockId)
2940
2972
  lines.push(`about ${q.aboutBlockId}`);
2941
2973
  lines.push(q.closedAs === null
@@ -2945,7 +2977,7 @@ export const formatQuestion = (q) => {
2945
2977
  // here, but the same sanitize-before-TTY law applies to any rendered field.
2946
2978
  if (q.closeNote)
2947
2979
  lines.push(`note ${sanitizeForTerminal(q.closeNote, Infinity)}`);
2948
- lines.push('', sanitizeForTerminal(q.body, Infinity));
2980
+ lines.push('', sanitizeForTerminal(q.body ?? '', Infinity));
2949
2981
  return lines.join('\n');
2950
2982
  };
2951
2983
  export const formatQuestionList = (qs) => qs.map((q) => formatQuestion(q)).join('\n\n');
@@ -2965,9 +2997,6 @@ const getQuestions = async (cfg, mine) => {
2965
2997
  return;
2966
2998
  }
2967
2999
  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
3000
  };
2972
3001
  // ask — POST /by/:handle/questions. THE BODY IS THE GRANT (v1 doctrine): the
2973
3002
  // prose from stdin is exactly what crosses to the tree's owner — no
@@ -4170,9 +4199,9 @@ const update = async (cfg, noun, rest) => {
4170
4199
  // touches it. So a link is not admission, and the CLI says so rather than
4171
4200
  // letting a person discover it at the door.
4172
4201
  //
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.
4202
+ // Minting is open only on accounts with the server-side entitlement. The
4203
+ // server's refusal message rides the shared API error path unchanged. There is
4204
+ // no quota once open, and revoking one link does not close the door.
4176
4205
  export const INVITE_HELP = [
4177
4206
  'invite — bring one person in.',
4178
4207
  '',
@@ -4185,13 +4214,14 @@ export const INVITE_HELP = [
4185
4214
  'when you send it.',
4186
4215
  '',
4187
4216
  'Usage:',
4188
- ' lattris invite mints, prints the link',
4189
- ' lattris invite --for "Josh" the same, with your own label',
4217
+ ' lattris invite <contact-name-or-id> [--yes]',
4218
+ ' lattris invite --for <name-or-id> [--yes] compatibility spelling',
4219
+ '',
4220
+ 'The contact must be one of yours. A name is matched exactly, ignoring case.',
4221
+ 'If that name does not exist, a person contact is created before minting.',
4222
+ 'Duplicate names are never guessed: use the contact id shown by',
4223
+ '`lattris contact list`.',
4190
4224
  '',
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
4225
  ' --yes required when not at a terminal (there is no prompt to answer',
4196
4226
  ' there); at a terminal you are always asked.',
4197
4227
  '',
@@ -4218,6 +4248,22 @@ const formatInvite = (cfg, i) => {
4218
4248
  return `revoked${label}\n minted ${i.createdAt}`;
4219
4249
  return `live${label}\n ${inviteLink(cfg, i.token)}\n minted ${i.createdAt}`;
4220
4250
  };
4251
+ // Names are labels, so exact duplicate labels must never be guessed. UUIDs are
4252
+ // ids, never names: an unknown UUID refuses instead of creating a strangely
4253
+ // named contact that only looks like an existing id.
4254
+ export const resolveInviteContact = (contacts, selector) => {
4255
+ if (isUuidKey(selector)) {
4256
+ const contact = contacts.find((c) => c.id.toLowerCase() === selector.toLowerCase());
4257
+ return contact ? { kind: 'found', contact } : { kind: 'missing-id' };
4258
+ }
4259
+ const folded = selector.toLowerCase();
4260
+ const matches = contacts.filter((c) => c.displayName.toLowerCase() === folded);
4261
+ if (matches.length === 1)
4262
+ return { kind: 'found', contact: matches[0] };
4263
+ if (matches.length > 1)
4264
+ return { kind: 'ambiguous', matches };
4265
+ return { kind: 'missing-name', displayName: selector };
4266
+ };
4221
4267
  const invite = async (cfg, rest) => {
4222
4268
  if (rest.some((t) => HELP_TOKENS.has(t))) {
4223
4269
  process.stdout.write(`${INVITE_HELP}\n`);
@@ -4230,25 +4276,25 @@ const invite = async (cfg, rest) => {
4230
4276
  process.exitCode = EXIT.USAGE;
4231
4277
  return;
4232
4278
  }
4233
- if (positionals.length > 0) {
4234
- process.stderr.write(`Unexpected argument: ${positionals[0]}\n`);
4279
+ if (positionals.length > 1 || (positionals.length === 1 && flags.for !== undefined)) {
4280
+ process.stderr.write(`${INVITE_HELP}\n`);
4281
+ process.exitCode = EXIT.USAGE;
4282
+ return;
4283
+ }
4284
+ const selector = positionals[0] ?? flags.for;
4285
+ if (selector === undefined || selector.trim().length === 0) {
4286
+ process.stderr.write('A contact name or id is required.\n');
4235
4287
  process.exitCode = EXIT.USAGE;
4236
4288
  return;
4237
4289
  }
4238
4290
  // 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.
4291
+ // commit. A non-TTY caller must make the person's approval explicit.
4242
4292
  if (!process.stdin.isTTY && !yes) {
4243
4293
  process.stderr.write('Refusing to mint without confirmation. Pass --yes once the person has ' +
4244
4294
  'asked you to invite someone.\n');
4245
4295
  process.exitCode = EXIT.USAGE;
4246
4296
  return;
4247
4297
  }
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
4298
  let accessToken;
4253
4299
  try {
4254
4300
  accessToken = await validAccessToken(cfg);
@@ -4258,16 +4304,64 @@ const invite = async (cfg, rest) => {
4258
4304
  process.exitCode = EXIT.NOAUTH;
4259
4305
  return;
4260
4306
  }
4307
+ const contactsResult = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
4308
+ if (!contactsResult.ok) {
4309
+ process.exitCode = contactsResult.exit;
4310
+ return;
4311
+ }
4312
+ const resolution = resolveInviteContact(contactsResult.data, selector.trim());
4313
+ if (resolution.kind === 'missing-id') {
4314
+ process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
4315
+ process.exitCode = EXIT.GENERIC;
4316
+ return;
4317
+ }
4318
+ if (resolution.kind === 'ambiguous') {
4319
+ 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`);
4320
+ process.exitCode = EXIT.USAGE;
4321
+ return;
4322
+ }
4323
+ const displayName = resolution.kind === 'found' ? resolution.contact.displayName : resolution.displayName;
4324
+ if (resolution.kind === 'found' && resolution.contact.type !== 'person') {
4325
+ process.stderr.write(`${sanitizeForTerminal(displayName)} is an AI contact. An invitation must go to a person contact.\n`);
4326
+ process.exitCode = EXIT.USAGE;
4327
+ return;
4328
+ }
4329
+ const action = resolution.kind === 'missing-name'
4330
+ ? `Create person contact "${sanitizeForTerminal(displayName)}" and mint their invitation?`
4331
+ : `Mint an invitation for ${sanitizeForTerminal(displayName)}?`;
4332
+ if (process.stdin.isTTY && !(await confirm(action))) {
4333
+ process.stdout.write('Not minted.\n');
4334
+ return;
4335
+ }
4336
+ let contact;
4337
+ let createdContact = false;
4338
+ if (resolution.kind === 'missing-name') {
4339
+ const created = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`, {
4340
+ method: 'POST',
4341
+ headers: { 'Content-Type': 'application/json' },
4342
+ body: JSON.stringify({ displayName, type: 'person' })
4343
+ });
4344
+ if (!created.ok) {
4345
+ process.exitCode = created.exit;
4346
+ return;
4347
+ }
4348
+ contact = created.data;
4349
+ createdContact = true;
4350
+ }
4351
+ else {
4352
+ contact = resolution.contact;
4353
+ }
4261
4354
  const result = await apiFetch(accessToken, `${cfg.apiUrl}/invites`, {
4262
4355
  method: 'POST',
4263
4356
  headers: { 'Content-Type': 'application/json' },
4264
- body: JSON.stringify(flags.for ? { targetName: flags.for } : {})
4357
+ body: JSON.stringify({ contactId: contact.id })
4265
4358
  });
4266
4359
  if (!result.ok) {
4267
4360
  process.exitCode = result.exit;
4268
4361
  return;
4269
4362
  }
4270
- emit(result.data, result.rawText, (i) => `Invitation minted${i.targetName ? ` for ${i.targetName}` : ''}.\n\n` +
4363
+ emit(result.data, result.rawText, (i) => `${createdContact ? `Recorded person contact ${formatContact(contact)}.\n\n` : ''}` +
4364
+ `Invitation minted${i.targetName ? ` for ${i.targetName}` : ''}.\n\n` +
4271
4365
  ` ${inviteLink(cfg, i.token)}\n\n` +
4272
4366
  `Hand it to ONE person — it is spent when someone signs up with it.\n` +
4273
4367
  `It is not permission to join; whether they may register is decided separately.\n` +
@@ -4289,7 +4383,7 @@ const getInvites = async (cfg) => {
4289
4383
  return;
4290
4384
  }
4291
4385
  emit(result.data, result.rawText, (list) => list.length === 0
4292
- ? 'No invitations yet. `lattris invite` mints one.'
4386
+ ? 'No invitations yet. `lattris invite <contact>` mints one.'
4293
4387
  : list.map((i) => formatInvite(cfg, i)).join('\n\n'));
4294
4388
  };
4295
4389
  // ─── share (memory <id> --with <audience>) ─────────────────────────────────────
@@ -4305,17 +4399,36 @@ const describeAudience = (audience) => audience === 'public' ? 'public — anyon
4305
4399
  const SHARE_FLAG_NAMES = {
4306
4400
  '--with': 'with'
4307
4401
  };
4402
+ const resolveExistingPersonContact = async (cfg, accessToken, selector) => {
4403
+ const result = await apiFetch(accessToken, `${cfg.apiUrl}/contacts`);
4404
+ if (!result.ok) {
4405
+ process.exitCode = result.exit;
4406
+ return undefined;
4407
+ }
4408
+ const resolution = resolveInviteContact(result.data, selector.trim());
4409
+ if (resolution.kind === 'ambiguous') {
4410
+ 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`);
4411
+ process.exitCode = EXIT.USAGE;
4412
+ return undefined;
4413
+ }
4414
+ if (resolution.kind !== 'found') {
4415
+ process.stderr.write(`No contact ${sanitizeForTerminal(selector)} among yours. ${renderRunHint(['contact', 'list'])} shows them.\n`);
4416
+ process.exitCode = EXIT.GENERIC;
4417
+ return undefined;
4418
+ }
4419
+ if (resolution.contact.type !== 'person' || resolution.contact.claimedPersonId == null) {
4420
+ process.stderr.write('This person has no account yet — invite them first.\n');
4421
+ process.exitCode = EXIT.USAGE;
4422
+ return undefined;
4423
+ }
4424
+ return { ...resolution.contact, claimedPersonId: resolution.contact.claimedPersonId };
4425
+ };
4308
4426
  const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4309
4427
  // ALL local preconditions settle before any network/token touch — same law
4310
4428
  // as delete: a question that was always going to be refused must never
4311
4429
  // spend a token refresh first.
4312
4430
  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`);
4431
+ process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
4319
4432
  process.exitCode = EXIT.USAGE;
4320
4433
  return;
4321
4434
  }
@@ -4337,6 +4450,11 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4337
4450
  // the confirm fetch and share by the FULL id from then on — not the raw
4338
4451
  // (possibly a prefix) argument again.
4339
4452
  let shareId = idOrPrefix;
4453
+ const contact = audience === 'public'
4454
+ ? undefined
4455
+ : await resolveExistingPersonContact(cfg, accessToken, audience);
4456
+ if (audience !== 'public' && !contact)
4457
+ return;
4340
4458
  if (process.stdin.isTTY) {
4341
4459
  const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
4342
4460
  if (!fetched.ok) {
@@ -4345,7 +4463,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4345
4463
  return;
4346
4464
  }
4347
4465
  const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
4348
- const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(audience)}? [y/N] `);
4466
+ const confirmed = await confirmAction(`Share "${title}" (${fetched.data.id}) with ${describeAudience(contact?.displayName ?? audience)}? [y/N] `);
4349
4467
  if (confirmed === 'interrupted') {
4350
4468
  process.stderr.write('Cancelled.\n');
4351
4469
  process.exitCode = EXIT.GENERIC;
@@ -4360,7 +4478,7 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4360
4478
  const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(shareId)}/grants`, {
4361
4479
  method: 'POST',
4362
4480
  headers: { 'Content-Type': 'application/json' },
4363
- body: JSON.stringify({ audience })
4481
+ body: JSON.stringify(contact ? { kind: 'person', contactId: contact.id } : { audience: 'public' })
4364
4482
  });
4365
4483
  if (!result.ok) {
4366
4484
  renderAmbiguousIfPresent(result.body);
@@ -4373,15 +4491,15 @@ const shareMemory = async (cfg, idOrPrefix, audience, yes) => {
4373
4491
  // wire, byte-verbatim (emit's non-TTY branch); a TTY gets the human line
4374
4492
  // instead. shareId (not the response body, which never echoes an id) is
4375
4493
  // 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}`);
4494
+ emit(result.data, result.rawText, (grant) => `Shared memory ${shareId} with ${contact?.displayName ?? grant.kind}`);
4377
4495
  };
4378
4496
  export const SHARE_HELP = [
4379
- 'share — grant an audience access to a memory.',
4497
+ 'share — grant public or a person contact access to a memory.',
4380
4498
  '',
4381
4499
  'Usage:',
4382
- ' lattris share memory <id-or-prefix> --with <audience> [--yes]',
4500
+ ' lattris share memory <id-or-prefix> --with <public-or-contact> [--yes]',
4383
4501
  '',
4384
- ' --with <audience> required. v1 accepts: public',
4502
+ ' --with public, or an exact contact name or id from `lattris contact list`.',
4385
4503
  ' --yes required when not at a terminal; at a terminal you',
4386
4504
  ' are always asked, --yes or not.',
4387
4505
  '',
@@ -4415,29 +4533,10 @@ const share = async (cfg, noun, rest) => {
4415
4533
  }
4416
4534
  await shareMemory(cfg, positionals[0], flags.with, yes);
4417
4535
  };
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
4536
  const UNSHARE_FLAG_NAMES = {
4428
4537
  '--with': 'with'
4429
4538
  };
4430
4539
  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
4540
  let accessToken;
4442
4541
  try {
4443
4542
  accessToken = await validAccessToken(cfg);
@@ -4447,6 +4546,11 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
4447
4546
  process.exitCode = EXIT.NOAUTH;
4448
4547
  return;
4449
4548
  }
4549
+ const contact = audience === undefined || audience === 'public'
4550
+ ? undefined
4551
+ : await resolveExistingPersonContact(cfg, accessToken, audience);
4552
+ if (audience !== undefined && audience !== 'public' && !contact)
4553
+ return;
4450
4554
  const fetched = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(idOrPrefix)}`);
4451
4555
  if (!fetched.ok) {
4452
4556
  renderAmbiguousIfPresent(fetched.body);
@@ -4455,26 +4559,28 @@ const unshareMemory = async (cfg, idOrPrefix, audience) => {
4455
4559
  }
4456
4560
  if (audience === undefined) {
4457
4561
  const title = sanitizeForTerminal(fetched.data.title ?? '(untitled)');
4458
- process.stderr.write(`--with is required. Accepted: ${UNSHARE_AUDIENCES.join(', ')}\n`);
4562
+ process.stderr.write('--with is required. Use public or one of your contact names or ids.\n');
4459
4563
  process.stderr.write(`"${title}" (${fetched.data.id}) is currently shared with: ${formatAudiences(fetched.data.audiences)}\n`);
4460
4564
  process.exitCode = EXIT.USAGE;
4461
4565
  return;
4462
4566
  }
4463
- const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/${audience}`, { method: 'DELETE' });
4567
+ const result = await apiFetch(accessToken, contact
4568
+ ? `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/person/${contact.claimedPersonId}`
4569
+ : `${cfg.apiUrl}/memories/${encodeURIComponent(fetched.data.id)}/grants/public`, { method: 'DELETE' });
4464
4570
  if (!result.ok) {
4465
4571
  process.exitCode = result.exit;
4466
4572
  return;
4467
4573
  }
4468
- process.stderr.write(`Unshared memory ${fetched.data.id} from ${audience}\n`);
4574
+ process.stderr.write(`Unshared memory ${fetched.data.id} from ${contact?.displayName ?? 'public'}\n`);
4469
4575
  };
4470
4576
  export const UNSHARE_HELP = [
4471
4577
  'unshare — withdraw an audience from a memory. No confirmation — withdrawal',
4472
4578
  'is the safe direction.',
4473
4579
  '',
4474
4580
  'Usage:',
4475
- ' lattris unshare memory <id-or-prefix> --with <audience>',
4581
+ ' lattris unshare memory <id-or-prefix> --with <public-or-contact>',
4476
4582
  '',
4477
- ' --with <audience> required. v1 accepts: public',
4583
+ ' --with public, or an exact contact name or id from `lattris contact list`.',
4478
4584
  '',
4479
4585
  'Bare (no --with): refuses and shows what the memory is currently shared',
4480
4586
  'with, so you can see the state and the fix in one line.',
@@ -4505,6 +4611,58 @@ const unshare = async (cfg, noun, rest) => {
4505
4611
  }
4506
4612
  await unshareMemory(cfg, positionals[0], flags.with);
4507
4613
  };
4614
+ // ─── consent (<shared-memory-id> [--withdraw]) ─────────────────────────────
4615
+ export const CONSENT_HELP = [
4616
+ 'consent — choose whether a memory shared with you appears on your graph.',
4617
+ '',
4618
+ 'Usage:',
4619
+ ' lattris consent <id-or-prefix>',
4620
+ ' lattris consent <id-or-prefix> --withdraw',
4621
+ '',
4622
+ 'The choice is yours alone. Withdrawing a share hides the block regardless;',
4623
+ 'if its owner shares it again later, your previous choice still stands.'
4624
+ ].join('\n');
4625
+ const consent = async (cfg, rest) => {
4626
+ if (rest.some((arg) => HELP_TOKENS.has(arg))) {
4627
+ process.stdout.write(`${CONSENT_HELP}\n`);
4628
+ return;
4629
+ }
4630
+ const withdrawCount = rest.filter((arg) => arg === '--withdraw').length;
4631
+ const args = rest.filter((arg) => arg !== '--withdraw');
4632
+ const unknown = args.find((arg) => arg.startsWith('--'));
4633
+ if (unknown !== undefined) {
4634
+ process.stderr.write(`Unknown flag: ${unknown}\n`);
4635
+ process.exitCode = EXIT.USAGE;
4636
+ return;
4637
+ }
4638
+ if (withdrawCount > 1 || args.length !== 1) {
4639
+ process.stderr.write(`Usage: lattris consent <id-or-prefix> [--withdraw]\n`);
4640
+ process.exitCode = EXIT.USAGE;
4641
+ return;
4642
+ }
4643
+ let accessToken;
4644
+ try {
4645
+ accessToken = await validAccessToken(cfg);
4646
+ }
4647
+ catch {
4648
+ renderNotLoggedIn();
4649
+ process.exitCode = EXIT.NOAUTH;
4650
+ return;
4651
+ }
4652
+ const result = await apiFetch(accessToken, `${cfg.apiUrl}/memories/${encodeURIComponent(args[0])}/consent`, {
4653
+ method: 'PUT',
4654
+ headers: { 'Content-Type': 'application/json' },
4655
+ body: JSON.stringify({ consented: withdrawCount === 0 })
4656
+ });
4657
+ if (!result.ok) {
4658
+ renderAmbiguousIfPresent(result.body);
4659
+ process.exitCode = result.exit;
4660
+ return;
4661
+ }
4662
+ emit(result.data, result.rawText, (state) => state.consented
4663
+ ? `Memory ${args[0]} may appear on your graph.`
4664
+ : `Memory ${args[0]} will stay off your graph.`);
4665
+ };
4508
4666
  // ─── capture recording (train 8) ────────────────────────────────────────────
4509
4667
  // The API meets ONE recording shape; vendor transcript formats translate HERE,
4510
4668
  // CLI-side, so the door never grows a per-vendor arm. A recording is one door,
@@ -5889,7 +6047,7 @@ const usage = () => {
5889
6047
  ' lattris capture session <transcript> Save an AI conversation — the whole chain from its file',
5890
6048
  ' lattris get memories List your memories, newest first, with filters',
5891
6049
  ' 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',
6050
+ ' lattris invite <contact> Mint an invitation for one of your person contacts',
5893
6051
  ' lattris get invites List the invitations you have minted',
5894
6052
  ' lattris get memory <id> Read one memory back \u2014 words and edges (--links/--depth/--content)',
5895
6053
  ' lattris ask <handle> Leave a question on someone\u2019s tree \u2014 `echo "\u2026" | lattris ask <handle>`',
@@ -5897,6 +6055,7 @@ const usage = () => {
5897
6055
  ' lattris close question <id> Close a question (--close-as updated|declined|already-answered)',
5898
6056
  ' lattris share memory <id> Grant an audience access (--with public)',
5899
6057
  ' lattris unshare memory <id> Withdraw an audience (--with public)',
6058
+ ' lattris consent <id> Put a memory shared with you on your graph (--withdraw reverses)',
5900
6059
  ' lattris link <from> <to> State that one memory leads to another (--verb, --reason)',
5901
6060
  ' lattris unlink <from> <to> Remove that claim (--verb; the blocks stay)',
5902
6061
  ' lattris update memory <id> Correct a memory — `echo "…" | lattris update memory <id> --base-fingerprint <fp>`',
@@ -6420,11 +6579,13 @@ export const main = async () => {
6420
6579
  process.env.LATTRIS_HOME = asResolvedDir;
6421
6580
  }
6422
6581
  }
6423
- // Resolve the environment name: --env > .lattris env= > LATTRIS_ENV > "local".
6582
+ // Resolve the environment name: --env > .lattris env= > LATTRIS_ENV > the
6583
+ // install-aware fallback (published package = prod; worktree/source = local).
6424
6584
  // Must run AFTER --as (which sets LATTRIS_HOME, affecting resolveSessionHome
6425
6585
  // which findBinding reads for the env= field).
6426
6586
  const home = resolveSessionHome();
6427
- resolvedEnvName = envName ?? home.bindingEnv ?? process.env.LATTRIS_ENV ?? 'local';
6587
+ resolvedEnvName =
6588
+ envName ?? home.bindingEnv ?? process.env.LATTRIS_ENV ?? defaultEnvironmentForEntry();
6428
6589
  envWasExplicit =
6429
6590
  envName !== undefined || home.bindingEnv !== undefined || process.env.LATTRIS_ENV !== undefined;
6430
6591
  const forceFlag = argv.includes('--force');
@@ -6525,6 +6686,9 @@ export const main = async () => {
6525
6686
  case 'unshare':
6526
6687
  await unshare(cfg, argv[3], argv.slice(4));
6527
6688
  break;
6689
+ case 'consent':
6690
+ await consent(cfg, argv.slice(3));
6691
+ break;
6528
6692
  case 'contact':
6529
6693
  await contact(cfg, argv[3], argv.slice(4));
6530
6694
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattris",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
  },