wendkeep 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -39,6 +39,7 @@ npx wendkeep init
39
39
  2. **Merge** the three session hooks and `OBSIDIAN_VAULT_PATH` into `.claude/settings.json` — without clobbering your existing settings (a `.bak` is saved; an unparseable file is left untouched and a `.new` is written for you to merge).
40
40
  3. Add the `wendkeep-vault` MCP server to `.mcp.json` (skip with `--no-mcp`).
41
41
  4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` as an `.mcp.json` MCP server (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. Control with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
42
+ 5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
42
43
 
43
44
  ```bash
44
45
  npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode)
package/bin/wendkeep.mjs CHANGED
@@ -32,6 +32,7 @@ Usage:
32
32
  --companions <csv> Companion plugins/MCP to pin: context-mode,caveman,understand-anything
33
33
  (default when interactive/--yes: context-mode).
34
34
  --no-companions Skip companion plugins/MCP entirely.
35
+ --no-colors Skip the Obsidian color system (.obsidian snippet + graph groups).
35
36
  --yes, -y Non-interactive; accept defaults.
36
37
  --force Overwrite existing wendkeep config blocks.
37
38
 
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
3
3
  import { basename, join } from 'path';
4
4
  import {
5
- datedFolderRelFromDateStr,
5
+ monthFolderRelFromDateStr,
6
6
  derivedContentKey,
7
7
  ensureDir,
8
8
  getNextAdrNumber,
@@ -457,7 +457,7 @@ function existingKeysForSession(vaultBase, sessionRel, dateStr) {
457
457
  const wikilink = wikilinkFromRel(sessionRel);
458
458
  const out = { bugs: [], decisions: [], learnings: [] };
459
459
  for (const [type, folder] of Object.entries(DERIVED_FOLDERS)) {
460
- const dir = join(vaultBase, datedFolderRelFromDateStr(folder, dateStr));
460
+ const dir = join(vaultBase, monthFolderRelFromDateStr(folder, dateStr));
461
461
  for (const fileName of listMd(dir)) {
462
462
  try {
463
463
  const c = readFileSync(join(dir, fileName), 'utf-8');
@@ -477,9 +477,9 @@ function alreadyHasKey(keys, candidate) {
477
477
  export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
478
478
  const linked = { decisions: [], bugs: [], learnings: [] };
479
479
  const provider = providerMeta(options.provider);
480
- const bugsDir = join(vaultBase, datedFolderRelFromDateStr('05-Bugs', dateStr));
481
- const decisionsDir = join(vaultBase, datedFolderRelFromDateStr('04-Decisões', dateStr));
482
- const learningsDir = join(vaultBase, datedFolderRelFromDateStr('06-Aprendizados', dateStr));
480
+ const bugsDir = join(vaultBase, monthFolderRelFromDateStr('05-Bugs', dateStr));
481
+ const decisionsDir = join(vaultBase, monthFolderRelFromDateStr('04-Decisões', dateStr));
482
+ const learningsDir = join(vaultBase, monthFolderRelFromDateStr('06-Aprendizados', dateStr));
483
483
  ensureDir(bugsDir);
484
484
  ensureDir(decisionsDir);
485
485
  ensureDir(learningsDir);
@@ -133,12 +133,19 @@ export function datedFolderRel(rootFolder, date = new Date()) {
133
133
  return join(rootFolder, String(p.year), MONTH_FOLDERS[p.month - 1], `DIA ${pad2(p.day)}`);
134
134
  }
135
135
 
136
- // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (usada pelas notas derivadas).
136
+ // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (sessões: até o DIA).
137
137
  export function datedFolderRelFromDateStr(rootFolder, dateStr) {
138
138
  const [year, month, day] = String(dateStr).split('-');
139
139
  return join(rootFolder, year, MONTH_FOLDERS[Number(month) - 1], `DIA ${pad2(day)}`);
140
140
  }
141
141
 
142
+ // Estrutura até o MÊS (sem DIA) — usada pelas notas derivadas (decisões/bugs/
143
+ // aprendizados): tudo do mês fica junto em <pasta>/<ano>/<MM-MMM>/.
144
+ export function monthFolderRelFromDateStr(rootFolder, dateStr) {
145
+ const [year, month] = String(dateStr).split('-');
146
+ return join(rootFolder, year, MONTH_FOLDERS[Number(month) - 1]);
147
+ }
148
+
142
149
  export function sessionFolderRel(date = new Date()) {
143
150
  return datedFolderRel('02-Sessões', date);
144
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Automatically capture AI coding agent sessions (Claude Code, Codex) as local Markdown in your Obsidian vault — turn-by-turn history, cost/token tracking, auto-extracted decisions/bugs/learnings, rendered in the graph. Local-first.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/init.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
4
4
  // .mcp.json. Idempotent: re-running only adds what is missing.
5
5
  import { spawnSync } from 'node:child_process';
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
7
8
  import { basename, isAbsolute, join, resolve } from 'node:path';
8
9
  import { createInterface } from 'node:readline/promises';
9
10
  import {
@@ -19,9 +20,17 @@ import {
19
20
  companionMcpPatch,
20
21
  companionHookSpecs,
21
22
  cavemanInstallerCommand,
23
+ cavemanInstallerUrl,
22
24
  } from './taxonomy.mjs';
23
25
  import { renderVaultReadme } from './vault-readme.mjs';
24
26
  import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
27
+ import {
28
+ SNIPPET_NAME,
29
+ renderColorSnippetCss,
30
+ mergeAppearance,
31
+ graphColorGroups,
32
+ mergeGraphColorGroups,
33
+ } from './vault-theme.mjs';
25
34
 
26
35
  function parseArgs(argv) {
27
36
  const args = { mcp: true, yes: false, force: false };
@@ -33,6 +42,7 @@ function parseArgs(argv) {
33
42
  else if (a === '--yes' || a === '-y') args.yes = true;
34
43
  else if (a === '--force') args.force = true;
35
44
  else if (a === '--no-companions') args.noCompanions = true;
45
+ else if (a === '--no-colors') args.noColors = true;
36
46
  else if (a === '--companions') args.companions = argv[++i];
37
47
  else if (a.startsWith('--companions=')) args.companions = a.slice(13);
38
48
  else if (a.startsWith('--vault=')) args.vault = a.slice(8);
@@ -118,6 +128,60 @@ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [
118
128
  return m;
119
129
  }
120
130
 
131
+ // Run caveman's cross-agent installer (non-Claude skill coverage). Downloads the
132
+ // script to a temp file and runs it as a FILE — piping to iex/bash leaves the
133
+ // script's self-path null and the installer aborts. Best-effort, fail-soft: the
134
+ // Claude Code plugin entry is already wired regardless.
135
+ async function runCavemanInstaller(log) {
136
+ const url = cavemanInstallerUrl();
137
+ const ext = process.platform === 'win32' ? 'ps1' : 'sh';
138
+ const tmp = join(tmpdir(), `wendkeep-caveman-install-${process.pid}.${ext}`);
139
+ log(`\n caveman: fetching cross-agent installer (best-effort): ${url}`);
140
+ try {
141
+ const res = await fetch(url);
142
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
143
+ writeFileSync(tmp, await res.text(), 'utf8');
144
+ const cmd = cavemanInstallerCommand(process.platform, tmp);
145
+ log(` caveman: running ${cmd.command} ${cmd.args.join(' ')}`);
146
+ const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
147
+ if (r.status !== 0) {
148
+ log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
149
+ }
150
+ } catch (e) {
151
+ log(` [!] caveman installer skipped (${e.message}) — Claude Code plugin entry still wired.`);
152
+ } finally {
153
+ try { rmSync(tmp, { force: true }); } catch { /* temp cleanup best-effort */ }
154
+ }
155
+ }
156
+
157
+ // Install the vault color system into .obsidian: write wendkeep's CSS snippet,
158
+ // enable it (non-destructive), and add graph color groups. Returns a short note.
159
+ // Unparseable user JSON is left untouched (we only merge into valid/absent files).
160
+ function installVaultColors(vaultPath) {
161
+ const obsidianDir = join(vaultPath, '.obsidian');
162
+ const snippetsDir = join(obsidianDir, 'snippets');
163
+ mkdirSync(snippetsDir, { recursive: true });
164
+ writeFileSync(join(snippetsDir, `${SNIPPET_NAME}.css`), renderColorSnippetCss(), 'utf8');
165
+
166
+ let enabled = false;
167
+ const appPath = join(obsidianDir, 'appearance.json');
168
+ const appRead = readJsonSafe(appPath);
169
+ if (appRead.ok) {
170
+ writeJson(appPath, mergeAppearance(appRead.data || {}, SNIPPET_NAME));
171
+ enabled = true;
172
+ }
173
+
174
+ let groups = 0;
175
+ const graphPath = join(obsidianDir, 'graph.json');
176
+ const graphRead = readJsonSafe(graphPath);
177
+ if (graphRead.ok) {
178
+ const merged = mergeGraphColorGroups(graphRead.data || {}, graphColorGroups());
179
+ writeJson(graphPath, merged);
180
+ groups = graphColorGroups().length;
181
+ }
182
+ return `snippet ${SNIPPET_NAME}.css${enabled ? ' (enabled)' : ' (enable by hand: appearance.json unreadable)'} + ${groups} graph group(s)`;
183
+ }
184
+
121
185
  // --- main -------------------------------------------------------------------
122
186
 
123
187
  export async function runInit(argv) {
@@ -148,11 +212,12 @@ export async function runInit(argv) {
148
212
  } else if (args.companions !== undefined) {
149
213
  companions = resolveCompanions({ companionsFlag: args.companions });
150
214
  } else if (process.stdin.isTTY && !args.yes) {
151
- log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
152
215
  if (canInteractiveSelect()) {
216
+ log(''); // the checkbox menu renders its own header
153
217
  companions = await selectCompanionsInteractive(COMPANIONS);
154
218
  } else {
155
219
  // Text fallback (no raw-mode TTY): list + comma input with clear instructions.
220
+ log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
156
221
  for (const c of COMPANIONS) log(` ${c.default ? '[x]' : '[ ]'} ${c.label}`);
157
222
  const def = resolveCompanions({}).join(',');
158
223
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -170,7 +235,8 @@ export async function runInit(argv) {
170
235
  log(` project : ${projectPath}`);
171
236
  log(` vault : ${vaultPath}`);
172
237
  log(` mcp : ${args.mcp ? 'mcpvault (wendkeep-vault)' : 'skipped'}`);
173
- log(` companions : ${companions.length ? companions.join(', ') : 'none'}\n`);
238
+ log(` companions : ${companions.length ? companions.join(', ') : 'none'}`);
239
+ log(` colors : ${args.noColors ? 'skipped' : 'wendkeep-colors (snippet + graph groups)'}\n`);
174
240
 
175
241
  // 1. Vault taxonomy ---------------------------------------------------------
176
242
  if (!existsSync(vaultPath)) mkdirSync(vaultPath, { recursive: true });
@@ -189,7 +255,7 @@ export async function runInit(argv) {
189
255
  writeFileSync(readmePath, renderVaultReadme({ projectName: basename(projectPath), vaultPath, withMcp: args.mcp }), 'utf8');
190
256
  readmeNote = ', README.md created';
191
257
  }
192
- log(` [1/3] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}`);
258
+ log(` [1/4] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}`);
193
259
 
194
260
  // 2. .claude/settings.json --------------------------------------------------
195
261
  const settingsPath = join(projectPath, '.claude', 'settings.json');
@@ -198,13 +264,13 @@ export async function runInit(argv) {
198
264
  // Unparseable existing file — never clobber. Drop a .new for manual merge.
199
265
  const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions }).settings;
200
266
  writeJson(`${settingsPath}.new`, fresh);
201
- log(` [2/3] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
267
+ log(` [2/4] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
202
268
  } else {
203
269
  const hadFile = settingsRead.data !== null;
204
270
  const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions });
205
271
  if (hadFile) backup(settingsPath);
206
272
  writeJson(settingsPath, settings);
207
- log(` [2/3] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
273
+ log(` [2/4] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
208
274
  }
209
275
 
210
276
  // 3. .mcp.json --------------------------------------------------------------
@@ -217,32 +283,28 @@ export async function runInit(argv) {
217
283
  const names = [args.mcp && MCP_SERVER_KEY, ...Object.keys(companionMcp)].filter(Boolean).join(', ');
218
284
  if (!mcpRead.ok) {
219
285
  writeJson(`${mcpPath}.new`, mergeMcp(null, mcpOpts));
220
- log(` [3/3] .mcp.json exists but is not valid JSON -> wrote ${mcpPath}.new (merge by hand)`);
286
+ log(` [3/4] .mcp.json exists but is not valid JSON -> wrote ${mcpPath}.new (merge by hand)`);
221
287
  } else {
222
288
  const hadFile = mcpRead.data !== null;
223
289
  if (hadFile) backup(mcpPath);
224
290
  writeJson(mcpPath, mergeMcp(mcpRead.data, mcpOpts));
225
- log(` [3/3] .mcp.json ${hadFile ? 'merged' : 'created'} (${names}${hadFile ? ', .bak saved' : ''})`);
291
+ log(` [3/4] .mcp.json ${hadFile ? 'merged' : 'created'} (${names}${hadFile ? ', .bak saved' : ''})`);
226
292
  }
227
293
  } else {
228
- log(' [3/3] .mcp.json skipped (--no-mcp, no MCP companions)');
294
+ log(' [3/4] .mcp.json skipped (--no-mcp, no MCP companions)');
295
+ }
296
+
297
+ // 4. Vault color system (.obsidian) -----------------------------------------
298
+ if (args.noColors) {
299
+ log(' [4/4] colors skipped (--no-colors)');
300
+ } else {
301
+ log(` [4/4] colors: ${installVaultColors(vaultPath)}`);
229
302
  }
230
303
 
231
304
  // caveman has no MCP / agnostic-hook path: on non-Claude agents its skills come
232
305
  // from its own cross-agent installer. Best-effort, fail-soft — the Claude Code
233
306
  // plugin entry is already wired in settings.json regardless.
234
- if (companions.includes('caveman')) {
235
- const cmd = cavemanInstallerCommand();
236
- log(`\n caveman: running cross-agent installer (best-effort): ${cmd.command} ${cmd.args.join(' ')}`);
237
- try {
238
- const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
239
- if (r.status !== 0) {
240
- log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
241
- }
242
- } catch (e) {
243
- log(` [!] caveman installer could not run (${e.code || e.message}) — Claude Code plugin entry still wired.`);
244
- }
245
- }
307
+ if (companions.includes('caveman')) await runCavemanInstaller(log);
246
308
 
247
309
  log('\nNext steps:');
248
310
  log(` 1. Open the vault in Obsidian: "Open folder as vault" -> ${vaultPath}`);
package/src/taxonomy.mjs CHANGED
@@ -178,15 +178,21 @@ export function companionHookSpecs(ids) {
178
178
  return specs;
179
179
  }
180
180
 
181
- // Build the platform-appropriate command to run caveman's own cross-agent installer
182
- // (used on non-Claude agents — caveman has no MCP/agnostic-hook path). Pure: returns
183
- // {command, args}; the caller decides whether to spawn it.
184
- export function cavemanInstallerCommand(platform = process.platform) {
181
+ // The caveman installer URL for this platform (.ps1 on Windows, .sh elsewhere).
182
+ export function cavemanInstallerUrl(platform = process.platform) {
185
183
  const { sh, ps1 } = COMPANION_BY_ID.caveman.installer;
184
+ return platform === 'win32' ? ps1 : sh;
185
+ }
186
+
187
+ // Command to run caveman's installer FROM A LOCAL FILE. We download the script to a
188
+ // temp file and run it as a file (not `irm|iex` / `curl|bash`) so the script gets a
189
+ // real $PSCommandPath/$0 — piping to iex leaves that null and the installer aborts
190
+ // ("cannot bind argument to parameter 'Path'"). Pure: returns {command, args}.
191
+ export function cavemanInstallerCommand(platform = process.platform, scriptPath) {
186
192
  if (platform === 'win32') {
187
- return { command: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `irm ${ps1} | iex`] };
193
+ return { command: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath] };
188
194
  }
189
- return { command: 'bash', args: ['-c', `curl -fsSL ${sh} | bash`] };
195
+ return { command: 'bash', args: [scriptPath] };
190
196
  }
191
197
 
192
198
  // Derive the default vault folder name from the project folder: `.<project>-vault`.
@@ -0,0 +1,75 @@
1
+ // Vault color system for `wendkeep init`. Two layers, both keyed to the note
2
+ // taxonomy the hooks already emit:
3
+ // - a CSS snippet that accents an open note by its `cssclasses`
4
+ // (topic-session/decision/bug/learning), enabled via appearance.json
5
+ // - Obsidian graph color groups that tint nodes by their folder
6
+ // Pure + side-effect free; init does the file I/O and non-destructive merges.
7
+
8
+ export const SNIPPET_NAME = 'wendkeep-colors';
9
+
10
+ // Soft, semantic palette — readable in light and dark themes.
11
+ export const NOTE_COLORS = {
12
+ session: { cssClass: 'topic-session', folder: '02-Sessões', hex: '#4f86c6' }, // blue
13
+ decision: { cssClass: 'topic-decision', folder: '04-Decisões', hex: '#9b72cf' }, // purple
14
+ bug: { cssClass: 'topic-bug', folder: '05-Bugs', hex: '#d9645c' }, // red
15
+ learning: { cssClass: 'topic-learning', folder: '06-Aprendizados', hex: '#5ca862' }, // green
16
+ };
17
+
18
+ const ENTRIES = Object.entries(NOTE_COLORS);
19
+
20
+ export function renderColorSnippetCss() {
21
+ const vars = ENTRIES.map(([k, v]) => ` --wk-${k}: ${v.hex};`).join('\n');
22
+ const blocks = ENTRIES.map(([k, v]) => {
23
+ const c = `var(--wk-${k})`;
24
+ return [
25
+ `/* ${k} */`,
26
+ `.${v.cssClass} .inline-title {`,
27
+ ` color: ${c};`,
28
+ ` border-left: 4px solid ${c};`,
29
+ ` padding-left: 0.5em;`,
30
+ `}`,
31
+ `.${v.cssClass} .view-header-title { color: ${c}; }`,
32
+ `.${v.cssClass} .markdown-preview-view h1 { border-bottom: 2px solid ${c}; }`,
33
+ ].join('\n');
34
+ }).join('\n\n');
35
+
36
+ return `/* ${SNIPPET_NAME} — generated by wendkeep. Accents notes by type (cssclasses). */
37
+ :root {
38
+ ${vars}
39
+ }
40
+
41
+ ${blocks}
42
+ `;
43
+ }
44
+
45
+ // Add the snippet to appearance.json's enabled list without touching other prefs.
46
+ export function mergeAppearance(existing, snippetName = SNIPPET_NAME) {
47
+ const a = existing && typeof existing === 'object' ? { ...existing } : {};
48
+ const set = new Set(Array.isArray(a.enabledCssSnippets) ? a.enabledCssSnippets : []);
49
+ set.add(snippetName);
50
+ a.enabledCssSnippets = [...set];
51
+ return a;
52
+ }
53
+
54
+ // Graph color groups: one per note folder, colored from the palette.
55
+ export function graphColorGroups() {
56
+ return ENTRIES.map(([, v]) => ({
57
+ query: `path:"${v.folder}"`,
58
+ color: { a: 1, rgb: parseInt(v.hex.slice(1), 16) },
59
+ }));
60
+ }
61
+
62
+ // Merge color groups into graph.json, deduping by query and keeping other keys.
63
+ export function mergeGraphColorGroups(existing, groups) {
64
+ const g = existing && typeof existing === 'object' ? { ...existing } : {};
65
+ const current = Array.isArray(g.colorGroups) ? [...g.colorGroups] : [];
66
+ const seen = new Set(current.map((x) => x.query));
67
+ for (const group of groups) {
68
+ if (!seen.has(group.query)) {
69
+ current.push(group);
70
+ seen.add(group.query);
71
+ }
72
+ }
73
+ g.colorGroups = current;
74
+ return g;
75
+ }