hafez 1.3.0 → 1.4.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/dist/cli/index.js CHANGED
@@ -1,45 +1,67 @@
1
1
  // src/cli/index.ts
2
+ //
3
+ // Import discipline: this module is loaded for EVERY invocation, so it may
4
+ // only import what every invocation needs — node builtins, the flag parser,
5
+ // and the error type. Everything else (the core factory and its native SQLite
6
+ // binding, zod-backed schema introspection, the markdown formatters, even
7
+ // vault-path resolution) loads on demand from the command that needs it, so
8
+ // `hafez --version` / `help` / `init` no longer pay for the whole program.
2
9
  import { readFileSync } from 'fs';
3
- import { dirname, join, resolve } from 'path';
10
+ import { dirname, join } from 'path';
4
11
  import { fileURLToPath } from 'url';
5
- import { createHafez } from '../index.js';
6
12
  import { HafezError } from '../types.js';
7
- import { parseArgs, UsageError } from './args.js';
8
- import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
9
- import { cmdSchema } from './commands-schema.js';
10
- import { renderAgentHelp } from './help-agent.js';
11
- import { renderOnboard } from './onboard.js';
12
- import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
13
- const COMMANDS = {
14
- read: cmdRead,
15
- sync: cmdSync,
16
- query: cmdQuery,
17
- search: cmdSearch,
18
- create: cmdCreate,
19
- capture: cmdCapture,
20
- update: cmdUpdate,
21
- link: cmdLink,
22
- unlink: cmdUnlink,
23
- promote: cmdPromote,
24
- batch: cmdBatch,
25
- schema: cmdSchema,
26
- validate: cmdValidate,
27
- index: (os, args) => {
28
- if (args[0] === 'rebuild')
29
- return cmdIndexRebuild(os);
30
- throw new UsageError('Usage: hafez index rebuild');
13
+ import { UsageError } from './args.js';
14
+ /** Bind a vault command to its handler without importing the handler module
15
+ * until the command actually runs. */
16
+ function lazy(pick) {
17
+ return async (os, args, opts) => pick(await import('./commands.js'))(os, args, opts);
18
+ }
19
+ export const COMMANDS = {
20
+ // --- pre-vault ---
21
+ help: { phase: 'pre-vault', run: async (args) => (args[0] === '--agent' ? (await import('./help-agent.js')).renderAgentHelp() : USAGE) },
22
+ onboard: { phase: 'pre-vault', run: async () => (await import('./onboard.js')).renderOnboard() },
23
+ schema: { phase: 'pre-vault', run: async (args) => (await import('./commands-schema.js')).cmdSchema(null, args) },
24
+ version: { phase: 'pre-vault', run: () => versionLine() },
25
+ init: { phase: 'pre-vault', run: async (args, ctx) => { (await import('./resolve-vault.js')).cmdInit(args, ctx.vaultFlag); return null; } },
26
+ // Binds (args, ctx) explicitly: cmdUpgrade's third parameter is its
27
+ // dependency seam, and PreVaultRun's signature keeps dispatch from ever
28
+ // passing a third argument into it.
29
+ upgrade: { phase: 'pre-vault', run: async (args, ctx) => (await import('./upgrade.js')).cmdUpgrade(args, ctx) },
30
+ // --- vault ---
31
+ read: { phase: 'vault', run: lazy(m => m.cmdRead) },
32
+ sync: { phase: 'vault', run: lazy(m => m.cmdSync) },
33
+ query: { phase: 'vault', run: lazy(m => m.cmdQuery) },
34
+ search: { phase: 'vault', run: lazy(m => m.cmdSearch) },
35
+ create: { phase: 'vault', mutating: true, run: lazy(m => m.cmdCreate) },
36
+ capture: { phase: 'vault', mutating: true, run: lazy(m => m.cmdCapture) },
37
+ update: { phase: 'vault', mutating: true, run: lazy(m => m.cmdUpdate) },
38
+ link: { phase: 'vault', mutating: true, run: lazy(m => m.cmdLink) },
39
+ unlink: { phase: 'vault', mutating: true, run: lazy(m => m.cmdUnlink) },
40
+ promote: { phase: 'vault', mutating: true, run: lazy(m => m.cmdPromote) },
41
+ batch: { phase: 'vault', mutating: true, run: lazy(m => m.cmdBatch) },
42
+ validate: { phase: 'vault', run: lazy(m => m.cmdValidate) },
43
+ index: {
44
+ phase: 'vault',
45
+ run: async (os, args) => {
46
+ if (args[0] === 'rebuild')
47
+ return (await import('./commands.js')).cmdIndexRebuild(os);
48
+ throw new UsageError('Usage: hafez index rebuild');
49
+ },
31
50
  },
32
- stats: cmdStats,
33
- changelog: cmdChangelog,
34
- digest: cmdDigest,
51
+ stats: { phase: 'vault', run: lazy(m => m.cmdStats) },
52
+ changelog: { phase: 'vault', run: lazy(m => m.cmdChangelog) },
53
+ digest: { phase: 'vault', run: lazy(m => m.cmdDigest) },
54
+ export: { phase: 'vault', run: lazy(m => m.cmdExport) },
35
55
  };
36
- // Commands that modify vault files — auto-sync after these
37
- const MUTATING_COMMANDS = new Set(['update', 'create', 'capture', 'link', 'unlink', 'batch', 'promote']);
38
- const USAGE = `Usage: hafez <command> [args]
56
+ /** Exported so a guard test can assert every registered command is documented
57
+ * here — a registered-but-undocumented command is otherwise invisible. */
58
+ export const USAGE = `Usage: hafez <command> [args]
39
59
 
40
60
  Commands:
41
61
  read <slug> Read entity or knowledge note
42
62
  query [--filter ...] ... Filter entities/knowledge
63
+ --kind all Both kinds; --filter matches entity status only,
64
+ so knowledge notes (which have none) always show
43
65
  search <terms> Full-text search
44
66
  create <kind> <name> Create entity or knowledge note
45
67
  capture <name> [--notes] Quick inbox capture
@@ -69,6 +91,7 @@ Commands:
69
91
  init --register <path> Register an existing vault
70
92
  init Show current vault resolution
71
93
  onboard Agent-directed first run: seed the vault, pick integration level
94
+ upgrade Check whether hafez is outdated and print the update steps
72
95
  help --agent Full API reference for agents
73
96
 
74
97
  Global flags:
@@ -80,7 +103,7 @@ Vault discovery: --vault flag > ~/.config/hafez/vault
80
103
  `;
81
104
  /** Exit codes, documented in `hafez help --agent`. 0 = success; 4 is the
82
105
  * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
83
- const EXIT_CODES = {
106
+ export const EXIT_CODES = {
84
107
  NOT_FOUND: 1,
85
108
  VALIDATION_FAILED: 2,
86
109
  GIT_PUSH_FAILED: 3,
@@ -88,12 +111,21 @@ const EXIT_CODES = {
88
111
  GIT_COMMIT_FAILED: 6,
89
112
  VAULT_LOCKED: 7,
90
113
  };
114
+ /** The pure half of the error → exit adapter: which code an error maps to.
115
+ * Exported so the exit-code contract is testable without spawning the CLI. */
116
+ export function exitCodeFor(err) {
117
+ if (err instanceof UsageError)
118
+ return 2;
119
+ if (err instanceof HafezError)
120
+ return EXIT_CODES[err.code] ?? 1;
121
+ return 1;
122
+ }
91
123
  /** The one error → exit adapter. Usage errors (bad flags/args) are distinct
92
124
  * from vault errors and from unknown crashes. */
93
125
  function report(err) {
94
126
  if (err instanceof UsageError) {
95
127
  process.stderr.write(`Error: ${err.message}\n`);
96
- process.exit(2);
128
+ process.exit(exitCodeFor(err));
97
129
  }
98
130
  if (err instanceof HafezError) {
99
131
  process.stderr.write(`Error: ${err.message}\n`);
@@ -101,17 +133,29 @@ function report(err) {
101
133
  for (const detail of err.details)
102
134
  process.stderr.write(` - ${detail}\n`);
103
135
  }
104
- process.exit(EXIT_CODES[err.code] ?? 1);
136
+ process.exit(exitCodeFor(err));
105
137
  }
106
138
  failUnknown(err);
107
139
  }
140
+ /** Does this error mean the native module failed to load/build?
141
+ *
142
+ * Matched on the MESSAGE only, and only on the messages a load/ABI failure
143
+ * actually produces. The old test also scanned the stack for "better-sqlite3",
144
+ * which every SqliteError's stack contains — so a plain query error ("near
145
+ * 'abc': syntax error") printed reinstall advice instead of itself. */
146
+ export function isBindingsFailure(err) {
147
+ const message = err?.message;
148
+ if (typeof message !== 'string')
149
+ return false;
150
+ return /Could not locate the bindings file|better_sqlite3\.node|was compiled against a different Node\.js version|NODE_MODULE_VERSION|invalid ELF header/i.test(message);
151
+ }
108
152
  // Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
109
153
  // bindings: npm 11+ blocks install scripts by default, so `npm install -g
110
154
  // hafez` reports success with the native module unbuilt and the first indexed
111
155
  // command dies here — guide instead of stack-tracing.
112
156
  function failUnknown(err) {
113
157
  const e = err;
114
- if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
158
+ if (isBindingsFailure(e)) {
115
159
  process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
116
160
  'npm 11+ blocks install scripts by default, so the install reports success\n' +
117
161
  'while skipping the native build. Fix with:\n\n' +
@@ -121,108 +165,84 @@ function failUnknown(err) {
121
165
  process.stderr.write(`Error: ${e.message}\n`);
122
166
  process.exit(1);
123
167
  }
124
- export async function main(argv) {
125
- const args = argv.slice(2);
126
- // Extract global flags
127
- const vaultIdx = args.indexOf('--vault');
128
- let flagValue;
129
- if (vaultIdx !== -1) {
130
- flagValue = args[vaultIdx + 1];
131
- args.splice(vaultIdx, 2);
132
- }
133
- const jsonIdx = args.indexOf('--json');
134
- const json = jsonIdx !== -1;
135
- if (json)
136
- args.splice(jsonIdx, 1);
137
- const command = args.shift();
138
- if (!command || command === '--help' || command === '-h') {
139
- process.stdout.write(USAGE);
140
- return;
141
- }
142
- if (command === '--version' || command === '-v' || command === 'version') {
143
- const here = dirname(fileURLToPath(import.meta.url));
144
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
145
- process.stdout.write(`hafez ${pkg.version}\n`);
146
- return;
168
+ function versionLine() {
169
+ const here = dirname(fileURLToPath(import.meta.url));
170
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
171
+ return `hafez ${pkg.version}`;
172
+ }
173
+ /** Extract the global flags (--vault <path>, --json) from anywhere in argv.
174
+ * Exported for the in-process dispatch tests. A flag sitting where the vault
175
+ * path should be means the value is missing — never consume it (the same
176
+ * value-position rule parseArgs applies to declared command flags). */
177
+ export function extractGlobalFlags(args) {
178
+ const rest = [];
179
+ let vaultFlag;
180
+ let json = false;
181
+ for (let i = 0; i < args.length; i++) {
182
+ const a = args[i];
183
+ if (a === '--vault') {
184
+ const v = args[i + 1];
185
+ if (v === undefined || v.startsWith('--'))
186
+ throw new UsageError('--vault requires a path value');
187
+ // The old one-shot extraction left a second --vault in the command args
188
+ // where the declared-flag parser rejected it — keep failing loudly
189
+ // rather than silently letting the last one win (a mutating command
190
+ // would write to a different vault than the first flag named).
191
+ if (vaultFlag !== undefined)
192
+ throw new UsageError('--vault specified more than once');
193
+ vaultFlag = v;
194
+ i++;
195
+ }
196
+ else if (a === '--json') {
197
+ json = true;
198
+ }
199
+ else {
200
+ rest.push(a);
201
+ }
147
202
  }
148
- // help runs before vault resolution — works without a registered vault
149
- if (command === 'help') {
150
- if (args[0] === '--agent') {
151
- process.stdout.write(renderAgentHelp());
203
+ return { rest, vaultFlag, json };
204
+ }
205
+ export async function main(argv) {
206
+ try {
207
+ const { rest: args, vaultFlag, json } = extractGlobalFlags(argv.slice(2));
208
+ const command = args.shift();
209
+ if (!command || command === '--help' || command === '-h') {
210
+ process.stdout.write(USAGE);
152
211
  return;
153
212
  }
154
- process.stdout.write(USAGE);
155
- return;
156
- }
157
- // onboard runs before vault resolution — static agent-directed text
158
- if (command === 'onboard') {
159
- process.stdout.write(renderOnboard());
160
- return;
161
- }
162
- // schema runs before vault resolution — pure Zod introspection, no vault needed.
163
- if (command === 'schema') {
164
- try {
165
- const result = await cmdSchema(null, args);
166
- process.stdout.write(result + '\n');
213
+ const name = command === '--version' || command === '-v' ? 'version' : command;
214
+ const spec = COMMANDS[name];
215
+ if (!spec) {
216
+ process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
217
+ process.exit(1);
167
218
  }
168
- catch (err) {
169
- report(err);
219
+ if (spec.phase === 'pre-vault') {
220
+ const out = await spec.run(args, { json, vaultFlag });
221
+ if (out !== null)
222
+ process.stdout.write(out.endsWith('\n') ? out : out + '\n');
223
+ return;
170
224
  }
171
- return;
172
- }
173
- // init runs before vault resolution
174
- if (command === 'init') {
175
- cmdInit(args, flagValue);
176
- return;
177
- }
178
- // Resolve vault
179
- const resolution = resolveVaultPath(flagValue);
180
- if (resolution === null) {
181
- process.stderr.write(noVaultMessage());
182
- process.exit(1);
183
- }
184
- if (typeof resolution === 'object' && 'error' in resolution) {
185
- process.stderr.write(`Error: ${resolution.error}\n`);
186
- process.exit(1);
187
- }
188
- const vaultPath = resolution;
189
- // export runs before Hafez instance creation — read-only, no auto-sync
190
- if (command === 'export') {
191
- try {
192
- const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
193
- const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
194
- if (flags.okf !== true)
195
- throw new UsageError(EXPORT_USAGE);
196
- const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
197
- const { exportOkf } = await import('../export-okf.js');
198
- const exportReport = exportOkf(vaultPath, outDir);
199
- if (json) {
200
- process.stdout.write(JSON.stringify(exportReport, null, 2) + '\n');
201
- }
202
- else {
203
- const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
204
- for (const s of exportReport.skipped)
205
- lines.push(` skipped ${s.file}: ${s.reason}`);
206
- if (exportReport.unresolvedLinks > 0)
207
- lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
208
- process.stdout.write(lines.join('\n') + '\n');
209
- }
225
+ // Resolve vault (loaded here, not at module top: the pre-vault commands
226
+ // above return without ever needing the vault machinery)
227
+ const { resolveVaultPath, noVaultMessage } = await import('./resolve-vault.js');
228
+ const resolution = resolveVaultPath(vaultFlag);
229
+ if (resolution === null) {
230
+ process.stderr.write(noVaultMessage());
231
+ process.exit(1);
210
232
  }
211
- catch (err) {
212
- report(err);
233
+ if (typeof resolution === 'object' && 'error' in resolution) {
234
+ process.stderr.write(`Error: ${resolution.error}\n`);
235
+ process.exit(1);
213
236
  }
214
- return;
215
- }
216
- const handler = COMMANDS[command];
217
- if (!handler) {
218
- process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
219
- process.exit(1);
220
- }
221
- const os = createHafez({ vaultPath, git: { push: false } });
222
- try {
223
- const result = await handler(os, args, { json });
237
+ const vaultPath = resolution;
238
+ // First touch of the core — and so of the native SQLite binding. A
239
+ // failure to load it lands in the catch below and is recognised by
240
+ // isBindingsFailure, exactly as it was when this import was static.
241
+ const { createHafez } = await import('../index.js');
242
+ const os = createHafez({ vaultPath, git: { push: false } });
243
+ const result = await spec.run(os, args, { json, vaultPath });
224
244
  // Auto-sync after mutating commands
225
- if (MUTATING_COMMANDS.has(command)) {
245
+ if (spec.mutating) {
226
246
  try {
227
247
  await os.sync();
228
248
  }
@@ -0,0 +1,42 @@
1
+ export type Comparison = 'behind' | 'equal' | 'ahead' | 'unknown';
2
+ /** `command` entries are directly executable (after user approval); `action`
3
+ * entries are prose, so an agent that shells out every entry does not try to
4
+ * run English. */
5
+ export interface UpgradeStep {
6
+ kind: 'command' | 'action';
7
+ text: string;
8
+ }
9
+ export interface UpgradeState {
10
+ current: string;
11
+ latest: string | null;
12
+ comparison: Comparison;
13
+ registryReachable: boolean;
14
+ npmMajor: number | null;
15
+ install: 'registry' | 'dev-checkout';
16
+ skill: 'present' | 'absent';
17
+ skillSource: string;
18
+ skillTarget: string;
19
+ /** Mutually exclusive with the registry install in `steps` — exposed
20
+ * separately so an agent iterating `steps` cannot run both. */
21
+ alternativeInstall: string | null;
22
+ steps: UpgradeStep[];
23
+ }
24
+ export interface UpgradeDeps {
25
+ fetch: typeof globalThis.fetch;
26
+ registryUrl: string;
27
+ packageRoot: string;
28
+ installedSkillPath: string;
29
+ npmVersion: () => string;
30
+ }
31
+ /** Three-way compare on the numeric segments. Hand-rolled string compare is
32
+ * the trap this exists to avoid: `1.10.0` < `1.9.0` lexicographically.
33
+ * Anything that is not plain X.Y.Z (a prerelease, a build tag) is `unknown` —
34
+ * this tool never guesses at a version it cannot order. */
35
+ export declare function compareVersions(local: string, latest: string): Comparison;
36
+ export declare function buildUpgradeState(deps: UpgradeDeps): Promise<UpgradeState>;
37
+ export declare function renderUpgrade(state: UpgradeState): string;
38
+ /** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
39
+ * `(args, ctx)`) — the tests inject it directly. */
40
+ export declare function cmdUpgrade(args: string[], ctx: {
41
+ json: boolean;
42
+ }, deps?: UpgradeDeps): Promise<string>;