pkm-mcp-server 1.3.3 → 1.4.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/CHANGELOG.md +14 -0
- package/README.md +32 -8
- package/hooks/resolve-project.js +8 -2
- package/hooks/stop-sweep.js +178 -0
- package/init.js +252 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.4.1] - 2026-03-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- SessionStart hook crash when installed via `init` — `resolve-project.js` imported `resolvePath` from `../helpers.js` which doesn't exist at `~/.claude/hooks/pkm/`. Inlined the path security check to remove the external dependency.
|
|
13
|
+
|
|
14
|
+
## [1.4.0] - 2026-03-21
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `pkm-mcp-server init` now configures PKM hooks for Claude Code — copies hook scripts to `~/.claude/hooks/pkm/` and writes hook entries to `~/.claude/settings.json`
|
|
18
|
+
- Individual opt-in for each hook: project context loading (SessionStart), passive capture (Stop), and explicit capture (PostToolUse)
|
|
19
|
+
- Merge-aware settings.json writing — preserves unrelated hooks, replaces PKM entries on re-run
|
|
20
|
+
- Hook script MCP config patching — works for both npx and cloned-repo installations
|
|
21
|
+
- 32 new unit tests for hook setup functions (`patchMcpConfig`, `isPkmHookEntry`, `buildHookEntries`, `mergeHooksIntoSettings`, `copyHooks`)
|
|
22
|
+
|
|
9
23
|
## [1.3.3] - 2026-03-21
|
|
10
24
|
|
|
11
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -91,12 +91,34 @@ npm install -g pkm-mcp-server
|
|
|
91
91
|
pkm-mcp-server init
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
The setup wizard walks you through
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
The setup wizard walks you through 5 steps. Nothing is written until you confirm each step, and you can press Ctrl+C at any time to cancel.
|
|
95
|
+
|
|
96
|
+
**Step 1 — Vault path.** Point to an existing Obsidian vault or create a new one. The wizard resolves `~`, `$HOME`, and relative paths automatically. Safety checks prevent using system directories (`/`, `/home`, etc.) as a vault. For existing non-empty directories you can use it as-is, create a subfolder inside it, or wipe it (with triple confirmation). You'll be offered an optional backup before any changes — this creates a timestamped copy next to the vault (e.g. `PKM-backup-2026-03-21T14-30-00/`).
|
|
97
|
+
|
|
98
|
+
**Step 2 — Note templates.** Copies template files into `<vault>/05-Templates/`. Three options:
|
|
99
|
+
- **Full set** — all 13 templates (`adr`, `daily-note`, `devlog`, `fleeting-note`, `literature-note`, `meeting-notes`, `moc`, `note`, `permanent-note`, `project-index`, `research-note`, `task`, `troubleshooting-log`)
|
|
100
|
+
- **Minimal** — just `note.md` (a single generic template)
|
|
101
|
+
- **Skip** — for users with their own templates
|
|
102
|
+
|
|
103
|
+
Existing templates are never overwritten.
|
|
104
|
+
|
|
105
|
+
**Step 3 — PARA folder structure.** Creates 7 top-level folders with `_index.md` stubs:
|
|
106
|
+
|
|
107
|
+
| Folder | Purpose |
|
|
108
|
+
|--------|---------|
|
|
109
|
+
| `00-Inbox/` | Quick captures and unsorted notes |
|
|
110
|
+
| `01-Projects/` | Active project folders |
|
|
111
|
+
| `02-Areas/` | Ongoing areas of responsibility |
|
|
112
|
+
| `03-Resources/` | Reference material and reusable knowledge |
|
|
113
|
+
| `04-Archive/` | Completed or inactive items |
|
|
114
|
+
| `05-Templates/` | Note templates |
|
|
115
|
+
| `06-System/` | System configuration and metadata |
|
|
116
|
+
|
|
117
|
+
Each `_index.md` has `type: moc` frontmatter. Existing folders and index files are skipped.
|
|
118
|
+
|
|
119
|
+
**Step 4 — OpenAI API key (optional).** Enables `vault_semantic_search` and `vault_suggest_links`. The key is stored only in your Claude Code configuration (`~/.claude.json`) and is used solely for generating text embeddings. You can add this later — see [Enable Semantic Search](#3-enable-semantic-search-optional).
|
|
120
|
+
|
|
121
|
+
**Step 5 — Claude Code registration.** Registers the MCP server via `claude mcp add -s user`. If `obsidian-pkm` is already registered, you'll be asked whether to overwrite. The exact command is shown for confirmation before running. If the `claude` CLI is not found on PATH, the wizard prints the manual registration command instead.
|
|
100
122
|
|
|
101
123
|
Restart Claude Code after setup. The server provides all tools except semantic search out of the box.
|
|
102
124
|
|
|
@@ -109,6 +131,8 @@ npm install
|
|
|
109
131
|
node cli.js init
|
|
110
132
|
```
|
|
111
133
|
|
|
134
|
+
You can also run the wizard without a global install: `npx pkm-mcp-server init`.
|
|
135
|
+
|
|
112
136
|
### 2. Manual Registration (alternative)
|
|
113
137
|
|
|
114
138
|
If you prefer to skip the wizard, register directly with the Claude CLI:
|
|
@@ -222,9 +246,9 @@ Vault/
|
|
|
222
246
|
|
|
223
247
|
### Templates
|
|
224
248
|
|
|
225
|
-
|
|
249
|
+
`vault_write` loads all `.md` files from `05-Templates/` at startup and enforces frontmatter on every note created. The setup wizard (`pkm-mcp-server init`) installs these automatically — or you can copy the files from `templates/` manually.
|
|
226
250
|
|
|
227
|
-
|
|
251
|
+
13 included templates: `adr`, `daily-note`, `devlog`, `fleeting-note`, `literature-note`, `meeting-notes`, `moc`, `note`, `permanent-note`, `project-index`, `research-note`, `task`, `troubleshooting-log`. Add your own templates to `05-Templates/` and they become available to `vault_write` automatically.
|
|
228
252
|
|
|
229
253
|
### CLAUDE.md for Your Projects
|
|
230
254
|
|
package/hooks/resolve-project.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
function assertPathWithinVault(relativePath, vaultPath) {
|
|
5
|
+
const resolved = path.resolve(vaultPath, relativePath);
|
|
6
|
+
if (resolved !== vaultPath && !resolved.startsWith(vaultPath + path.sep)) {
|
|
7
|
+
throw new Error("Path escapes vault directory");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
export async function resolveProject(cwd, vaultPath) {
|
|
6
12
|
try {
|
|
@@ -35,7 +41,7 @@ export async function resolveProject(cwd, vaultPath) {
|
|
|
35
41
|
if (match) {
|
|
36
42
|
const annotatedPath = match[1].trim();
|
|
37
43
|
try {
|
|
38
|
-
|
|
44
|
+
assertPathWithinVault(annotatedPath, vaultPath);
|
|
39
45
|
} catch (e) {
|
|
40
46
|
if (e.message === "Path escapes vault directory") {
|
|
41
47
|
return { error: `CLAUDE.md annotation escapes vault directory: ${annotatedPath}` };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFileSync, createWriteStream, mkdirSync } from "node:fs";
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { resolveProject } from "./resolve-project.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const VAULT_PATH = process.env.VAULT_PATH;
|
|
12
|
+
const LOG_DIR = VAULT_PATH ? join(VAULT_PATH, ".obsidian", "hook-logs") : null;
|
|
13
|
+
|
|
14
|
+
function logError(message) {
|
|
15
|
+
if (!LOG_DIR) {
|
|
16
|
+
console.error(`stop-sweep: ${message}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
21
|
+
appendFileSync(join(LOG_DIR, "sweep-errors.log"), `${new Date().toISOString()} ${message}\n`);
|
|
22
|
+
} catch {
|
|
23
|
+
console.error(`stop-sweep: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
// Read hook input from stdin
|
|
29
|
+
let inputJson = "";
|
|
30
|
+
for await (const chunk of process.stdin) {
|
|
31
|
+
inputJson += chunk;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let input;
|
|
35
|
+
try {
|
|
36
|
+
input = JSON.parse(inputJson);
|
|
37
|
+
} catch {
|
|
38
|
+
logError("could not parse hook input JSON");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { transcript_path, session_id, cwd } = input;
|
|
43
|
+
|
|
44
|
+
// Skip if no transcript
|
|
45
|
+
if (!transcript_path) return;
|
|
46
|
+
try {
|
|
47
|
+
await access(transcript_path);
|
|
48
|
+
} catch {
|
|
49
|
+
return; // transcript file missing — normal for non-conversation triggers
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!VAULT_PATH) {
|
|
53
|
+
console.error("stop-sweep: VAULT_PATH not set");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!cwd) {
|
|
58
|
+
logError("hook input missing 'cwd' field");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve project — no project, no captures
|
|
63
|
+
const { projectPath, error } = await resolveProject(cwd, VAULT_PATH);
|
|
64
|
+
if (error || !projectPath) return;
|
|
65
|
+
|
|
66
|
+
// Build MCP config
|
|
67
|
+
const indexPath = join(__dirname, "..", "index.js");
|
|
68
|
+
const mcpEnv = { VAULT_PATH };
|
|
69
|
+
if (process.env.OPENAI_API_KEY) {
|
|
70
|
+
mcpEnv.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
71
|
+
}
|
|
72
|
+
const mcpConfig = JSON.stringify({
|
|
73
|
+
mcpServers: {
|
|
74
|
+
"obsidian-pkm": {
|
|
75
|
+
command: "node",
|
|
76
|
+
args: [indexPath],
|
|
77
|
+
env: mcpEnv,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const prompt = `You are a PKM librarian agent. Your job is to identify PKM-worthy content from the most recent conversation exchange and file it correctly in the vault.
|
|
83
|
+
|
|
84
|
+
## Context
|
|
85
|
+
|
|
86
|
+
- Project: ${projectPath}
|
|
87
|
+
- Session: ${session_id}
|
|
88
|
+
- Transcript: ${transcript_path}
|
|
89
|
+
|
|
90
|
+
## Step 1: Read the transcript
|
|
91
|
+
|
|
92
|
+
Read the file at ${transcript_path}. Find the LAST user message and LAST assistant response — this is the current exchange. All prior messages are historical context only. Do NOT capture anything from prior messages.
|
|
93
|
+
|
|
94
|
+
## Step 2: Classify what's PKM-worthy
|
|
95
|
+
|
|
96
|
+
Be conservative. Most exchanges produce NOTHING worth capturing. Skip:
|
|
97
|
+
- Trivial Q&A, clarifications, implementation details obvious from code
|
|
98
|
+
- Anything restating existing project context
|
|
99
|
+
- Content already handled by a vault_capture call in the same exchange
|
|
100
|
+
(read the vault_capture arguments to see what type/title/content was captured;
|
|
101
|
+
skip that specific item, but still capture other PKM-worthy content)
|
|
102
|
+
|
|
103
|
+
Before creating any note, use vault_search to check if a very similar note already exists — another sweep instance may have captured the same content from a prior exchange in a rapid conversation.
|
|
104
|
+
|
|
105
|
+
If you find something worth capturing, classify it:
|
|
106
|
+
|
|
107
|
+
| Content Type | Action |
|
|
108
|
+
|---|---|
|
|
109
|
+
| New task identified | Create task note: vault_write(template: "task", path: "${projectPath}/tasks/{kebab-title}.md") |
|
|
110
|
+
| Task completed/status changed | Find existing task via vault_query, then vault_update_frontmatter to update status |
|
|
111
|
+
| Significant decision agreed upon | Create ADR: vault_write(template: "adr", path: "${projectPath}/development/decisions/ADR-NNN-{kebab-title}.md") — use vault_list on the decisions directory to find the next ADR number |
|
|
112
|
+
| Research finding / gotcha | Create research note: vault_write(template: "research-note", path: "${projectPath}/research/{kebab-title}.md") |
|
|
113
|
+
| Bug root cause documented | Create troubleshooting log: vault_write(template: "troubleshooting-log", path: "${projectPath}/development/debug/{kebab-title}.md") |
|
|
114
|
+
| Minor implementation detail | SKIP — it's in the code/commits |
|
|
115
|
+
|
|
116
|
+
## Step 3: Create/update notes properly
|
|
117
|
+
|
|
118
|
+
For every note you create:
|
|
119
|
+
1. Use vault_write with the correct template
|
|
120
|
+
2. Use vault_edit to replace ALL template placeholders with real content
|
|
121
|
+
3. Use vault_suggest_links to find related notes (skip gracefully if unavailable)
|
|
122
|
+
4. Add [[wikilinks]] to the related notes in the body
|
|
123
|
+
5. Verify the note has no placeholder text remaining
|
|
124
|
+
|
|
125
|
+
For task updates:
|
|
126
|
+
1. Use vault_query to find the task by title/tags
|
|
127
|
+
2. Use vault_update_frontmatter to update status/priority
|
|
128
|
+
3. Optionally vault_append to add context about what changed
|
|
129
|
+
|
|
130
|
+
## Step 4: Quality check
|
|
131
|
+
|
|
132
|
+
If you created any notes, read each one back to confirm:
|
|
133
|
+
- No template placeholders remain
|
|
134
|
+
- Wikilinks are present to related notes (if vault_suggest_links was available)
|
|
135
|
+
- Frontmatter is complete and valid
|
|
136
|
+
|
|
137
|
+
If nothing is PKM-worthy, do nothing. Doing nothing is the correct default.`;
|
|
138
|
+
|
|
139
|
+
// All vault tools except vault_trash and vault_capture
|
|
140
|
+
const allowedTools = [
|
|
141
|
+
"vault_read", "vault_peek", "vault_write", "vault_append", "vault_edit",
|
|
142
|
+
"vault_update_frontmatter", "vault_search", "vault_semantic_search",
|
|
143
|
+
"vault_suggest_links", "vault_list", "vault_recent", "vault_links",
|
|
144
|
+
"vault_neighborhood", "vault_query", "vault_tags", "vault_activity", "vault_move",
|
|
145
|
+
].map(t => `mcp__obsidian-pkm__${t}`).join(" ");
|
|
146
|
+
|
|
147
|
+
// Ensure log directory exists
|
|
148
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
149
|
+
const logFile = join(LOG_DIR, `sweep-${new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)}.log`);
|
|
150
|
+
|
|
151
|
+
const child = spawn("claude", [
|
|
152
|
+
"-p",
|
|
153
|
+
"--model", "sonnet",
|
|
154
|
+
"--mcp-config", mcpConfig,
|
|
155
|
+
"--max-turns", "15",
|
|
156
|
+
"--allowedTools", allowedTools,
|
|
157
|
+
], {
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Pipe prompt to stdin
|
|
163
|
+
child.stdin.write(prompt);
|
|
164
|
+
child.stdin.end();
|
|
165
|
+
|
|
166
|
+
// Log stdout and stderr to file
|
|
167
|
+
const logStream = createWriteStream(logFile, { flags: "a" });
|
|
168
|
+
child.stdout.pipe(logStream);
|
|
169
|
+
child.stderr.pipe(logStream);
|
|
170
|
+
|
|
171
|
+
// Detach — let the background process run independently
|
|
172
|
+
child.unref();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((err) => {
|
|
176
|
+
logError(`uncaught: ${err.message}`);
|
|
177
|
+
process.exit(0);
|
|
178
|
+
});
|
package/init.js
CHANGED
|
@@ -198,6 +198,160 @@ export function detectInstallType(filePath) {
|
|
|
198
198
|
return { command: "node", args: [cliPath] };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Replace the MCP_CONFIG= line in a shell script with a pre-baked JSON config.
|
|
203
|
+
* @param {string} scriptContent - Shell script content
|
|
204
|
+
* @param {{ command: string, args: string[] }} installType - Server command
|
|
205
|
+
* @returns {string} Patched script content
|
|
206
|
+
*/
|
|
207
|
+
export function patchMcpConfig(scriptContent, installType) {
|
|
208
|
+
const argsJson = JSON.stringify(installType.args);
|
|
209
|
+
const replacement = `MCP_CONFIG='{"mcpServers":{"obsidian-pkm":{"command":"${installType.command}","args":${argsJson},"env":{"VAULT_PATH":"'"$VAULT_PATH"'"}}}}'`;
|
|
210
|
+
const lines = scriptContent.split("\n");
|
|
211
|
+
const patched = lines.map(line => line.startsWith("MCP_CONFIG=") ? replacement : line);
|
|
212
|
+
return patched.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const PKM_HOOK_BASENAMES = new Set(["session-start.js", "stop-sweep.sh", "capture-handler.sh"]);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Detect if a hook entry is a PKM hook (by path substring or script basename).
|
|
219
|
+
* @param {object} entry - A hook event entry with `hooks` array
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
export function isPkmHookEntry(entry) {
|
|
223
|
+
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
|
|
224
|
+
return entry.hooks.some(h => {
|
|
225
|
+
const cmd = h.command || "";
|
|
226
|
+
if (cmd.includes("hooks/pkm/")) return true;
|
|
227
|
+
for (const basename of PKM_HOOK_BASENAMES) {
|
|
228
|
+
if (cmd.includes(basename)) return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build settings.json hook config entries for enabled hooks.
|
|
236
|
+
* @param {string} vaultPath - Absolute vault path
|
|
237
|
+
* @param {string} hooksDir - Absolute path to installed hooks directory
|
|
238
|
+
* @param {{ sessionStart: boolean, stopSweep: boolean, captureHandler: boolean }} enabledHooks
|
|
239
|
+
* @returns {Object} Hook entries keyed by event name (SessionStart, Stop, PostToolUse)
|
|
240
|
+
*/
|
|
241
|
+
export function buildHookEntries(vaultPath, hooksDir, enabledHooks) {
|
|
242
|
+
const entries = {};
|
|
243
|
+
|
|
244
|
+
if (enabledHooks.sessionStart) {
|
|
245
|
+
entries.SessionStart = [{
|
|
246
|
+
matcher: "startup|clear|compact",
|
|
247
|
+
hooks: [{
|
|
248
|
+
type: "command",
|
|
249
|
+
command: `VAULT_PATH="${vaultPath}" node ${path.join(hooksDir, "session-start.js")}`,
|
|
250
|
+
timeout: 15,
|
|
251
|
+
statusMessage: "Loading PKM project context...",
|
|
252
|
+
}],
|
|
253
|
+
}];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (enabledHooks.stopSweep) {
|
|
257
|
+
entries.Stop = [{
|
|
258
|
+
hooks: [{
|
|
259
|
+
type: "command",
|
|
260
|
+
command: `VAULT_PATH="${vaultPath}" ${path.join(hooksDir, "stop-sweep.sh")}`,
|
|
261
|
+
async: true,
|
|
262
|
+
timeout: 10,
|
|
263
|
+
}],
|
|
264
|
+
}];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (enabledHooks.captureHandler) {
|
|
268
|
+
entries.PostToolUse = [{
|
|
269
|
+
matcher: "mcp__obsidian-pkm__vault_capture",
|
|
270
|
+
hooks: [{
|
|
271
|
+
type: "command",
|
|
272
|
+
command: `VAULT_PATH="${vaultPath}" ${path.join(hooksDir, "capture-handler.sh")}`,
|
|
273
|
+
async: true,
|
|
274
|
+
timeout: 10,
|
|
275
|
+
}],
|
|
276
|
+
}];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return entries;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Merge PKM hook entries into ~/.claude/settings.json.
|
|
284
|
+
* @param {string} settingsPath - Path to settings.json
|
|
285
|
+
* @param {Object} hookEntries - Entries to add, keyed by event name
|
|
286
|
+
* @param {string[]} disabledEvents - Event names where PKM hooks should be removed
|
|
287
|
+
* @returns {Promise<{ error?: string }>}
|
|
288
|
+
*/
|
|
289
|
+
export async function mergeHooksIntoSettings(settingsPath, hookEntries, disabledEvents) {
|
|
290
|
+
let settings = {};
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const raw = await fs.readFile(settingsPath, "utf8");
|
|
294
|
+
try {
|
|
295
|
+
settings = JSON.parse(raw);
|
|
296
|
+
} catch {
|
|
297
|
+
return { error: `${settingsPath} contains invalid JSON. Fix it manually and re-run init.` };
|
|
298
|
+
}
|
|
299
|
+
} catch (e) {
|
|
300
|
+
if (e.code !== "ENOENT") throw e;
|
|
301
|
+
// File doesn't exist — start fresh
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!settings.hooks) settings.hooks = {};
|
|
305
|
+
|
|
306
|
+
// Add/replace enabled hooks
|
|
307
|
+
for (const [eventName, entries] of Object.entries(hookEntries)) {
|
|
308
|
+
if (!settings.hooks[eventName]) settings.hooks[eventName] = [];
|
|
309
|
+
// Remove existing PKM entries
|
|
310
|
+
settings.hooks[eventName] = settings.hooks[eventName].filter(e => !isPkmHookEntry(e));
|
|
311
|
+
// Append new entries
|
|
312
|
+
settings.hooks[eventName].push(...entries);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Remove disabled hooks
|
|
316
|
+
for (const eventName of disabledEvents) {
|
|
317
|
+
if (!settings.hooks[eventName]) continue;
|
|
318
|
+
settings.hooks[eventName] = settings.hooks[eventName].filter(e => !isPkmHookEntry(e));
|
|
319
|
+
if (settings.hooks[eventName].length === 0) {
|
|
320
|
+
delete settings.hooks[eventName];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
325
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const HOOK_FILES = ["session-start.js", "resolve-project.js", "load-context.js", "stop-sweep.sh", "capture-handler.sh"];
|
|
330
|
+
const SHELL_HOOKS = new Set(["stop-sweep.sh", "capture-handler.sh"]);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Copy hook scripts to destination, patching shell scripts with correct MCP config.
|
|
334
|
+
* @param {string} src - Source hooks directory
|
|
335
|
+
* @param {string} dest - Destination directory (e.g. ~/.claude/hooks/pkm/)
|
|
336
|
+
* @param {{ command: string, args: string[] }} installType - Server command for MCP config patching
|
|
337
|
+
*/
|
|
338
|
+
export async function copyHooks(src, dest, installType) {
|
|
339
|
+
await fs.mkdir(dest, { recursive: true });
|
|
340
|
+
|
|
341
|
+
for (const file of HOOK_FILES) {
|
|
342
|
+
const srcFile = path.join(src, file);
|
|
343
|
+
const destFile = path.join(dest, file);
|
|
344
|
+
|
|
345
|
+
if (SHELL_HOOKS.has(file)) {
|
|
346
|
+
let content = await fs.readFile(srcFile, "utf8");
|
|
347
|
+
content = patchMcpConfig(content, installType);
|
|
348
|
+
await fs.writeFile(destFile, content, { mode: 0o755 });
|
|
349
|
+
} else {
|
|
350
|
+
await fs.copyFile(srcFile, destFile);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
201
355
|
const SYSTEM_DIRS = new Set(["/", "/home", "/usr", "/var", "/etc", "/tmp", "/opt", "/bin", "/sbin"]);
|
|
202
356
|
|
|
203
357
|
function formatBytes(bytes) {
|
|
@@ -222,13 +376,14 @@ export async function runInit() {
|
|
|
222
376
|
pkm-mcp-server setup wizard
|
|
223
377
|
|
|
224
378
|
This will walk you through setting up your Obsidian vault for use with the
|
|
225
|
-
PKM MCP server. You'll be asked about
|
|
379
|
+
PKM MCP server. You'll be asked about 6 things:
|
|
226
380
|
|
|
227
381
|
1. Where your vault is (or where to create one)
|
|
228
382
|
2. Whether to install note templates
|
|
229
383
|
3. Whether to set up the recommended folder structure
|
|
230
384
|
4. An optional OpenAI API key for semantic search
|
|
231
385
|
5. Registering the server with Claude Code
|
|
386
|
+
6. Setting up PKM hooks for Claude Code
|
|
232
387
|
|
|
233
388
|
Nothing is written until you confirm each step. Press Ctrl+C at any time to cancel.
|
|
234
389
|
`);
|
|
@@ -316,6 +471,7 @@ Nothing is written until you confirm each step. Press Ctrl+C at any time to canc
|
|
|
316
471
|
}
|
|
317
472
|
// ── Step 6: Registration ──
|
|
318
473
|
const hasClaude = await checkClaudeCli();
|
|
474
|
+
let hasMcpRegistration = false;
|
|
319
475
|
if (!hasClaude) {
|
|
320
476
|
const installType = detectInstallType();
|
|
321
477
|
const manualCmd = `claude mcp add -s user -e VAULT_PATH=${vaultPath} -- obsidian-pkm ${installType.command} ${installType.args.join(" ")}`;
|
|
@@ -335,6 +491,7 @@ Nothing is written until you confirm each step. Press Ctrl+C at any time to canc
|
|
|
335
491
|
if (!overwrite) {
|
|
336
492
|
console.log(" Registration skipped.\n");
|
|
337
493
|
skipRegistration = true;
|
|
494
|
+
hasMcpRegistration = true;
|
|
338
495
|
} else {
|
|
339
496
|
// Remove existing before re-adding
|
|
340
497
|
try {
|
|
@@ -360,6 +517,7 @@ Nothing is written until you confirm each step. Press Ctrl+C at any time to canc
|
|
|
360
517
|
await execFileAsync("claude", addArgs);
|
|
361
518
|
console.log(" MCP server registered with Claude Code");
|
|
362
519
|
steps.push("MCP server: registered");
|
|
520
|
+
hasMcpRegistration = true;
|
|
363
521
|
} catch (regErr) {
|
|
364
522
|
console.error(`\n Registration failed: ${regErr.message}`);
|
|
365
523
|
if (regErr.stderr) console.error(` ${regErr.stderr.trim()}`);
|
|
@@ -377,7 +535,98 @@ Nothing is written until you confirm each step. Press Ctrl+C at any time to canc
|
|
|
377
535
|
}
|
|
378
536
|
}
|
|
379
537
|
|
|
380
|
-
|
|
538
|
+
const skipHooks = !hasClaude || !hasMcpRegistration;
|
|
539
|
+
|
|
540
|
+
// ── Step 7: PKM Hooks ──
|
|
541
|
+
const hooksDir = path.join(os.homedir(), ".claude", "hooks", "pkm");
|
|
542
|
+
const bundledHooksDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "hooks");
|
|
543
|
+
let hooksSummary;
|
|
544
|
+
|
|
545
|
+
if (skipHooks) {
|
|
546
|
+
const reason = !hasClaude ? "Claude CLI not found" : "no MCP registration";
|
|
547
|
+
hooksSummary = `skipped (${reason})`;
|
|
548
|
+
steps.push(`Hooks: skipped (${reason})`);
|
|
549
|
+
} else {
|
|
550
|
+
// Check for existing hook files
|
|
551
|
+
let hookFilesExist = false;
|
|
552
|
+
try {
|
|
553
|
+
const existing = await fs.readdir(hooksDir);
|
|
554
|
+
hookFilesExist = existing.length > 0;
|
|
555
|
+
} catch { /* doesn't exist */ }
|
|
556
|
+
|
|
557
|
+
let doCopyHooks = true;
|
|
558
|
+
if (hookFilesExist) {
|
|
559
|
+
doCopyHooks = await confirmPrompt({
|
|
560
|
+
message: "PKM hook scripts are already installed. Overwrite with current version?",
|
|
561
|
+
default: false,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (doCopyHooks) {
|
|
566
|
+
try {
|
|
567
|
+
const installType = detectInstallType();
|
|
568
|
+
await copyHooks(bundledHooksDir, hooksDir, installType);
|
|
569
|
+
console.log(` Hook scripts installed to ${hooksDir}`);
|
|
570
|
+
} catch (copyErr) {
|
|
571
|
+
console.error(`\n Hook file installation failed: ${copyErr.message}`);
|
|
572
|
+
const skipHookSetup = await confirmPrompt({ message: "Skip hooks and finish setup?", default: true });
|
|
573
|
+
if (skipHookSetup) {
|
|
574
|
+
hooksSummary = "skipped (file copy failed)";
|
|
575
|
+
steps.push("Hooks: skipped (file copy failed)");
|
|
576
|
+
} else {
|
|
577
|
+
throw copyErr;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Only proceed to config if we haven't set hooksSummary (i.e., no error)
|
|
583
|
+
if (!hooksSummary) {
|
|
584
|
+
const enableSessionStart = await confirmPrompt({
|
|
585
|
+
message: "Enable project context loading? Automatically loads your project's index, recent devlog entries, and active tasks at the start of each Claude Code session.",
|
|
586
|
+
default: true,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const enableStopSweep = await confirmPrompt({
|
|
590
|
+
message: "Enable passive capture? After each Claude response, a background agent scans the conversation for decisions, task changes, and research findings, and stages them in your vault's inbox.",
|
|
591
|
+
default: true,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const enableCaptureHandler = await confirmPrompt({
|
|
595
|
+
message: "Enable explicit capture? When Claude calls vault_capture, a background agent creates a properly structured vault note (ADR, task, research note, or bug report) from the capture payload.",
|
|
596
|
+
default: true,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const enabledHooks = {
|
|
600
|
+
sessionStart: enableSessionStart,
|
|
601
|
+
stopSweep: enableStopSweep,
|
|
602
|
+
captureHandler: enableCaptureHandler,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const hookEntries = buildHookEntries(vaultPath, hooksDir, enabledHooks);
|
|
606
|
+
const disabledEvents = [];
|
|
607
|
+
if (!enableSessionStart) disabledEvents.push("SessionStart");
|
|
608
|
+
if (!enableStopSweep) disabledEvents.push("Stop");
|
|
609
|
+
if (!enableCaptureHandler) disabledEvents.push("PostToolUse");
|
|
610
|
+
|
|
611
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
612
|
+
const mergeResult = await mergeHooksIntoSettings(settingsPath, hookEntries, disabledEvents);
|
|
613
|
+
|
|
614
|
+
if (mergeResult.error) {
|
|
615
|
+
console.error(`\n ${mergeResult.error}`);
|
|
616
|
+
hooksSummary = "skipped (settings.json error)";
|
|
617
|
+
} else {
|
|
618
|
+
const names = [];
|
|
619
|
+
if (enableSessionStart) names.push("context-loading");
|
|
620
|
+
if (enableStopSweep) names.push("passive-capture");
|
|
621
|
+
if (enableCaptureHandler) names.push("explicit-capture");
|
|
622
|
+
hooksSummary = names.length > 0 ? names.join(", ") : "none";
|
|
623
|
+
console.log(` Hooks configured: ${hooksSummary}`);
|
|
624
|
+
}
|
|
625
|
+
steps.push(`Hooks: ${hooksSummary}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── Step 8: Summary ──
|
|
381
630
|
// Build summary lines based on what was actually done
|
|
382
631
|
const templateSummary = templateMode === "skip"
|
|
383
632
|
? "Skipped"
|
|
@@ -402,6 +651,7 @@ Setup complete!
|
|
|
402
651
|
Folders: ${folderSummary}
|
|
403
652
|
Semantic: ${semanticSummary}
|
|
404
653
|
Claude Code: ${registrationSummary}
|
|
654
|
+
Hooks: ${hooksSummary}
|
|
405
655
|
|
|
406
656
|
To verify, restart Claude Code and try:
|
|
407
657
|
"List the folders in my vault"
|