hafez 1.0.1
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/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli/batch-errors.d.ts +24 -0
- package/dist/cli/batch-errors.js +236 -0
- package/dist/cli/commands-schema.d.ts +3 -0
- package/dist/cli/commands-schema.js +80 -0
- package/dist/cli/commands.d.ts +133 -0
- package/dist/cli/commands.js +628 -0
- package/dist/cli/format.d.ts +9 -0
- package/dist/cli/format.js +158 -0
- package/dist/cli/help-agent.d.ts +6 -0
- package/dist/cli/help-agent.js +158 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +304 -0
- package/dist/cli/resolve-vault.d.ts +13 -0
- package/dist/cli/resolve-vault.js +124 -0
- package/dist/cli/schema-introspect.d.ts +70 -0
- package/dist/cli/schema-introspect.js +372 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -0
- package/dist/contracts.d.ts +26 -0
- package/dist/contracts.js +87 -0
- package/dist/db.d.ts +68 -0
- package/dist/db.js +663 -0
- package/dist/digest.d.ts +29 -0
- package/dist/digest.js +94 -0
- package/dist/export-okf.d.ts +12 -0
- package/dist/export-okf.js +176 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.js +355 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +909 -0
- package/dist/knowledge-index.d.ts +3 -0
- package/dist/knowledge-index.js +135 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +176 -0
- package/dist/migrate-knowledge-v2.d.ts +11 -0
- package/dist/migrate-knowledge-v2.js +158 -0
- package/dist/migrate-next-actions.d.ts +6 -0
- package/dist/migrate-next-actions.js +32 -0
- package/dist/migrate-types.d.ts +11 -0
- package/dist/migrate-types.js +142 -0
- package/dist/parser.d.ts +18 -0
- package/dist/parser.js +68 -0
- package/dist/query.d.ts +24 -0
- package/dist/query.js +167 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +59 -0
- package/dist/sections.d.ts +54 -0
- package/dist/sections.js +297 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +39 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.js +11 -0
- package/dist/vault.d.ts +12 -0
- package/dist/vault.js +45 -0
- package/package.json +67 -0
- package/skill/SKILL.md +190 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shrvn-r
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Hafez
|
|
2
|
+
|
|
3
|
+
**An agent-native personal knowledge vault.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. It keeps a durable, structured memory for AI agents: plain markdown files you own, backed by SQLite full-text search and git.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
AI agents forget everything between sessions. Hafez gives them a place to remember — projects, insights, decisions, session history — as a knowledge graph they can read, query, and write through a CLI.
|
|
8
|
+
|
|
9
|
+
The files stay yours. Everything is markdown with YAML frontmatter in a git repo: readable in any editor, Obsidian-compatible, diffable, and synced like code. The SQLite index (`.hafez.db`) is a disposable read cache rebuilt from the files; git is the source of truth and the sync mechanism.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Hafez is a tool for agents — so let an agent install it. Paste this into
|
|
14
|
+
Claude Code (or any agent with shell access):
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
|
|
18
|
+
|
|
19
|
+
1. Install the CLI (needs Node.js 22+ and git):
|
|
20
|
+
npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz
|
|
21
|
+
On npm 12+, append --allow-remote=root --allow-scripts=better-sqlite3
|
|
22
|
+
(npm 12 blocks URL-tarball installs and install scripts by default;
|
|
23
|
+
the SQLite bindings need their install script).
|
|
24
|
+
2. Check git identity is configured (git config user.name and user.email);
|
|
25
|
+
if not, ask me for a name and email and set them with git config --global.
|
|
26
|
+
3. Create my vault — a git repo with entities/ and knowledge/ directories and an
|
|
27
|
+
initial commit. Suggest ~/vault but ask me where I want it first.
|
|
28
|
+
4. Register it: hafez init --register <vault-path>
|
|
29
|
+
5. Install the bundled agent skill: copy skill/SKILL.md from the installed
|
|
30
|
+
package ("$(npm root -g)/hafez/skill/SKILL.md") to ~/.claude/skills/hafez/SKILL.md
|
|
31
|
+
6. Read hafez help --agent, then verify end-to-end: create a test capture,
|
|
32
|
+
hafez read it back, and show me hafez stats.
|
|
33
|
+
7. Tell me what you set up, and give me three example asks I can use in future
|
|
34
|
+
sessions to see the vault working (e.g. "what am I working on?").
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From then on, just mention your projects by name in any session — the skill
|
|
38
|
+
teaches the agent when to read and write the vault, no commands required.
|
|
39
|
+
|
|
40
|
+
### Manual setup
|
|
41
|
+
|
|
42
|
+
The same thing by hand:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Install (Node.js 22+ and git required).
|
|
46
|
+
# On npm 12+, append: --allow-remote=root --allow-scripts=better-sqlite3
|
|
47
|
+
npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz
|
|
48
|
+
|
|
49
|
+
# One-time, if you've never used git on this machine:
|
|
50
|
+
# git config --global user.name "Your Name"
|
|
51
|
+
# git config --global user.email "you@example.com"
|
|
52
|
+
|
|
53
|
+
mkdir -p ~/vault/entities ~/vault/knowledge
|
|
54
|
+
cd ~/vault && git init && git commit --allow-empty -m "init vault"
|
|
55
|
+
|
|
56
|
+
hafez init --register ~/vault # remember this vault (one-time)
|
|
57
|
+
hafez init # show current vault resolution
|
|
58
|
+
|
|
59
|
+
# Claude Code skill (optional but recommended)
|
|
60
|
+
mkdir -p ~/.claude/skills/hafez
|
|
61
|
+
cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
On npm ≤11, `npm install -g github:shrvn-r/hafez` also works (npm 12 disables
|
|
65
|
+
git installs by default). An npm registry package is planned; GitHub is the
|
|
66
|
+
distribution channel for now.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
Whether you drive it or an agent does, the CLI is the whole interface:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Create a project
|
|
74
|
+
hafez create entity "Auth Refactor" --type project \
|
|
75
|
+
--description "Move auth service to JWT" --add-action "Write migration plan"
|
|
76
|
+
|
|
77
|
+
# Quick capture for later triage
|
|
78
|
+
hafez capture "Look into passkeys" --notes "Saw this mentioned in an RFC"
|
|
79
|
+
|
|
80
|
+
# Read it back
|
|
81
|
+
hafez read auth-refactor
|
|
82
|
+
|
|
83
|
+
# Query
|
|
84
|
+
hafez query --filter active --type project
|
|
85
|
+
hafez query --kind knowledge --domain security
|
|
86
|
+
|
|
87
|
+
# Full-text search across the whole vault
|
|
88
|
+
hafez search "token rotation"
|
|
89
|
+
|
|
90
|
+
# Record progress
|
|
91
|
+
hafez update auth-refactor --log "progress: JWT flow implemented" --agent claude
|
|
92
|
+
hafez update auth-refactor --complete-action "migration plan"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Every mutating command commits to git and syncs with the remote (if one is configured).
|
|
96
|
+
|
|
97
|
+
## Core concepts
|
|
98
|
+
|
|
99
|
+
Three kinds of documents, all markdown + YAML frontmatter:
|
|
100
|
+
|
|
101
|
+
- **Entities** (`entities/*.md`) — the things you're working on. Types: `capture` (inbox item), `entity` (anything worth tracking), `project` (has outcomes). Statuses: `active | paused | done`. Bodies start from a per-type template (capture: `## Notes`; entity: `## Context`; project: `## Purpose` + `## Goals`) plus a `## Session Log` of dated agent entries; `## Brief` (freeform handoff context) and `## Next Actions` (checkbox list) appear when first written to.
|
|
102
|
+
- **Knowledge** (`knowledge/*.md`) — durable insights that outlive any one project. Subtypes `insight | plan`; confidence levels `observation → pattern → principle` (promoted manually). Bodies hold `## Synthesis`, `## Evidence`, and `## Sources` — evidence accumulates over time via `--add-evidence`.
|
|
103
|
+
- **Sessions** (`sessions/*.md`) — session summaries written via `batch`, kept as history, not indexed.
|
|
104
|
+
|
|
105
|
+
Frontmatter at a glance:
|
|
106
|
+
|
|
107
|
+
| Field | Entities | Knowledge |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `name`, `created` | required | required |
|
|
110
|
+
| `type`, `status`, `last-touched` | required | — |
|
|
111
|
+
| `description` (one-line summary) | optional | optional |
|
|
112
|
+
| `domain` (string[]), `tags`, `related` | optional | optional |
|
|
113
|
+
| `parent` (slug) | optional | — |
|
|
114
|
+
| `resource` (canonical URI) | optional | — |
|
|
115
|
+
| `subtype`, `confidence` | — | optional |
|
|
116
|
+
|
|
117
|
+
Links (`parent`, `related`) are validated — they must point at slugs that exist. `hafez validate` checks vault integrity.
|
|
118
|
+
|
|
119
|
+
## CLI overview
|
|
120
|
+
|
|
121
|
+
| Command | What it does |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `hafez read <slug> [--depth frontmatter\|summary\|full]` | Read an entity or knowledge note |
|
|
124
|
+
| `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
|
|
125
|
+
| `hafez search <terms> [--kind ...]` | FTS5 full-text search |
|
|
126
|
+
| `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
|
|
127
|
+
| `hafez capture <name> [--notes]` | Quick inbox capture |
|
|
128
|
+
| `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
|
|
129
|
+
| `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
|
|
130
|
+
| `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
|
|
131
|
+
| `hafez batch [--dry-run]` | JSON ops on stdin → atomic multi-op, single git commit, rollback on failure |
|
|
132
|
+
| `hafez stats` | Vault summary: counts, stale items, recents |
|
|
133
|
+
| `hafez changelog --since 7.days.ago` | Git-derived change history |
|
|
134
|
+
| `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
|
|
135
|
+
| `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
|
|
136
|
+
| `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
|
|
137
|
+
| `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
|
|
138
|
+
|
|
139
|
+
This is not exhaustive — `hafez help --agent` prints the full, always-current reference. Add `--json` to any command for structured output.
|
|
140
|
+
|
|
141
|
+
## Library usage
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { createHafez } from 'hafez'
|
|
145
|
+
|
|
146
|
+
const os = createHafez({ vaultPath: '/path/to/vault' })
|
|
147
|
+
|
|
148
|
+
const slug = await os.create('entity', 'New Project', {
|
|
149
|
+
type: 'project',
|
|
150
|
+
brief: 'Context here',
|
|
151
|
+
add_action: 'Start design',
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await os.update(slug, { status: 'active', add_action: 'Write tests' })
|
|
155
|
+
|
|
156
|
+
const { items } = await os.query({ filter: 'active', type: 'project' })
|
|
157
|
+
const results = await os.search('auth pattern')
|
|
158
|
+
const note = await os.read(slug, 'full')
|
|
159
|
+
|
|
160
|
+
// Atomic multi-op — single git commit, rollback on failure
|
|
161
|
+
await os.batch([
|
|
162
|
+
{ op: 'update', slug, fields: { status: 'done' } },
|
|
163
|
+
{ op: 'create', kind: 'knowledge', name: 'What we learned', fields: { domain: ['auth'] } },
|
|
164
|
+
])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Writes are serialized through an internal mutex, so a single `Hafez` instance is safe to share.
|
|
168
|
+
|
|
169
|
+
## Agent integration
|
|
170
|
+
|
|
171
|
+
Hafez is built to be operated by agents:
|
|
172
|
+
|
|
173
|
+
- **Claude Code skill** — the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
|
|
174
|
+
- **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
|
|
175
|
+
- **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
|
|
176
|
+
- **`hafez batch`** — apply a whole session's worth of updates atomically from a single JSON payload. Validation errors report every bad op at once with did-you-mean hints.
|
|
177
|
+
- **Markdown-native output** by default, `--json` everywhere for machine consumption.
|
|
178
|
+
|
|
179
|
+
## Vault discovery
|
|
180
|
+
|
|
181
|
+
The CLI resolves the vault in order:
|
|
182
|
+
|
|
183
|
+
1. `--vault <path>` flag (explicit, highest priority)
|
|
184
|
+
2. `~/.config/hafez/vault` (or `$XDG_CONFIG_HOME/hafez/vault`) — a plain-text file containing the vault path, written by `hafez init --register`
|
|
185
|
+
|
|
186
|
+
If the config points at a path that isn't a vault, the CLI errors loudly instead of silently falling through.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
export declare function levenshtein(a: string, b: string): number;
|
|
3
|
+
export declare function didYouMean(key: string, validKeys: readonly string[], maxDistance?: number): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Given the raw op JSON, determine a display name for the schema pointer.
|
|
6
|
+
* For create ops, returns `create-<kind>` if kind is a valid literal, else `create`.
|
|
7
|
+
* For typos in `op` itself, returns null.
|
|
8
|
+
*/
|
|
9
|
+
export declare function opNameOf(raw: Record<string, unknown> | undefined): string | null;
|
|
10
|
+
export interface FormattedError {
|
|
11
|
+
/** The block shown to the user — one issue, multi-line. */
|
|
12
|
+
block: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Format a single Zod issue into a readable block. Caller supplies the op
|
|
16
|
+
* index and the raw op JSON so the block can reference the right `hafez
|
|
17
|
+
* schema <op>` pointer.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatIssue(issue: z.ZodIssue, opIndex: number, raw: Record<string, unknown> | undefined): string;
|
|
20
|
+
/**
|
|
21
|
+
* Top-level entry for a single op. Returns one composite block that concatenates
|
|
22
|
+
* every issue the safeParse returned, preserving all N issues (never drops).
|
|
23
|
+
*/
|
|
24
|
+
export declare function formatBatchError(issues: z.ZodIssue[], opIndex: number, raw: Record<string, unknown> | undefined): string;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// src/cli/batch-errors.ts
|
|
2
|
+
//
|
|
3
|
+
// Multi-error batch formatter. Takes a Zod `safeParse` issue list for one op
|
|
4
|
+
// index and returns a human-readable block keyed to the op + index, with a
|
|
5
|
+
// "did you mean" suggestion on unknown keys and a `hafez schema <op>`
|
|
6
|
+
// pointer at the end of every block.
|
|
7
|
+
//
|
|
8
|
+
// Depends on Phase 0 step 2 (discriminatedUnion) and step 3 (.strict()):
|
|
9
|
+
// without those, the `unrecognized_keys` and `invalid_union_discriminator`
|
|
10
|
+
// paths are unreachable.
|
|
11
|
+
import { getOpSchema, listOps } from './schema-introspect.js';
|
|
12
|
+
// --- did-you-mean (cheap Levenshtein) ---
|
|
13
|
+
export function levenshtein(a, b) {
|
|
14
|
+
if (a === b)
|
|
15
|
+
return 0;
|
|
16
|
+
if (!a.length)
|
|
17
|
+
return b.length;
|
|
18
|
+
if (!b.length)
|
|
19
|
+
return a.length;
|
|
20
|
+
const prev = new Array(b.length + 1).fill(0).map((_, i) => i);
|
|
21
|
+
for (let i = 1; i <= a.length; i++) {
|
|
22
|
+
const cur = [i];
|
|
23
|
+
for (let j = 1; j <= b.length; j++) {
|
|
24
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
25
|
+
cur.push(Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost));
|
|
26
|
+
}
|
|
27
|
+
for (let j = 0; j <= b.length; j++)
|
|
28
|
+
prev[j] = cur[j];
|
|
29
|
+
}
|
|
30
|
+
return prev[b.length];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Known deprecated key names that don't match their canonical name by
|
|
34
|
+
* Levenshtein distance. Checked before the generic Levenshtein pass so
|
|
35
|
+
* `{"kind": "parent"}` on a link op maps to "relation" even though the
|
|
36
|
+
* edit distance is much larger than the threshold.
|
|
37
|
+
*/
|
|
38
|
+
const DEPRECATED_KEY_ALIASES = {
|
|
39
|
+
kind: 'relation',
|
|
40
|
+
};
|
|
41
|
+
export function didYouMean(key, validKeys, maxDistance = 3) {
|
|
42
|
+
// Manual alias pass first — covers rename history where the new and old
|
|
43
|
+
// names have no shared letters.
|
|
44
|
+
const manual = DEPRECATED_KEY_ALIASES[key];
|
|
45
|
+
if (manual && validKeys.includes(manual))
|
|
46
|
+
return manual;
|
|
47
|
+
let best = null;
|
|
48
|
+
for (const k of validKeys) {
|
|
49
|
+
const d = levenshtein(key, k);
|
|
50
|
+
if (d === 0)
|
|
51
|
+
continue;
|
|
52
|
+
if (d <= maxDistance && (!best || d < best.dist))
|
|
53
|
+
best = { key: k, dist: d };
|
|
54
|
+
}
|
|
55
|
+
return best ? best.key : null;
|
|
56
|
+
}
|
|
57
|
+
// --- Schema-key lookup for unrecognized_keys hints ---
|
|
58
|
+
/**
|
|
59
|
+
* Collect the full set of valid keys at the level where `path` points. Used for
|
|
60
|
+
* did-you-mean across `unrecognized_keys` issues. `path` is the Zod issue path —
|
|
61
|
+
* typically something like `['fields']` or `[]` at the op level.
|
|
62
|
+
*/
|
|
63
|
+
function validKeysAtPath(opName, path) {
|
|
64
|
+
const opSchema = getOpSchema(opName);
|
|
65
|
+
if (!opSchema)
|
|
66
|
+
return [];
|
|
67
|
+
let cur = { type: 'object', fields: opSchema.fields, strict: true };
|
|
68
|
+
for (const seg of path) {
|
|
69
|
+
if (cur.type !== 'object' || !cur.fields)
|
|
70
|
+
return [];
|
|
71
|
+
const next = cur.fields[String(seg)];
|
|
72
|
+
if (!next)
|
|
73
|
+
return [];
|
|
74
|
+
cur = next;
|
|
75
|
+
}
|
|
76
|
+
if (cur.type === 'object' && cur.fields)
|
|
77
|
+
return Object.keys(cur.fields);
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
// --- Op-name resolution from an unknown input ---
|
|
81
|
+
/**
|
|
82
|
+
* Given the raw op JSON, determine a display name for the schema pointer.
|
|
83
|
+
* For create ops, returns `create-<kind>` if kind is a valid literal, else `create`.
|
|
84
|
+
* For typos in `op` itself, returns null.
|
|
85
|
+
*/
|
|
86
|
+
export function opNameOf(raw) {
|
|
87
|
+
if (!raw)
|
|
88
|
+
return null;
|
|
89
|
+
const op = raw.op;
|
|
90
|
+
if (typeof op !== 'string')
|
|
91
|
+
return null;
|
|
92
|
+
if (op === 'create') {
|
|
93
|
+
const kind = raw.kind;
|
|
94
|
+
if (typeof kind === 'string')
|
|
95
|
+
return `create-${kind}`;
|
|
96
|
+
return 'create';
|
|
97
|
+
}
|
|
98
|
+
const known = new Set(listOps().map(o => o.op));
|
|
99
|
+
return known.has(op) ? op : null;
|
|
100
|
+
}
|
|
101
|
+
function formatPath(path) {
|
|
102
|
+
if (!path.length)
|
|
103
|
+
return '(root)';
|
|
104
|
+
return path.map(p => (typeof p === 'number' ? `[${p}]` : typeof p === 'symbol' ? p.toString() : p)).join('.');
|
|
105
|
+
}
|
|
106
|
+
function lookupByPath(obj, path) {
|
|
107
|
+
let cur = obj;
|
|
108
|
+
for (const p of path) {
|
|
109
|
+
if (cur == null)
|
|
110
|
+
return undefined;
|
|
111
|
+
cur = cur[p];
|
|
112
|
+
}
|
|
113
|
+
return cur;
|
|
114
|
+
}
|
|
115
|
+
function toStr(v) {
|
|
116
|
+
if (typeof v === 'string')
|
|
117
|
+
return `"${v}"`;
|
|
118
|
+
if (v === null)
|
|
119
|
+
return 'null';
|
|
120
|
+
if (v === undefined)
|
|
121
|
+
return 'undefined';
|
|
122
|
+
try {
|
|
123
|
+
return JSON.stringify(v);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return String(v);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const VALID_OPS = ['update', 'create', 'capture', 'link', 'unlink', 'promote'];
|
|
130
|
+
/**
|
|
131
|
+
* Format a single Zod issue into a readable block. Caller supplies the op
|
|
132
|
+
* index and the raw op JSON so the block can reference the right `hafez
|
|
133
|
+
* schema <op>` pointer.
|
|
134
|
+
*/
|
|
135
|
+
export function formatIssue(issue, opIndex, raw) {
|
|
136
|
+
const opDisplay = opNameOf(raw) ?? (typeof raw?.op === 'string' ? String(raw.op) : '?');
|
|
137
|
+
const pointerName = opNameOf(raw) ?? 'update'; // fallback pointer so the tip still renders
|
|
138
|
+
const fieldPath = formatPath(issue.path);
|
|
139
|
+
const tip = `tip: run \`hafez schema ${pointerName}\` for the full field list`;
|
|
140
|
+
const header = `[index ${opIndex}] op=${opDisplay}`;
|
|
141
|
+
const anyIssue = issue;
|
|
142
|
+
const code = anyIssue.code;
|
|
143
|
+
// Zod v4 emits `invalid_union` with a `discriminator` field when the
|
|
144
|
+
// discriminatedUnion can't match the discriminator value to a branch.
|
|
145
|
+
// For the outer `op` union this means "unknown op". For the inner create
|
|
146
|
+
// union this means "unknown kind".
|
|
147
|
+
if (code === 'invalid_union' && anyIssue.discriminator) {
|
|
148
|
+
const discriminator = anyIssue.discriminator;
|
|
149
|
+
if (discriminator === 'op') {
|
|
150
|
+
const got = typeof raw?.op === 'string' ? `"${raw.op}"` : toStr(raw?.op);
|
|
151
|
+
return [
|
|
152
|
+
header,
|
|
153
|
+
` field: op`,
|
|
154
|
+
` issue: unknown op ${got} (expected one of [${VALID_OPS.join(', ')}])`,
|
|
155
|
+
` tip: run \`hafez schema\` for the op catalog`,
|
|
156
|
+
].join('\n');
|
|
157
|
+
}
|
|
158
|
+
if (discriminator === 'kind') {
|
|
159
|
+
const got = typeof raw?.kind === 'string' ? `"${raw.kind}"` : toStr(raw?.kind);
|
|
160
|
+
return [
|
|
161
|
+
header,
|
|
162
|
+
` field: kind`,
|
|
163
|
+
` issue: unknown kind ${got} (expected one of [entity, knowledge, session])`,
|
|
164
|
+
` ${tip}`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (code === 'unrecognized_keys') {
|
|
169
|
+
const keys = issue.keys;
|
|
170
|
+
const first = keys?.[0] ?? '<unknown>';
|
|
171
|
+
const stringPath = [];
|
|
172
|
+
for (const p of issue.path)
|
|
173
|
+
stringPath.push(typeof p === 'symbol' ? p.toString() : p);
|
|
174
|
+
const valid = validKeysAtPath(pointerName, stringPath);
|
|
175
|
+
const suggestion = didYouMean(first, valid);
|
|
176
|
+
const suggestText = suggestion ? ` (did you mean "${suggestion}"?)` : '';
|
|
177
|
+
return [
|
|
178
|
+
header,
|
|
179
|
+
` field: ${first}${fieldPath === '(root)' ? '' : ` (under ${fieldPath})`}`,
|
|
180
|
+
` issue: unknown field${suggestText}`,
|
|
181
|
+
...(valid.length ? [` allowed: ${valid.join(', ')}`] : []),
|
|
182
|
+
` ${tip}`,
|
|
183
|
+
].join('\n');
|
|
184
|
+
}
|
|
185
|
+
if (code === 'invalid_enum_value' || code === 'invalid_value') {
|
|
186
|
+
// Zod v3 uses `options`, Zod v4 uses `values`.
|
|
187
|
+
const expected = anyIssue.options ?? anyIssue.values;
|
|
188
|
+
// Zod v4 doesn't attach the received value to the issue, so walk the raw
|
|
189
|
+
// input by path to find it.
|
|
190
|
+
const received = anyIssue.received ?? anyIssue.input ?? lookupByPath(raw, issue.path);
|
|
191
|
+
return [
|
|
192
|
+
header,
|
|
193
|
+
` field: ${fieldPath}`,
|
|
194
|
+
` issue: invalid value`,
|
|
195
|
+
...(expected ? [` expected: one of [${expected.join(', ')}]`] : []),
|
|
196
|
+
` got: ${toStr(received)}`,
|
|
197
|
+
` ${tip}`,
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
if (code === 'invalid_type') {
|
|
201
|
+
const expected = issue.expected;
|
|
202
|
+
const received = issue.received;
|
|
203
|
+
return [
|
|
204
|
+
header,
|
|
205
|
+
` field: ${fieldPath}`,
|
|
206
|
+
` issue: wrong type`,
|
|
207
|
+
` expected: ${expected}`,
|
|
208
|
+
` got: ${received}`,
|
|
209
|
+
` ${tip}`,
|
|
210
|
+
].join('\n');
|
|
211
|
+
}
|
|
212
|
+
if (code === 'invalid_literal') {
|
|
213
|
+
const expected = issue.expected;
|
|
214
|
+
return [
|
|
215
|
+
header,
|
|
216
|
+
` field: ${fieldPath}`,
|
|
217
|
+
` issue: invalid literal`,
|
|
218
|
+
` expected: ${toStr(expected)}`,
|
|
219
|
+
` ${tip}`,
|
|
220
|
+
].join('\n');
|
|
221
|
+
}
|
|
222
|
+
// Fallback — unknown issue code.
|
|
223
|
+
return [
|
|
224
|
+
header,
|
|
225
|
+
` field: ${fieldPath}`,
|
|
226
|
+
` issue: ${issue.message}`,
|
|
227
|
+
` ${tip}`,
|
|
228
|
+
].join('\n');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Top-level entry for a single op. Returns one composite block that concatenates
|
|
232
|
+
* every issue the safeParse returned, preserving all N issues (never drops).
|
|
233
|
+
*/
|
|
234
|
+
export function formatBatchError(issues, opIndex, raw) {
|
|
235
|
+
return issues.map(i => formatIssue(i, opIndex, raw)).join('\n');
|
|
236
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/cli/commands-schema.ts
|
|
2
|
+
//
|
|
3
|
+
// `hafez schema` — machine-readable lookup surface. Three forms:
|
|
4
|
+
//
|
|
5
|
+
// hafez schema # list every op
|
|
6
|
+
// hafez schema <op> # full JSON schema for one op
|
|
7
|
+
// hafez schema <op> --examples # working JSON examples per enum value
|
|
8
|
+
//
|
|
9
|
+
// Output is JSON by default. All data is derived from schema-introspect.ts.
|
|
10
|
+
// Unknown op names produce a did-you-mean error and exit non-zero.
|
|
11
|
+
import { HafezError } from '../types.js';
|
|
12
|
+
import { listOps, zodToJsonSchema, getOpExample, getEnumValues, getOpSchema, DIGEST_DESCRIPTION } from './schema-introspect.js';
|
|
13
|
+
import { didYouMean } from './batch-errors.js';
|
|
14
|
+
export async function cmdSchema(_os, args) {
|
|
15
|
+
// args come in already-stripped of global flags; first positional is the op name (if any)
|
|
16
|
+
const target = args[0];
|
|
17
|
+
const wantExamples = args.includes('--examples');
|
|
18
|
+
if (!target) {
|
|
19
|
+
// Form 1 — list ops. Digest is appended by hand: it is a stdin pipe, not
|
|
20
|
+
// a BatchOperationSchema branch, so listOps() deliberately excludes it.
|
|
21
|
+
const ops = listOps().map(o => ({ name: o.name, op: o.op, kind: o.kind, description: o.description }));
|
|
22
|
+
ops.push({ name: 'digest', op: 'digest', kind: undefined, description: DIGEST_DESCRIPTION });
|
|
23
|
+
return JSON.stringify({ ops }, null, 2);
|
|
24
|
+
}
|
|
25
|
+
// Form 2 / 3 — single op
|
|
26
|
+
const json = zodToJsonSchema(target);
|
|
27
|
+
if (!json) {
|
|
28
|
+
const names = [...listOps().map(o => o.name), 'digest'];
|
|
29
|
+
const suggestion = didYouMean(target, names);
|
|
30
|
+
const hint = suggestion ? ` (did you mean "${suggestion}"?)` : '';
|
|
31
|
+
throw new HafezError('VALIDATION_FAILED', `Unknown op: "${target}"${hint}`, [`Available ops: ${names.join(', ')}`]);
|
|
32
|
+
}
|
|
33
|
+
if (!wantExamples) {
|
|
34
|
+
return JSON.stringify(json, null, 2);
|
|
35
|
+
}
|
|
36
|
+
// Form 3 — include working examples, one per enum value on primary enums.
|
|
37
|
+
const examples = [];
|
|
38
|
+
const base = getOpExample(target);
|
|
39
|
+
if (base)
|
|
40
|
+
examples.push(base);
|
|
41
|
+
// Expand examples across each enum field in the op's fields tree.
|
|
42
|
+
const opSchema = getOpSchema(target);
|
|
43
|
+
if (opSchema && base) {
|
|
44
|
+
for (const [enumPath, enumValues] of enumFieldsOf(target, opSchema.fields)) {
|
|
45
|
+
// Skip literal fields (op, kind) — those are fixed for the branch.
|
|
46
|
+
if (enumPath === 'op' || enumPath === 'kind')
|
|
47
|
+
continue;
|
|
48
|
+
for (const v of enumValues) {
|
|
49
|
+
const variant = structuredClone(base);
|
|
50
|
+
setDottedValue(variant, enumPath, v);
|
|
51
|
+
examples.push(variant);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return JSON.stringify({ schema: json, examples }, null, 2);
|
|
56
|
+
}
|
|
57
|
+
function* enumFieldsOf(_opName, fields, prefix = []) {
|
|
58
|
+
for (const [k, f] of Object.entries(fields)) {
|
|
59
|
+
const path = [...prefix, k];
|
|
60
|
+
if (f.type === 'enum' && f.enum) {
|
|
61
|
+
yield [path.join('.'), f.enum];
|
|
62
|
+
}
|
|
63
|
+
else if (f.type === 'object' && f.fields) {
|
|
64
|
+
yield* enumFieldsOf(_opName, f.fields, path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function setDottedValue(obj, path, value) {
|
|
69
|
+
const parts = path.split('.');
|
|
70
|
+
let cur = obj;
|
|
71
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
72
|
+
const p = parts[i];
|
|
73
|
+
if (cur[p] == null || typeof cur[p] !== 'object')
|
|
74
|
+
cur[p] = {};
|
|
75
|
+
cur = cur[p];
|
|
76
|
+
}
|
|
77
|
+
cur[parts[parts.length - 1]] = value;
|
|
78
|
+
}
|
|
79
|
+
// Re-export for consumers that want a direct enum lookup.
|
|
80
|
+
export { getEnumValues };
|