@hraness/wordcell 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +63 -22
  2. package/dist/authoring.js +2 -2
  3. package/dist/{authority-06eqhqam.js → authority-35cbvp1x.js} +2 -2
  4. package/dist/benchmark.js +3 -3
  5. package/dist/capture.js +6 -6
  6. package/dist/cli.js +26 -24
  7. package/dist/clip/acquire.js +3 -3
  8. package/dist/clip/args.js +1 -1
  9. package/dist/clip/cli.js +6 -6
  10. package/dist/clip/doctor.js +4 -4
  11. package/dist/evaluation-builder.js +10 -10
  12. package/dist/evaluation-kb.js +10 -10
  13. package/dist/graph-authority.js +4 -4
  14. package/dist/graph-percolation.js +6 -6
  15. package/dist/graph.js +5 -1
  16. package/dist/{index-bcknqxrq.js → index-0eacgvpv.js} +9 -1
  17. package/dist/{index-xv80vzfp.js → index-1er88ckw.js} +4 -4
  18. package/dist/{index-233z9wmn.js → index-1s8mc4hz.js} +13 -5
  19. package/dist/{index-de2w8crk.js → index-2fmnttca.js} +82 -15
  20. package/dist/{index-vnybgywh.js → index-ahyhryb8.js} +1 -1
  21. package/dist/{index-ecqpgr5y.js → index-b0khj0h5.js} +164 -9
  22. package/dist/{index-jk1sgx36.js → index-bdwcjvr4.js} +1 -1
  23. package/dist/{index-dfag79p7.js → index-byz4kzww.js} +3 -0
  24. package/dist/{index-hgve9rh2.js → index-cd75vky9.js} +96 -1
  25. package/dist/{index-rr9kzq4n.js → index-fp732bgg.js} +2 -2
  26. package/dist/{index-qbssx940.js → index-jvb7w0gg.js} +227 -9
  27. package/dist/{index-f984hw45.js → index-k86wepd8.js} +1 -1
  28. package/dist/{index-1k595z6v.js → index-mnq2wy34.js} +2 -2
  29. package/dist/{index-n386evxj.js → index-nn67kzwa.js} +1 -1
  30. package/dist/{index-fc4dr114.js → index-nwrehke7.js} +1 -1
  31. package/dist/{index-76dbn6pe.js → index-p70t8j13.js} +1 -1
  32. package/dist/{index-tf4wzjew.js → index-pgtm2nhf.js} +1 -1
  33. package/dist/{index-054mb7d3.js → index-qfqjfxaa.js} +10 -9
  34. package/dist/{index-pnt9wr59.js → index-r0m5taz2.js} +2 -2
  35. package/dist/{index-bqtqeak3.js → index-r4qs6vq4.js} +4 -4
  36. package/dist/{index-djrcc1yf.js → index-tcaq1c7f.js} +4 -4
  37. package/dist/{index-q7tbg07z.js → index-tvd2sqrx.js} +7 -7
  38. package/dist/{index-9h7zh0wt.js → index-vtwbapjr.js} +3814 -162
  39. package/dist/{index-g5vsqmdy.js → index-xwxy71ew.js} +190 -4
  40. package/dist/index.js +22 -14
  41. package/dist/pdf.js +3 -3
  42. package/dist/percolate.js +2 -2
  43. package/dist/portfolio.js +10 -10
  44. package/dist/publish.js +3 -3
  45. package/dist/sdk.js +9 -9
  46. package/dist/search.js +2 -2
  47. package/dist/semantic.js +4 -4
  48. package/dist/{support-m3t917ev.js → support-c8hy7qfa.js} +25 -23
  49. package/dist/url-intelligence.js +1 -1
  50. package/dist/workflows/decision-context.js +10 -10
  51. package/dist/workflows/index.js +10 -10
  52. package/package.json +12 -4
  53. package/skills/wordcell/AGENTS.md +1 -0
  54. package/skills/wordcell/SKILL.md +5 -3
  55. package/skills/wordcell/references/plan-structure.md +1 -1
  56. package/skills/wordcell/references/query.md +35 -1
  57. package/skills/wordcell/references/session-memory.md +207 -0
  58. package/skills/wordcell/references/url-platforms.md +1 -1
  59. package/src/authoring-import.ts +32 -0
  60. package/src/authoring-model.ts +185 -2
  61. package/src/authoring-program.ts +19 -7
  62. package/src/cli-help.ts +893 -0
  63. package/src/cli-intro.ts +1 -1
  64. package/src/cli-program.ts +575 -82
  65. package/src/cli-style.ts +132 -0
  66. package/src/clip/acquire.ts +20 -0
  67. package/src/clip/args.ts +3 -0
  68. package/src/clip/cli.ts +82 -5
  69. package/src/clip/cookie-permission.ts +241 -0
  70. package/src/clip/url-metadata-cli.ts +6 -5
  71. package/src/clip/url-metadata.ts +14 -0
  72. package/src/graph.ts +88 -13
  73. package/src/import-supermemory.ts +1629 -0
  74. package/src/mcp-server.ts +415 -0
  75. package/src/mcp-tools.ts +1122 -0
  76. package/src/mention-index.ts +194 -0
  77. package/src/pdf/cli.ts +5 -4
  78. package/src/support-profile.ts +1 -1
  79. package/src/support.ts +3 -0
  80. package/src/vault.ts +26 -2
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Terminal style for human CLI output: status symbols with ASCII fallbacks,
3
+ * symbol-only color, and the shared audience rule.
4
+ *
5
+ * TODO(df-0.8): use detectAudience and cli-style from @hraness/desktop-foundation
6
+ * once 0.8.0 is released. This file copies the Hraness CLI style contract
7
+ * (SPEC § C and § D6) verbatim so the output already matches it.
8
+ */
9
+
10
+ export type TerminalEnvironment = Readonly<Record<string, string | undefined>>;
11
+
12
+ export type CliSymbol = "ok" | "fail" | "warn" | "next" | "on" | "off" | "skip" | "progress" | "notice";
13
+
14
+ const UNICODE_SYMBOLS: Readonly<Record<CliSymbol, string>> = {
15
+ ok: "✓",
16
+ fail: "✗",
17
+ warn: "⚠",
18
+ next: "→",
19
+ on: "●",
20
+ off: "○",
21
+ skip: "–",
22
+ progress: "↻",
23
+ notice: "🔐",
24
+ };
25
+
26
+ const ASCII_SYMBOLS: Readonly<Record<CliSymbol, string>> = {
27
+ ok: "OK",
28
+ fail: "FAIL",
29
+ warn: "WARN",
30
+ next: "->",
31
+ on: "*",
32
+ off: "o",
33
+ skip: "-",
34
+ progress: "...",
35
+ notice: "NOTE",
36
+ };
37
+
38
+ const SYMBOL_COLORS: Readonly<Partial<Record<CliSymbol, string>>> = {
39
+ ok: "32",
40
+ fail: "31",
41
+ warn: "33",
42
+ next: "2",
43
+ on: "32",
44
+ skip: "2",
45
+ };
46
+
47
+ export type TerminalStyle = {
48
+ readonly ascii: boolean;
49
+ readonly color: boolean;
50
+ };
51
+
52
+ function nonEmpty(value: string | undefined): value is string {
53
+ return value !== undefined && value !== "";
54
+ }
55
+
56
+ /** ASCII fallbacks apply for `TERM=dumb`, `HRANESS_ASCII=1`, or a locale that does not name UTF-8. */
57
+ export function prefersAscii(env: TerminalEnvironment): boolean {
58
+ if (env.TERM === "dumb" || env.HRANESS_ASCII === "1") return true;
59
+ const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(nonEmpty);
60
+ return locale === undefined || !/utf-?8/iu.test(locale);
61
+ }
62
+
63
+ /** Color only on a terminal that is not `dumb` and when `NO_COLOR` is unset or empty; `FORCE_COLOR=1` forces it. */
64
+ export function prefersColor(env: TerminalEnvironment, isTTY: boolean): boolean {
65
+ if (env.FORCE_COLOR === "1") return true;
66
+ if (nonEmpty(env.NO_COLOR)) return false;
67
+ return isTTY && env.TERM !== "dumb";
68
+ }
69
+
70
+ export function terminalStyle(env: TerminalEnvironment, isTTY: boolean): TerminalStyle {
71
+ return { ascii: prefersAscii(env), color: prefersColor(env, isTTY) };
72
+ }
73
+
74
+ /** One status symbol. Only the symbol is ever colored, never the sentence. */
75
+ export function symbol(name: CliSymbol, style: TerminalStyle): string {
76
+ const glyph = style.ascii ? ASCII_SYMBOLS[name] : UNICODE_SYMBOLS[name];
77
+ const color = SYMBOL_COLORS[name];
78
+ return style.color && color !== undefined ? `\u001b[${color}m${glyph}\u001b[0m` : glyph;
79
+ }
80
+
81
+ export type Audience = "human" | "agent" | "quiet";
82
+
83
+ const AGENT_MARKERS = [
84
+ "AI_AGENT",
85
+ "CLAUDECODE",
86
+ "CODEX_SANDBOX",
87
+ "CODEX_SANDBOX_NETWORK_DISABLED",
88
+ "CURSOR_AGENT",
89
+ "GEMINI_CLI",
90
+ ] as const;
91
+
92
+ /**
93
+ * Who reads stderr: `HRANESS_AUDIENCE` wins, then exact agent markers, then a
94
+ * terminal on stderr means a person, and anything else stays quiet.
95
+ */
96
+ export function detectAudience(env: TerminalEnvironment, stderrIsTTY: boolean): Audience {
97
+ const explicit = env.HRANESS_AUDIENCE;
98
+ if (explicit === "human" || explicit === "agent" || explicit === "quiet") return explicit;
99
+ if (explicit === "off") return "quiet";
100
+ if (AGENT_MARKERS.some((marker) => nonEmpty(env[marker]))) return "agent";
101
+ return stderrIsTTY ? "human" : "quiet";
102
+ }
103
+
104
+ /** The two-line human error: what happened, then exactly one next command. */
105
+ export function renderFailure(text: string, next: string, style: TerminalStyle): string {
106
+ return `${symbol("fail", style)} ${text}\n${symbol("next", style)} ${next}\n`;
107
+ }
108
+
109
+ /** A parser message as one sentence: capitalized unless it starts with a flag, with a final period. */
110
+ export function sentence(message: string): string {
111
+ const trimmed = message.trim();
112
+ const capitalized = /^[a-z]/u.test(trimmed) ? `${trimmed[0]?.toUpperCase() ?? ""}${trimmed.slice(1)}` : trimmed;
113
+ return /[.!?]$/u.test(capitalized) ? capitalized : `${capitalized}.`;
114
+ }
115
+
116
+ const terminalOutputs = new WeakSet<object>();
117
+
118
+ /** Mark an output object as the process's real stdout and stderr. */
119
+ export function terminalOutput<T extends object>(output: T): T {
120
+ terminalOutputs.add(output);
121
+ return output;
122
+ }
123
+
124
+ /** Style for an error written to `output`: color only when it is the real terminal stderr. */
125
+ export function stderrStyle(env: TerminalEnvironment, output: object): TerminalStyle {
126
+ return terminalStyle(env, terminalOutputs.has(output) && process.stderr.isTTY === true);
127
+ }
128
+
129
+ /** True when `output` writes to the process's real stderr and that stderr is a terminal. */
130
+ export function writesToTerminal(output: object): boolean {
131
+ return terminalOutputs.has(output) && process.stderr.isTTY === true;
132
+ }
@@ -16,6 +16,13 @@ import { getCookies, type BrowserName, type GetCookiesOptions } from "@steipete/
16
16
 
