@tpsdev-ai/flair 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.yaml +35 -2
- package/dist/build-info.json +6 -0
- package/dist/cli.js +847 -168
- package/dist/doctor-client.js +358 -11
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +71 -21
- package/dist/lib/scheduler-platform.js +363 -1
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +126 -20
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +363 -1
- package/dist/src/rem/scheduler.js +126 -20
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +52 -3
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/hook-install.js
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
// reuses bootstrap's own maxTokens machinery.
|
|
55
55
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
56
56
|
import { dirname, join } from "node:path";
|
|
57
|
-
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
|
|
57
|
+
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
|
|
58
58
|
// ── harness registry ────────────────────────────────────────────────────────
|
|
59
59
|
/** v1 supports exactly one harness. The flag/type exist so a second harness
|
|
60
60
|
* is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
|
|
@@ -352,3 +352,152 @@ export function hookStatus(homeDir, harness) {
|
|
|
352
352
|
agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null,
|
|
353
353
|
};
|
|
354
354
|
}
|
|
355
|
+
// ── continuity capture hooks (flair#1257 slice 2) ──────────────────────────
|
|
356
|
+
//
|
|
357
|
+
// The PostToolUse + Stop pair that auto-journals working state into the
|
|
358
|
+
// ephemeral Memory tier (see packages/flair-mcp/src/continuity.ts for the
|
|
359
|
+
// capture discipline). INSTALLING THIS PAIR IS THE OPT-IN — there is no env
|
|
360
|
+
// flag — so it gets the same standalone, symmetric, dry-run-able command
|
|
361
|
+
// surface as the SessionStart hook (`flair hook install|uninstall
|
|
362
|
+
// --continuity`, wired in src/cli.ts), sharing this module's Sherlock
|
|
363
|
+
// conditions: fail-closed on malformed settings.json, backup before any real
|
|
364
|
+
// mutation, idempotent merge that never touches unrelated hooks/keys,
|
|
365
|
+
// --dry-run computes the delta without writing (no backup either). The pure
|
|
366
|
+
// mutation cores (computeContinuityHookInstall / computeContinuityHookRemoval)
|
|
367
|
+
// live in src/doctor-client.ts next to the ONE command builder so `flair
|
|
368
|
+
// doctor --fix` and this family cannot drift apart.
|
|
369
|
+
/** Mirror of buildHookCommand for the continuity pair — delegates to the ONE
|
|
370
|
+
* builder in doctor-client.ts. Throws on unrepresentable values;
|
|
371
|
+
* installContinuityHooks() checks first and reports instead. */
|
|
372
|
+
export function buildContinuityHookCommand(agentId, flairUrl) {
|
|
373
|
+
return buildContinuityCaptureHookCommand(agentId, flairUrl);
|
|
374
|
+
}
|
|
375
|
+
/** Install (or repair to current form) the continuity capture pair. */
|
|
376
|
+
export function installContinuityHooks(opts) {
|
|
377
|
+
const { homeDir, harness, agentId, flairUrl } = opts;
|
|
378
|
+
const dryRun = !!opts.dryRun;
|
|
379
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
380
|
+
for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
|
|
381
|
+
if (!isHookCommandValueSafe(value)) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false, path, harness, dryRun,
|
|
384
|
+
message: `${label} '${value}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -) — refusing to write it`,
|
|
385
|
+
backupPath: null, actions: null,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (dryRun) {
|
|
390
|
+
const read = readSettingsFile(path);
|
|
391
|
+
if (read.parseError) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false, path, harness, dryRun,
|
|
394
|
+
message: `${read.parseError} — dry run: nothing would be written until this is fixed`,
|
|
395
|
+
backupPath: null, actions: null,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const { changed, actions } = computeContinuityHookInstall(read.parsed ?? {}, agentId, flairUrl);
|
|
399
|
+
const message = changed
|
|
400
|
+
? `would wire the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) in ${path} (dry run — nothing written)`
|
|
401
|
+
: `continuity capture hooks already current in ${path} — no changes`;
|
|
402
|
+
return { ok: true, path, harness, dryRun, message, backupPath: null, actions };
|
|
403
|
+
}
|
|
404
|
+
let backupPath = null;
|
|
405
|
+
if (existsSync(path)) {
|
|
406
|
+
try {
|
|
407
|
+
backupPath = takeBackup(path);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
411
|
+
return {
|
|
412
|
+
ok: false, path, harness, dryRun,
|
|
413
|
+
message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
|
|
414
|
+
backupPath: null, actions: null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const read = readSettingsFile(path);
|
|
419
|
+
if (read.parseError) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false, path, harness, dryRun,
|
|
422
|
+
message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
|
|
423
|
+
(backupPath ? `; backup copy at ${backupPath}.` : "."),
|
|
424
|
+
backupPath, actions: null,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const { changed, actions, newConfig } = computeContinuityHookInstall(read.parsed ?? {}, agentId, flairUrl);
|
|
428
|
+
if (!changed) {
|
|
429
|
+
return { ok: true, path, harness, dryRun, message: `continuity capture hooks already current in ${path}`, backupPath, actions };
|
|
430
|
+
}
|
|
431
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
432
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
433
|
+
return {
|
|
434
|
+
ok: true, path, harness, dryRun,
|
|
435
|
+
message: `wired the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) in ${path}`,
|
|
436
|
+
backupPath, actions,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/** Symmetric removal of the continuity pair — only ours, everything else in
|
|
440
|
+
* the file left untouched. A no-op when nothing is installed. */
|
|
441
|
+
export function uninstallContinuityHooks(opts) {
|
|
442
|
+
const { homeDir, harness } = opts;
|
|
443
|
+
const dryRun = !!opts.dryRun;
|
|
444
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
445
|
+
if (dryRun) {
|
|
446
|
+
const read = readSettingsFile(path);
|
|
447
|
+
if (read.parseError) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false, path, harness, dryRun,
|
|
450
|
+
message: `${read.parseError} — dry run: nothing would be removed until this is fixed`,
|
|
451
|
+
backupPath: null, actions: null,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const { changed, actions } = computeContinuityHookRemoval(read.parsed ?? {});
|
|
455
|
+
const message = changed
|
|
456
|
+
? `would remove the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) from ${path} (dry run — nothing written)`
|
|
457
|
+
: `no continuity capture hooks found in ${path} — nothing to remove`;
|
|
458
|
+
return { ok: true, path, harness, dryRun, message, backupPath: null, actions };
|
|
459
|
+
}
|
|
460
|
+
let backupPath = null;
|
|
461
|
+
if (existsSync(path)) {
|
|
462
|
+
try {
|
|
463
|
+
backupPath = takeBackup(path);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
467
|
+
return {
|
|
468
|
+
ok: false, path, harness, dryRun,
|
|
469
|
+
message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
|
|
470
|
+
backupPath: null, actions: null,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const read = readSettingsFile(path);
|
|
475
|
+
if (read.parseError) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false, path, harness, dryRun,
|
|
478
|
+
message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
|
|
479
|
+
(backupPath ? `; backup copy at ${backupPath}.` : "."),
|
|
480
|
+
backupPath, actions: null,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const { changed, actions, newConfig } = computeContinuityHookRemoval(read.parsed ?? {});
|
|
484
|
+
if (!changed) {
|
|
485
|
+
return { ok: true, path, harness, dryRun, message: `no continuity capture hooks found in ${path} — nothing to remove`, backupPath, actions };
|
|
486
|
+
}
|
|
487
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
488
|
+
return {
|
|
489
|
+
ok: true, path, harness, dryRun,
|
|
490
|
+
message: `removed the continuity capture hooks (PostToolUse + Stop) from ${path}`,
|
|
491
|
+
backupPath, actions,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/** Read-only continuity status for `flair hook status` — the same report
|
|
495
|
+
* doctor's check consumes, resolved through the harness's settings path. */
|
|
496
|
+
export function continuityHookStatus(homeDir, harness) {
|
|
497
|
+
// hookSettingsPath and checkContinuityCaptureHooks both resolve
|
|
498
|
+
// ~/.claude/settings.json from homeDir; asserting through the harness
|
|
499
|
+
// registry keeps a future second harness from silently reading the wrong
|
|
500
|
+
// file.
|
|
501
|
+
void hookSettingsPath(homeDir, harness);
|
|
502
|
+
return checkContinuityCaptureHooks(homeDir);
|
|
503
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ─── npm global bin dir vs PATH (flair#1134) ────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `npm install -g @tpsdev-ai/flair` on a user-prefix setup (prefix =
|
|
4
|
+
// ~/.npm-global or similar) succeeds, puts the `flair` bin in
|
|
5
|
+
// `<prefix>/bin`, and then `flair` is "command not found" because that
|
|
6
|
+
// directory was never added to PATH. The install instructions claim
|
|
7
|
+
// one-command readiness, so the failure reads as a broken package, not a
|
|
8
|
+
// broken PATH.
|
|
9
|
+
//
|
|
10
|
+
// This module is the single source of truth for detecting that state and
|
|
11
|
+
// for the message that fixes it. Two consumers:
|
|
12
|
+
// - dist/postinstall.cjs (src/postinstall.cts) — runs at `npm i -g` time,
|
|
13
|
+
// the moment the user hits the lie.
|
|
14
|
+
// - `flair doctor` — cheap, always runs, and covers every path where
|
|
15
|
+
// lifecycle scripts are suppressed (--ignore-scripts, bun without
|
|
16
|
+
// trustedDependencies, the fleet's tar-swap deploys).
|
|
17
|
+
//
|
|
18
|
+
// Contract (errors must enable a response): every warning names the ACTUAL
|
|
19
|
+
// bin directory and prints the exact line to add for the user's shell —
|
|
20
|
+
// never "check your PATH". If we cannot VALIDATE the directory (the flair
|
|
21
|
+
// bin is really there), we say nothing rather than print a wrong fix.
|
|
22
|
+
//
|
|
23
|
+
// Everything here is pure and dependency-injected except
|
|
24
|
+
// resolveNpmGlobalPrefix (spawns `npm prefix -g` for doctor).
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
// ─── path membership ────────────────────────────────────────────────────────
|
|
28
|
+
/** Strip trailing separators ("/", and "\" on win32) without eating a bare root. */
|
|
29
|
+
function stripTrailingSeps(p, win32) {
|
|
30
|
+
const stripped = p.replace(win32 ? /[\\/]+$/ : /\/+$/, "");
|
|
31
|
+
return stripped === "" ? p.slice(0, 1) : stripped;
|
|
32
|
+
}
|
|
33
|
+
function normalizeEntry(entry, win32) {
|
|
34
|
+
let e = stripTrailingSeps(entry.trim(), win32);
|
|
35
|
+
if (win32)
|
|
36
|
+
e = e.replace(/\//g, "\\").toLowerCase();
|
|
37
|
+
return e;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The directory npm links global bins into for a given prefix:
|
|
41
|
+
* `<prefix>/bin` everywhere except win32, where shims land in the prefix
|
|
42
|
+
* itself (npm's own layout, not ours).
|
|
43
|
+
*/
|
|
44
|
+
export function npmGlobalBinDir(prefix, platform = process.platform) {
|
|
45
|
+
const win32 = platform === "win32";
|
|
46
|
+
const clean = stripTrailingSeps(prefix.trim(), win32);
|
|
47
|
+
return win32 ? clean : join(clean, "bin");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Is `dir` one of the entries of `pathEnv`? Trailing slashes are ignored on
|
|
51
|
+
* both sides; win32 compares case-insensitively with either separator and
|
|
52
|
+
* splits on ";". Empty entries (historical "cwd" semantics) never match.
|
|
53
|
+
*/
|
|
54
|
+
export function isDirOnPath(dir, pathEnv, platform = process.platform) {
|
|
55
|
+
if (!pathEnv)
|
|
56
|
+
return false;
|
|
57
|
+
const win32 = platform === "win32";
|
|
58
|
+
const delim = win32 ? ";" : ":";
|
|
59
|
+
const want = normalizeEntry(dir, win32);
|
|
60
|
+
return pathEnv
|
|
61
|
+
.split(delim)
|
|
62
|
+
.filter((e) => e.trim() !== "")
|
|
63
|
+
.some((e) => normalizeEntry(e, win32) === want);
|
|
64
|
+
}
|
|
65
|
+
/** basename of $SHELL, lowercased — "/usr/local/bin/zsh" → "zsh". */
|
|
66
|
+
function shellFlavor(shell) {
|
|
67
|
+
if (!shell)
|
|
68
|
+
return "";
|
|
69
|
+
return shell.replace(/\\/g, "/").split("/").pop().toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
export function shellPathFix(binDir, shell) {
|
|
72
|
+
const flavor = shellFlavor(shell);
|
|
73
|
+
if (flavor === "fish") {
|
|
74
|
+
// fish_add_path persists via a universal variable — one command does both.
|
|
75
|
+
const line = `fish_add_path ${binDir}`;
|
|
76
|
+
return { exportLine: line, persistCommand: line, rcFile: null };
|
|
77
|
+
}
|
|
78
|
+
const exportLine = `export PATH="${binDir}:$PATH"`;
|
|
79
|
+
const rcFile = flavor === "zsh" ? "~/.zshrc" : flavor === "bash" ? "~/.bashrc" : null;
|
|
80
|
+
return {
|
|
81
|
+
exportLine,
|
|
82
|
+
persistCommand: rcFile === null ? null : `echo '${exportLine}' >> ${rcFile}`,
|
|
83
|
+
rcFile,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// ─── the message ────────────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* The full actionable warning. Names the actual bin dir, gives the exact
|
|
89
|
+
* line for the user's shell, says how to persist it, and how to verify.
|
|
90
|
+
*/
|
|
91
|
+
export function formatOffPathMessage(binDir, shell, platform = process.platform) {
|
|
92
|
+
if (platform === "win32") {
|
|
93
|
+
return [
|
|
94
|
+
`flair is installed in ${binDir}, but that directory is not on your PATH,`,
|
|
95
|
+
`so the "flair" command will not be found.`,
|
|
96
|
+
``,
|
|
97
|
+
`Fix — add it to your user PATH (new terminals pick it up):`,
|
|
98
|
+
``,
|
|
99
|
+
` powershell -Command "[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path','User') + ';${binDir}', 'User')"`,
|
|
100
|
+
``,
|
|
101
|
+
`Then open a new terminal and verify: flair --version`,
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
const fix = shellPathFix(binDir, shell);
|
|
105
|
+
const lines = [
|
|
106
|
+
`flair is installed at ${binDir}/flair, but ${binDir} is not on your PATH,`,
|
|
107
|
+
`so the "flair" command will not be found.`,
|
|
108
|
+
``,
|
|
109
|
+
`Fix — run this in your shell now:`,
|
|
110
|
+
``,
|
|
111
|
+
` ${fix.exportLine}`,
|
|
112
|
+
];
|
|
113
|
+
if (fix.persistCommand) {
|
|
114
|
+
lines.push(``, `and persist it for new shells:`, ``, ` ${fix.persistCommand}`);
|
|
115
|
+
}
|
|
116
|
+
else if (shellFlavor(shell) !== "fish") {
|
|
117
|
+
lines.push(``, `and add that same line to your shell's startup file to persist it.`);
|
|
118
|
+
}
|
|
119
|
+
lines.push(``, `Then verify: flair --version`);
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
export function checkGlobalBinOnPath(input) {
|
|
123
|
+
const platform = input.platform ?? process.platform;
|
|
124
|
+
const binDir = npmGlobalBinDir(input.prefix, platform);
|
|
125
|
+
if (isDirOnPath(binDir, input.pathEnv, platform))
|
|
126
|
+
return { onPath: true, binDir };
|
|
127
|
+
return {
|
|
128
|
+
onPath: false,
|
|
129
|
+
binDir,
|
|
130
|
+
exportLine: shellPathFix(binDir, input.shell).exportLine,
|
|
131
|
+
message: formatOffPathMessage(binDir, input.shell, platform),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Fallback when npm_config_prefix is absent: derive prefix from where npm put
|
|
136
|
+
* us. String-based (not path.join) so the win32 shape stays faithful even in
|
|
137
|
+
* tests running on posix hosts.
|
|
138
|
+
*/
|
|
139
|
+
export function prefixFromPackageDir(packageDir, platform = process.platform) {
|
|
140
|
+
// posix: <prefix>/lib/node_modules/@tpsdev-ai/flair → 4 segments up
|
|
141
|
+
// win32: <prefix>\node_modules\@tpsdev-ai\flair → 3 segments up
|
|
142
|
+
const win32 = platform === "win32";
|
|
143
|
+
const segments = stripTrailingSeps(packageDir, win32).split(win32 ? /[\\/]/ : "/");
|
|
144
|
+
const ups = win32 ? 3 : 4;
|
|
145
|
+
const kept = segments.slice(0, Math.max(1, segments.length - ups));
|
|
146
|
+
return kept.join(win32 ? "\\" : "/") || (win32 ? packageDir : "/");
|
|
147
|
+
}
|
|
148
|
+
function defaultBinDirHasFlair(binDir, platform) {
|
|
149
|
+
const names = platform === "win32" ? ["flair.cmd", "flair"] : ["flair"];
|
|
150
|
+
return names.some((n) => existsSync(join(binDir, n)));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Decide what (if anything) the postinstall hook should print.
|
|
154
|
+
*
|
|
155
|
+
* Returns the warning message, or null when there is nothing to say:
|
|
156
|
+
* - not a global install (npm_config_global !== "true" — local installs and
|
|
157
|
+
* non-npm runners stay silent),
|
|
158
|
+
* - no candidate prefix VALIDATES (the flair bin is not actually in the
|
|
159
|
+
* candidate's bin dir — we never print a fix naming a wrong directory),
|
|
160
|
+
* - or the bin dir is already on PATH.
|
|
161
|
+
*/
|
|
162
|
+
export function postinstallWarning(env) {
|
|
163
|
+
if (env.npmConfigGlobal !== "true")
|
|
164
|
+
return null;
|
|
165
|
+
const platform = env.platform ?? process.platform;
|
|
166
|
+
const hasFlair = env.binDirHasFlair ?? ((d) => defaultBinDirHasFlair(d, platform));
|
|
167
|
+
const candidates = [];
|
|
168
|
+
if (env.npmConfigPrefix)
|
|
169
|
+
candidates.push(env.npmConfigPrefix);
|
|
170
|
+
if (env.packageDir)
|
|
171
|
+
candidates.push(prefixFromPackageDir(env.packageDir, platform));
|
|
172
|
+
for (const prefix of candidates) {
|
|
173
|
+
const binDir = npmGlobalBinDir(prefix, platform);
|
|
174
|
+
if (!hasFlair(binDir))
|
|
175
|
+
continue; // unvalidated — never name a wrong dir
|
|
176
|
+
if (isDirOnPath(binDir, env.pathEnv, platform))
|
|
177
|
+
return null;
|
|
178
|
+
return formatOffPathMessage(binDir, env.shell, platform);
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/** Compact per-boot variant of the message — this one repeats until fixed. */
|
|
183
|
+
export function formatCompactOffPathBanner(binDir, shell) {
|
|
184
|
+
const fix = shellPathFix(binDir, shell);
|
|
185
|
+
const lines = [
|
|
186
|
+
`flair: ${binDir} (where npm installed flair) is not on your PATH.`,
|
|
187
|
+
` fix now: ${fix.exportLine}`,
|
|
188
|
+
];
|
|
189
|
+
if (fix.persistCommand && fix.persistCommand !== fix.exportLine) {
|
|
190
|
+
lines.push(` persist: ${fix.persistCommand}`);
|
|
191
|
+
}
|
|
192
|
+
else if (!fix.persistCommand) {
|
|
193
|
+
lines.push(` persist: add that line to your shell's startup file`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Decide what (if anything) the CLI should print to stderr at boot.
|
|
199
|
+
* Null when: not a TTY, the layout does not validate (dev checkouts, npx
|
|
200
|
+
* cache copies, tar-swap deploys — their derived dir has no flair bin), or
|
|
201
|
+
* the bin dir is on PATH.
|
|
202
|
+
*/
|
|
203
|
+
export function cliBootPathWarning(env) {
|
|
204
|
+
if (!env.stderrIsTTY)
|
|
205
|
+
return null;
|
|
206
|
+
const platform = env.platform ?? process.platform;
|
|
207
|
+
const hasFlair = env.binDirHasFlair ?? ((d) => defaultBinDirHasFlair(d, platform));
|
|
208
|
+
const binDir = npmGlobalBinDir(prefixFromPackageDir(env.packageDir, platform), platform);
|
|
209
|
+
if (!hasFlair(binDir))
|
|
210
|
+
return null; // unvalidated — never name a wrong dir
|
|
211
|
+
if (isDirOnPath(binDir, env.pathEnv, platform))
|
|
212
|
+
return null;
|
|
213
|
+
return formatCompactOffPathBanner(binDir, env.shell);
|
|
214
|
+
}
|
|
215
|
+
// ─── doctor plumbing ────────────────────────────────────────────────────────
|
|
216
|
+
/**
|
|
217
|
+
* `npm prefix -g`, best-effort. Returns the trimmed prefix or null when npm
|
|
218
|
+
* is absent / slow / errors — doctor SKIPS the check then (flair may have
|
|
219
|
+
* been installed by other means; a missing npm is not something this check
|
|
220
|
+
* can turn into an actionable finding).
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveNpmGlobalPrefix() {
|
|
223
|
+
try {
|
|
224
|
+
const { execFile } = await import("node:child_process");
|
|
225
|
+
const out = await new Promise((resolve, reject) => {
|
|
226
|
+
execFile(process.platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], { timeout: 5000, encoding: "utf-8", shell: process.platform === "win32" }, (err, stdout) => (err ? reject(err) : resolve(String(stdout))));
|
|
227
|
+
});
|
|
228
|
+
const prefix = out.trim();
|
|
229
|
+
return prefix === "" ? null : prefix;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entity-vocab-cli.ts — CLI-side copy of the attention-plane entity
|
|
3
|
+
* vocabulary validator (resources/entity-vocab.ts is the canonical module).
|
|
4
|
+
*
|
|
5
|
+
* INLINED, not imported: cross-boundary imports from src/ into resources/
|
|
6
|
+
* don't survive npm packaging — tsconfig.cli.json compiles with
|
|
7
|
+
* `rootDir: "src"`, so dist/cli.js has no resources/ module it can resolve
|
|
8
|
+
* at the same relative path. This is the same reason src/cli.ts inlines the
|
|
9
|
+
* federation crypto helpers (see the note beside `sortKeys()` there) and the
|
|
10
|
+
* private-visibility filter. The two files MUST stay in sync:
|
|
11
|
+
* test/unit/cli-entities-option.test.ts imports BOTH and pins ENTITY_TYPES
|
|
12
|
+
* equality, validator parity across a known-answer table, and the
|
|
13
|
+
* entityFormatHint() string — drift fails CI rather than shipping.
|
|
14
|
+
*
|
|
15
|
+
* Used by the `--entities <csv>` option on `flair memory add`,
|
|
16
|
+
* `flair workspace set`, and `flair orgevent` (flair#1288): the CLI validates
|
|
17
|
+
* before any signing/network work so a malformed entity is rejected
|
|
18
|
+
* client-side with an error that names the `type:value` format and
|
|
19
|
+
* enumerates the closed type set. The server independently re-validates on
|
|
20
|
+
* every write path (resources/Memory.ts / WorkspaceState.ts / OrgEvent.ts via
|
|
21
|
+
* invalidEntitiesResponse) — this module is UX, not the security gate.
|
|
22
|
+
*/
|
|
23
|
+
/** The closed set of entity types. Mirror of resources/entity-vocab.ts — extend BOTH together. */
|
|
24
|
+
export const ENTITY_TYPES = [
|
|
25
|
+
"repo",
|
|
26
|
+
"issue",
|
|
27
|
+
"customer",
|
|
28
|
+
"subsystem",
|
|
29
|
+
"agent",
|
|
30
|
+
"person",
|
|
31
|
+
];
|
|
32
|
+
const ENTITY_TYPE_SET = new Set(ENTITY_TYPES);
|
|
33
|
+
/**
|
|
34
|
+
* A "slug" value: lowercase alphanumeric segments joined by single `-` or
|
|
35
|
+
* `_` separators. Used for `customer:`, `subsystem:`, `agent:`, `person:`.
|
|
36
|
+
*/
|
|
37
|
+
const SLUG_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
38
|
+
/** A single repo path segment (owner or name): lowercase alphanumeric with `.`, `-`, `_` internal. */
|
|
39
|
+
const REPO_SEGMENT_RE = /^[a-z0-9]+(?:[.\-_][a-z0-9]+)*$/;
|
|
40
|
+
/** `<owner>/<name>` — both segments valid, exactly one `/`. */
|
|
41
|
+
function isValidRepoValue(value) {
|
|
42
|
+
const parts = value.split("/");
|
|
43
|
+
if (parts.length !== 2)
|
|
44
|
+
return false;
|
|
45
|
+
const [owner, name] = parts;
|
|
46
|
+
return REPO_SEGMENT_RE.test(owner) && REPO_SEGMENT_RE.test(name);
|
|
47
|
+
}
|
|
48
|
+
/** `<owner>/<name>#<n>` — a valid repo value, `#`, then a positive integer (no leading zero). */
|
|
49
|
+
function isValidIssueValue(value) {
|
|
50
|
+
const hashIndex = value.indexOf("#");
|
|
51
|
+
if (hashIndex === -1)
|
|
52
|
+
return false;
|
|
53
|
+
const repoPart = value.slice(0, hashIndex);
|
|
54
|
+
const numberPart = value.slice(hashIndex + 1);
|
|
55
|
+
if (!/^[1-9][0-9]*$/.test(numberPart))
|
|
56
|
+
return false;
|
|
57
|
+
return isValidRepoValue(repoPart);
|
|
58
|
+
}
|
|
59
|
+
function isValidSlugValue(value) {
|
|
60
|
+
return SLUG_RE.test(value);
|
|
61
|
+
}
|
|
62
|
+
const VALUE_VALIDATORS = {
|
|
63
|
+
repo: isValidRepoValue,
|
|
64
|
+
issue: isValidIssueValue,
|
|
65
|
+
customer: isValidSlugValue,
|
|
66
|
+
subsystem: isValidSlugValue,
|
|
67
|
+
agent: isValidSlugValue,
|
|
68
|
+
person: isValidSlugValue,
|
|
69
|
+
};
|
|
70
|
+
/** Split an entity string on its first `:` into { type, value }; null if it can't be well-formed. */
|
|
71
|
+
function parseEntity(entity) {
|
|
72
|
+
if (typeof entity !== "string" || entity.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const colonIndex = entity.indexOf(":");
|
|
75
|
+
if (colonIndex <= 0)
|
|
76
|
+
return null; // no colon, or colon is the first char (empty type)
|
|
77
|
+
const type = entity.slice(0, colonIndex);
|
|
78
|
+
const value = entity.slice(colonIndex + 1);
|
|
79
|
+
if (value.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
return { type, value };
|
|
82
|
+
}
|
|
83
|
+
/** Full validation: well-formed `type:value`, type in the closed set, value matches the type's grammar. */
|
|
84
|
+
export function isValidEntity(entity) {
|
|
85
|
+
if (typeof entity !== "string")
|
|
86
|
+
return false;
|
|
87
|
+
const parsed = parseEntity(entity);
|
|
88
|
+
if (!parsed)
|
|
89
|
+
return false;
|
|
90
|
+
if (!ENTITY_TYPE_SET.has(parsed.type))
|
|
91
|
+
return false;
|
|
92
|
+
return VALUE_VALIDATORS[parsed.type](parsed.value);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Canonical "what does well-formed look like" hint (flair#1288): names the
|
|
96
|
+
* `type:value` format AND enumerates the closed type set, so the rejection
|
|
97
|
+
* enables a response. Must produce the EXACT string resources/entity-vocab.ts's
|
|
98
|
+
* entityFormatHint() produces — the sync test compares them verbatim.
|
|
99
|
+
*/
|
|
100
|
+
export function entityFormatHint() {
|
|
101
|
+
return `entities are 'type:value' vocabulary strings (e.g. 'repo:owner/name'); valid types: ${ENTITY_TYPES.join(", ")}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a `--entities <csv>` option value: comma-split, trim, drop empties —
|
|
105
|
+
* the same list-option convention `--tags <csv>` / `--derived-from <csv>`
|
|
106
|
+
* already use (safe here because no entity grammar admits a comma) — then
|
|
107
|
+
* validate each element against the vocabulary.
|
|
108
|
+
*/
|
|
109
|
+
export function parseEntitiesCsv(csv) {
|
|
110
|
+
const entities = String(csv).split(",").map((x) => x.trim()).filter(Boolean);
|
|
111
|
+
const invalid = entities.filter((e) => !isValidEntity(e));
|
|
112
|
+
return { entities, invalid };
|
|
113
|
+
}
|