17
17
  import { captureUrl, type CaptureArguments } from "./args.js";
18
18
  import { readBoundedByteStream } from "./bounded-byte-buffer.js";
19
+ import {
20
+ announceCookiePermissions,
21
+ classifyCookieProviderWarnings,
22
+ cookiePermissionNeed,
23
+ cookiePermissionRecovery,
24
+ reportCookiePermissionFailure,
25
+ } from "./cookie-permission.js";
19
26
  import {
20
27
  filterCookieProviderResult,
21
28
  readCookieFile,
@@ -1210,6 +1217,13 @@ export function createCookieRecordReader(reader: CookieStoreReader): CookieRecor
1210
1217
  ...(options.cookieSources.includes("safari") ? { safariCookiesFile: options.cookieProfile } : {}),
1211
1218
  }),
1212
1219
  };
1220
+ const needs = options.cookieSources.flatMap((source) => {
1221
+ const need = cookiePermissionNeed(source);
1222
+ return need === undefined ? [] : [need];
1223
+ });
1224
+ if (!await announceCookiePermissions(needs)) {
1225
+ throw new Error("reading browser cookies was skipped; pass --cookies-file <path> to use exported cookies instead");
1226
+ }
1213
1227
  let provided: unknown;
1214
1228
  try {
1215
1229
  provided = await reader(cookieOptions);
@@ -1219,6 +1233,12 @@ export function createCookieRecordReader(reader: CookieStoreReader): CookieRecor
1219
1233
  const filtered = filterCookieProviderResult(provided, url);
1220
1234
  if (!filtered.validShape) throw new Error("the selected browser cookie provider returned malformed data");
1221
1235
  if (filtered.cookies.length === 0) {
1236
+ // A keychain denial or missing Full Disk Access is never "no matching cookies".
1237
+ const permission = classifyCookieProviderWarnings((provided as { readonly warnings?: unknown }).warnings);
1238
+ if (permission !== undefined) {
1239
+ reportCookiePermissionFailure(permission);
1240
+ throw new Error(cookiePermissionRecovery(permission, process.env).message);
1241
+ }
1222
1242
  throw new Error(filtered.rejected === 0
1223
1243
  ? "no matching cookies were found in the explicitly selected browser"
1224
1244
  : `no usable origin-scoped cookies were found; rejected ${filtered.rejected} malformed, expired, or out-of-scope record(s)`);
package/src/clip/args.ts CHANGED
@@ -400,9 +400,12 @@ Capture options:
400
400
  --scope auto|page|thread|comments Content scope (default: platform-aware)
401
401
  --html <path|-> Parse saved/rendered HTML; - reads stdin
402
402
  --browser-profile <name|path> Use a signed-in or persistent Chrome profile
403
+ (downloads read its cookies from the keychain)
403
404
  --browser-live Attach to a live Chrome session
404
405
  --cdp <loopback-port> Attach to a local CDP-capable browser
405
406
  --cookie-source <browser> chrome|arc|brave|chromium|edge|firefox|safari
407
+ On a Mac, Chromium browsers ask for keychain
408
+ access; Safari needs Full Disk Access
406
409
  --cookie-profile <name|path> Browser profile; Safari expects a Cookies.binarycookies path
407
410
  --cookies-file <path> Cookie-Editor JSON/base64, Netscape, Cookie header, or cURL input
408
411
  --media none|images|all Localize images or all supported media
package/src/clip/cli.ts CHANGED
@@ -9,6 +9,15 @@ import {
9
9
  renderDoctorReport,
10
10
  } from "./doctor.js";
11
11
  import { redactSensitiveText } from "./persist.js";
12
+ import { detectAudience, renderFailure, sentence, stderrStyle, terminalOutput, writesToTerminal } from "../cli-style.js";
13
+ import {
14
+ CONFIRM_LINE,
15
+ cookiePermissionRecovery,
16
+ renderCookiePermissionNotice,
17
+ renderCookiePermissionRecovery,
18
+ setCookiePermissionReporter,
19
+ type CookiePermissionFailure,
20
+ } from "./cookie-permission.js";
12
21
  import { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal.js";
13
22
 
14
23
  type Output = {
@@ -16,10 +25,10 @@ type Output = {
16
25
  readonly stderr: (value: string) => void;
17
26
  };
18
27
 
19
- const defaultOutput: Output = {
28
+ const defaultOutput: Output = terminalOutput({
20
29
  stdout: (value) => process.stdout.write(value),
21
30
  stderr: (value) => process.stderr.write(value),
22
- };
31
+ });
23
32
 
24
33
  function line(value: string): string {
25
34
  return value.endsWith("\n") ? value : `${value}\n`;
@@ -44,8 +53,34 @@ function terminalSafeJson(value: unknown): string {
44
53
  type CliDependencies = {
45
54
  readonly runCapture?: typeof runCapture;
46
55
  readonly inspectClipEnvironment?: typeof inspectClipEnvironment;
56
+ /** Ask whether to continue past a permission notice; present only when stdin and stderr are terminals. */
57
+ readonly confirmPermission?: () => Promise<"continue" | "skip">;
58
+ /** Whether the person is at a terminal on stderr; defaults to the real stderr. */
59
+ readonly stderrIsTerminal?: boolean;
47
60
  };
48
61
 
62
+ const CONFIRM_TIMEOUT_MS = 120_000;
63
+
64
+ /** Read one line from a terminal: Enter continues, `s` skips, and silence skips after two minutes. */
65
+ async function confirmFromTerminal(): Promise<"continue" | "skip"> {
66
+ const reader = Bun.stdin.stream().getReader();
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+ try {
69
+ const timeout = new Promise<"skip">((resolve) => {
70
+ timer = setTimeout(() => resolve("skip"), CONFIRM_TIMEOUT_MS);
71
+ });
72
+ const answer = reader.read().then(({ value }) => {
73
+ const text = value === undefined ? "" : new TextDecoder().decode(value).trim().toLowerCase();
74
+ return text === "s" || text === "skip" ? "skip" as const : "continue" as const;
75
+ });
76
+ return await Promise.race([answer, timeout]);
77
+ } finally {
78
+ if (timer !== undefined) clearTimeout(timer);
79
+ // A read still pending after the timeout must not keep stdin open.
80
+ await reader.cancel().catch(() => undefined);
81
+ }
82
+ }
83
+
49
84
  export type ClipRuntimeOptions = {
50
85
  /** Trusted embedding hint; never parsed from public CLI arguments. */
51
86
  readonly ownedBrowserProfile?: {
@@ -111,7 +146,9 @@ export async function main(
111
146
  ): Promise<number> {
112
147
  const parsed = parseArguments(rawArguments, environment);
113
148
  if (!parsed.ok) {
114
- output.stderr(`error: ${safe(parsed.message)}\n\n${sanitizeTerminalText(usage)}`);
149
+ const first = rawArguments[0];
150
+ const next = first === "doctor" || first === "adapters" || first === "inspect" ? `wordcell ${first} --help` : "wordcell clip --help";
151
+ output.stderr(renderFailure(sentence(safe(parsed.message)), next, stderrStyle(environment, output)));
115
152
  return 2;
116
153
  }
117
154
  const arguments_ = parsed.value;
@@ -129,6 +166,24 @@ export async function main(
129
166
  return 0;
130
167
  }
131
168
 
169
+ const onTerminal = dependencies.stderrIsTerminal ?? writesToTerminal(output);
170
+ const human = !arguments_.json && detectAudience(environment, onTerminal) === "human";
171
+ let permissionFailure: CookiePermissionFailure | undefined;
172
+ setCookiePermissionReporter({
173
+ notice: async (need) => {
174
+ if (!human || arguments_.quiet) return "continue";
175
+ output.stderr(renderCookiePermissionNotice(need, environment, stderrStyle(environment, output)));
176
+ const confirm = dependencies.confirmPermission
177
+ ?? (onTerminal && process.stdin.isTTY === true ? confirmFromTerminal : undefined);
178
+ if (confirm === undefined) return "continue";
179
+ output.stderr(CONFIRM_LINE);
180
+ return await confirm();
181
+ },
182
+ failure: (failure) => {
183
+ permissionFailure ??= failure;
184
+ },
185
+ });
186
+
132
187
  if (!arguments_.quiet && !arguments_.json) {
133
188
  const target = arguments_.currentTab ? "the current browser tab" : safe(arguments_.url?.href ?? "current");
134
189
  output.stderr(`Capturing ${target} (${arguments_.mode}, ${arguments_.scope}) ...\n`);
@@ -171,10 +226,32 @@ export async function main(
171
226
  }
172
227
  return captureExitCode(outcome);
173
228
  } catch (error) {
174
- const message = safe(error instanceof Error ? error.message : String(error));
229
+ const raw = error instanceof Error ? error.message : String(error);
230
+ const recovery = permissionFailure === undefined
231
+ ? undefined
232
+ : cookiePermissionRecovery(permissionFailure, environment);
233
+ // Use the permission copy only when the capture failed because of it: the
234
+ // cookie reader's own message is in the failure. Any other error wins.
235
+ if (recovery !== undefined && raw.includes(recovery.message)) {
236
+ if (arguments_.json) {
237
+ output.stdout(terminalSafeJson({
238
+ ok: false,
239
+ error: recovery.message,
240
+ code: recovery.code,
241
+ next: recovery.next,
242
+ permission: recovery.permission,
243
+ }));
244
+ } else {
245
+ output.stderr(renderCookiePermissionRecovery(recovery, stderrStyle(environment, output)));
246
+ }
247
+ return 1;
248
+ }
249
+ const message = safe(raw);
175
250
  if (arguments_.json) output.stdout(terminalSafeJson({ ok: false, error: message }));
176
- else output.stderr(`error: ${message}\n`);
251
+ else output.stderr(renderFailure(message, "wordcell doctor", stderrStyle(environment, output)));
177
252
  return 1;
253
+ } finally {
254
+ setCookiePermissionReporter(undefined);
178
255
  }
179
256
  }
180
257
 
@@ -0,0 +1,241 @@
1
+ /**
2
+ * What a person sees before and after macOS guards a browser cookie source:
3
+ * the keychain prompt for Chromium browsers' "Safe Storage" key, and Full
4
+ * Disk Access for Safari's cookie file.
5
+ *
6
+ * TODO(df-0.8): use CHROME_SAFE_STORAGE, renderNotice, and renderRecovery from
7
+ * @hraness/desktop-foundation's permissions kit once 0.8.0 is released. The
8
+ * copy below follows that kit's templates (SPEC § D, "Copy templates").
9
+ */
10
+
11
+ import { symbol, type TerminalEnvironment, type TerminalStyle } from "../cli-style.js";
12
+
13
+ export const PRODUCT = "Wordcell";
14
+ export const KEYCHAIN_REQUESTER = "security";
15
+ export const FULL_DISK_ACCESS_PATH = "System Settings › Privacy & Security › Full Disk Access";
16
+ export const FULL_DISK_ACCESS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
17
+
18
+ /** The Chromium browsers whose cookie key lives in the macOS keychain, by keychain label. */
19
+ const KEYCHAIN_BROWSERS = ["Chrome", "Brave", "Arc", "Chromium", "Dia", "Microsoft Edge"] as const;
20
+ export type KeychainBrowser = (typeof KEYCHAIN_BROWSERS)[number];
21
+
22
+ export type CookiePermissionNeed =
23
+ | { readonly kind: "keychain"; readonly browser: KeychainBrowser }
24
+ | { readonly kind: "full-disk-access"; readonly browser: "Safari" };
25
+
26
+ export type CookiePermissionState = "denied" | "unknown" | "missing";
27
+
28
+ export type CookiePermissionFailure = CookiePermissionNeed & {
29
+ readonly state: CookiePermissionState;
30
+ /** Keychain only: the login keychain is locked, so macOS could not ask. */
31
+ readonly locked?: boolean;
32
+ };
33
+
34
+ /** Map a `--cookie-source` value to the browser whose keychain key it reads. */
35
+ export function keychainBrowserFor(source: string): KeychainBrowser | undefined {
36
+ switch (source) {
37
+ case "chrome": return "Chrome";
38
+ case "brave": return "Brave";
39
+ case "arc": return "Arc";
40
+ case "chromium": return "Chromium";
41
+ case "edge": return "Microsoft Edge";
42
+ default: return undefined;
43
+ }
44
+ }
45
+
46
+ /** The permission a cookie source needs on macOS, if any. */
47
+ export function cookiePermissionNeed(source: string, platform: string = process.platform): CookiePermissionNeed | undefined {
48
+ if (platform !== "darwin") return undefined;
49
+ if (source === "safari") return { kind: "full-disk-access", browser: "Safari" };
50
+ const browser = keychainBrowserFor(source);
51
+ return browser === undefined ? undefined : { kind: "keychain", browser };
52
+ }
53
+
54
+ const KEYCHAIN_WARNING = /^Failed to read macOS Keychain \(([A-Za-z ]{1,40}) Safe Storage\): ([\s\S]{0,2000})$/u;
55
+ const SAFARI_WARNING = /^Failed to read Safari cookies: ([\s\S]{0,2000})$/u;
56
+
57
+ /**
58
+ * Classify a cookie provider's warnings as a permission failure. Only fixed
59
+ * facts leave this function: the browser comes from a closed list and the
60
+ * warning text itself is never retained.
61
+ */
62
+ export function classifyCookieProviderWarnings(warnings: unknown): CookiePermissionFailure | undefined {
63
+ if (!Array.isArray(warnings)) return undefined;
64
+ for (const warning of warnings.slice(0, 64)) {
65
+ if (typeof warning !== "string") continue;
66
+ const keychain = KEYCHAIN_WARNING.exec(warning);
67
+ if (keychain !== null) {
68
+ const browser = KEYCHAIN_BROWSERS.find((name) => name === keychain[1]);
69
+ if (browser === undefined) continue;
70
+ return { kind: "keychain", browser, ...keychainState(keychain[2] ?? "") };
71
+ }
72
+ const safari = SAFARI_WARNING.exec(warning);
73
+ if (safari !== null && /\b(?:EPERM|EACCES)\b|operation not permitted|permission denied/iu.test(safari[1] ?? "")) {
74
+ return { kind: "full-disk-access", browser: "Safari", state: "denied" };
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * `security find-generic-password` failures: a cancel (-128) or a wrong
82
+ * password (-25293) is a denial, a locked keychain with no UI (-25308) is
83
+ * unknown, and a missing item (-25300) means the browser never made its key.
84
+ */
85
+ function keychainState(detail: string): { readonly state: CookiePermissionState; readonly locked?: boolean } {
86
+ if (/-25300\b|could not be found/iu.test(detail)) return { state: "missing" };
87
+ if (/-25308\b|interaction is not allowed/iu.test(detail)) return { state: "unknown", locked: true };
88
+ // Sweet Cookie's stderr-less fallback ("permission denied / keychain locked /
89
+ // entry missing.") names three causes, so only a specific status is a denial.
90
+ if (/-128\b|-25293\b|canceled|cancelled|passphrase you entered is not correct/iu.test(detail)) {
91
+ return { state: "denied" };
92
+ }
93
+ return { state: "unknown" };
94
+ }
95
+
96
+ /** The terminal app that asks for Full Disk Access on the person's behalf. */
97
+ export function terminalAppName(env: TerminalEnvironment): string {
98
+ switch (env.TERM_PROGRAM) {
99
+ case "Apple_Terminal": return "Terminal";
100
+ case "iTerm.app": return "iTerm";
101
+ case "vscode": return "Visual Studio Code";
102
+ case "WezTerm": return "WezTerm";
103
+ case "ghostty": return "Ghostty";
104
+ case "WarpTerminal": return "Warp";
105
+ case "zed": return "Zed";
106
+ default: return "your terminal app";
107
+ }
108
+ }
109
+
110
+ function keychainAsk(browser: KeychainBrowser): string {
111
+ return `use "${browser} Safe Storage" from your keychain`;
112
+ }
113
+
114
+ /**
115
+ * The pre-prompt printed before Wordcell reads a guarded cookie source. The
116
+ * confirm line is added by the caller only when stdin and stderr are terminals.
117
+ */
118
+ export function renderCookiePermissionNotice(
119
+ need: CookiePermissionNeed,
120
+ env: TerminalEnvironment,
121
+ style: TerminalStyle,
122
+ ): string {
123
+ const icon = symbol("notice", style);
124
+ if (need.kind === "keychain") {
125
+ return `${icon} macOS will ask to let ${KEYCHAIN_REQUESTER} ${keychainAsk(need.browser)} for ${PRODUCT}.\n`
126
+ + ` ${PRODUCT} uses it to read the ${need.browser} sign-in you already have and never stores it. `
127
+ + "Enter your Mac password if asked, then choose Always Allow so macOS doesn't ask again.\n";
128
+ }
129
+ const requester = terminalAppName(env);
130
+ return `${icon} ${PRODUCT} needs Full Disk Access to read your Safari cookies.\n`
131
+ + ` macOS doesn't ask for this. Turn on ${requester} in ${FULL_DISK_ACCESS_PATH}. `
132
+ + `${PRODUCT} reads only the cookies for the page you capture and never stores them.\n`;
133
+ }
134
+
135
+ export const CONFIRM_LINE = " Press Enter to continue · s to skip\n";
136
+
137
+ export type CookiePermissionRecovery = {
138
+ /** The first line after the failure symbol. */
139
+ readonly message: string;
140
+ /** The indented line that says how to fix it. */
141
+ readonly detail: string;
142
+ /** Exactly one next command. */
143
+ readonly next: string;
144
+ readonly code: "permission-denied" | "permission-unknown" | "permission-missing";
145
+ readonly permission: { readonly kind: CookiePermissionNeed["kind"]; readonly settingsUrl?: string };
146
+ };
147
+
148
+ const COOKIES_FILE_NEXT = "wordcell clip <url> --cookies-file <path>";
149
+
150
+ /** Recovery copy for a failed guarded cookie read. */
151
+ export function cookiePermissionRecovery(
152
+ failure: CookiePermissionFailure,
153
+ env: TerminalEnvironment,
154
+ ): CookiePermissionRecovery {
155
+ if (failure.kind === "full-disk-access") {
156
+ const requester = terminalAppName(env);
157
+ return {
158
+ message: `${PRODUCT} can't read your Safari cookies: macOS access is off for ${requester}.`,
159
+ detail: `Turn on ${requester} in ${FULL_DISK_ACCESS_PATH}, then run it again.`,
160
+ next: `open "${FULL_DISK_ACCESS_URL}"`,
161
+ code: "permission-denied",
162
+ permission: { kind: "full-disk-access", settingsUrl: FULL_DISK_ACCESS_URL },
163
+ };
164
+ }
165
+ const ask = keychainAsk(failure.browser);
166
+ switch (failure.state) {
167
+ case "denied":
168
+ return {
169
+ message: `${PRODUCT} can't ${ask}: the keychain request was denied.`,
170
+ detail: "Run it again and choose Always Allow when macOS asks, or pass a cookie file exported from the browser.",
171
+ next: COOKIES_FILE_NEXT,
172
+ code: "permission-denied",
173
+ permission: { kind: "keychain" },
174
+ };
175
+ case "missing":
176
+ return {
177
+ message: `${PRODUCT} can't find "${failure.browser} Safe Storage" in your keychain.`,
178
+ detail: `Open ${failure.browser} and sign in to the site once, or pass a cookie file exported from the browser.`,
179
+ next: COOKIES_FILE_NEXT,
180
+ code: "permission-missing",
181
+ permission: { kind: "keychain" },
182
+ };
183
+ case "unknown":
184
+ return {
185
+ message: `${PRODUCT} couldn't ${ask}. macOS may be blocking ${KEYCHAIN_REQUESTER}.`,
186
+ detail: failure.locked === true
187
+ ? "Unlock your login keychain, then run it again."
188
+ : "Check the key in Keychain Access, then run it again.",
189
+ next: COOKIES_FILE_NEXT,
190
+ code: "permission-unknown",
191
+ permission: { kind: "keychain" },
192
+ };
193
+ }
194
+ }
195
+
196
+ /** The three-line human recovery: what happened, how to fix it, one next command. */
197
+ export function renderCookiePermissionRecovery(recovery: CookiePermissionRecovery, style: TerminalStyle): string {
198
+ return `${symbol("fail", style)} ${recovery.message}\n ${recovery.detail}\n${symbol("next", style)} ${recovery.next}\n`;
199
+ }
200
+
201
+ /**
202
+ * What the CLI registers so library capture stays quiet: `notice` runs once
203
+ * per need before the guarded read and may decline it; `failure` records a
204
+ * classified failure for the final error.
205
+ */
206
+ export type CookiePermissionReporter = {
207
+ readonly notice: (need: CookiePermissionNeed) => Promise<"continue" | "skip">;
208
+ readonly failure: (failure: CookiePermissionFailure) => void;
209
+ };
210
+
211
+ let reporter: CookiePermissionReporter | undefined;
212
+ const answers = new Map<string, Promise<"continue" | "skip">>();
213
+
214
+ export function setCookiePermissionReporter(next: CookiePermissionReporter | undefined): void {
215
+ reporter = next;
216
+ answers.clear();
217
+ }
218
+
219
+ /**
220
+ * Show the pre-prompt for each need once per run. Capture lanes read cookies
221
+ * concurrently, so every caller waits on the same answer and a skip stops them
222
+ * all. Resolves false when the person skipped.
223
+ */
224
+ export async function announceCookiePermissions(needs: readonly CookiePermissionNeed[]): Promise<boolean> {
225
+ const current = reporter;
226
+ if (current === undefined) return true;
227
+ for (const need of needs) {
228
+ const key = `${need.kind}:${need.browser}`;
229
+ let answer = answers.get(key);
230
+ if (answer === undefined) {
231
+ answer = current.notice(need);
232
+ answers.set(key, answer);
233
+ }
234
+ if (await answer === "skip") return false;
235
+ }
236
+ return true;
237
+ }
238
+
239
+ export function reportCookiePermissionFailure(failure: CookiePermissionFailure): void {
240
+ reporter?.failure(failure);
241
+ }
@@ -11,7 +11,8 @@ import {
11
11
  runMetadataSearchTool,
12
12
  type MetadataSearchToolAction,
13
13
  } from "./metadata-search-tool/runner.js";
14
- import { sanitizeTerminalText } from "./terminal.js";
14
+ import { renderFailure, sentence, stderrStyle, terminalOutput } from "../cli-style.js";
15
+ import { sanitizeTerminalLine, sanitizeTerminalText } from "./terminal.js";
15
16
 
16
17
  export const urlMetadataUsage = `wordcell url-metadata — backfill bounded metadata for saved URLs
17
18
 
@@ -31,10 +32,10 @@ type Output = {
31
32
  readonly stderr: (value: string) => void;
32
33
  };
33
34
 
34
- const defaultOutput: Output = {
35
+ const defaultOutput: Output = terminalOutput({
35
36
  stdout: (value) => process.stdout.write(value),
36
37
  stderr: (value) => process.stderr.write(value),
37
- };
38
+ });
38
39
 
39
40
  type ParsedUrlMetadataArguments =
40
41
  | { readonly kind: "help" }
@@ -203,7 +204,7 @@ export async function main(
203
204
  const parsed = parseUrlMetadataArguments(rawArguments, environment);
204
205
  if (!parsed.ok) {
205
206
  if (parsed.json) output.stdout(terminalJson({ ok: false, error: parsed.message }));
206
- else output.stderr(`error: ${sanitizeTerminalText(parsed.message)}\n\n${urlMetadataUsage}`);
207
+ else output.stderr(renderFailure(sentence(sanitizeTerminalLine(parsed.message)), "wordcell url-metadata --help", stderrStyle(environment, output)));
207
208
  return 2;
208
209
  }
209
210
  if (parsed.value.kind === "help") {
@@ -243,7 +244,7 @@ export async function main(
243
244
  if (parsed.value.kind === "backfill" && parsed.value.json) {
244
245
  output.stdout(terminalJson({ ok: false, error: message }));
245
246
  }
246
- else output.stderr(`error: ${sanitizeTerminalText(message)}\n`);
247
+ else output.stderr(renderFailure(sanitizeTerminalLine(message), "wordcell url-metadata tool check", stderrStyle(environment, output)));
247
248
  return 1;
248
249
  }
249
250
  }
@@ -808,6 +808,20 @@ function absoluteSource(value: unknown, label: string, allowLocalPdf: boolean):
808
808
  return normalizedPublicUrl(value, label);
809
809
  }
810
810
 
811
+ /**
812
+ * Why `url-metadata` would refuse `value` as the `source` of a saved article
813
+ * note, or `undefined` when it accepts it. Other writers of `source` use this
814
+ * so the saved-URL inventory keeps working.
815
+ */
816
+ export function savedSourceProblem(value: string): string | undefined {
817
+ try {
818
+ absoluteSource(value, "source", false);
819
+ return undefined;
820
+ } catch (error) {
821
+ return error instanceof Error ? error.message : String(error);
822
+ }
823
+ }
824
+
811
825
  function confined(root: string, path: string, label: string): void {
812
826
  const pathFromRoot = relative(root, path);
813
827
  if (pathFromRoot === "" || pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || pathFromRoot.includes(`\0`)) {