wendkeep 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {
@@ -0,0 +1,111 @@
1
+ // Interactive companion checkbox selector for `wendkeep init`.
2
+ // The state machine (initial/reduce/render/mapKey) is pure and tested; the raw-TTY
3
+ // event loop is a thin shell over it, with a text fallback when raw mode is absent.
4
+ import readline from 'node:readline';
5
+
6
+ const HINT = 'Espaço marca/desmarca · ↑/↓ move · a=todos · n=nenhum · Enter confirma';
7
+
8
+ export function initialCompanionMenu(companions) {
9
+ return {
10
+ items: companions.map((c) => ({ id: c.id, label: c.label, checked: !!c.default })),
11
+ cursor: 0,
12
+ };
13
+ }
14
+
15
+ export function reduceCompanionMenu(state, action) {
16
+ const n = state.items.length;
17
+ switch (action) {
18
+ case 'up':
19
+ return { ...state, cursor: (state.cursor - 1 + n) % n };
20
+ case 'down':
21
+ return { ...state, cursor: (state.cursor + 1) % n };
22
+ case 'space':
23
+ return {
24
+ ...state,
25
+ items: state.items.map((it, i) => (i === state.cursor ? { ...it, checked: !it.checked } : it)),
26
+ };
27
+ case 'all':
28
+ return { ...state, items: state.items.map((it) => ({ ...it, checked: true })) };
29
+ case 'none':
30
+ return { ...state, items: state.items.map((it) => ({ ...it, checked: false })) };
31
+ case 'enter':
32
+ return { ...state, done: true, selected: state.items.filter((it) => it.checked).map((it) => it.id) };
33
+ default:
34
+ return state;
35
+ }
36
+ }
37
+
38
+ export function renderCompanionMenu(state) {
39
+ const lines = [`Companions — ${HINT}:`];
40
+ state.items.forEach((it, i) => {
41
+ const cursor = i === state.cursor ? '>' : ' ';
42
+ const box = it.checked ? '[x]' : '[ ]';
43
+ lines.push(`${cursor} ${box} ${it.label}`);
44
+ });
45
+ return lines.join('\n');
46
+ }
47
+
48
+ export function mapKey(str, key = {}) {
49
+ const name = key.name;
50
+ if (name === 'up' || name === 'k') return 'up';
51
+ if (name === 'down' || name === 'j') return 'down';
52
+ if (name === 'space' || str === ' ') return 'space';
53
+ if (name === 'return' || name === 'enter') return 'enter';
54
+ if (str === 'a') return 'all';
55
+ if (str === 'n') return 'none';
56
+ return null;
57
+ }
58
+
59
+ // True when stdin/stdout can drive an interactive raw-mode menu.
60
+ export function canInteractiveSelect(input = process.stdin, output = process.stdout) {
61
+ return !!(input && output && input.isTTY && output.isTTY && typeof input.setRawMode === 'function');
62
+ }
63
+
64
+ // Run the raw-TTY checkbox menu; resolves with the selected ids. Caller must check
65
+ // canInteractiveSelect() first (otherwise use the text fallback).
66
+ export function selectCompanionsInteractive(companions, { input = process.stdin, output = process.stdout } = {}) {
67
+ return new Promise((resolve) => {
68
+ let state = initialCompanionMenu(companions);
69
+ let drawnLines = 0;
70
+
71
+ readline.emitKeypressEvents(input);
72
+ input.setRawMode(true);
73
+ input.resume();
74
+
75
+ const draw = () => {
76
+ if (drawnLines) {
77
+ readline.moveCursor(output, 0, -drawnLines);
78
+ readline.clearScreenDown(output);
79
+ }
80
+ const text = renderCompanionMenu(state);
81
+ output.write(`${text}\n`);
82
+ drawnLines = text.split('\n').length;
83
+ };
84
+
85
+ const cleanup = () => {
86
+ input.removeListener('keypress', onKey);
87
+ if (typeof input.setRawMode === 'function') input.setRawMode(false);
88
+ input.pause();
89
+ };
90
+
91
+ const onKey = (str, key = {}) => {
92
+ if (key.ctrl && key.name === 'c') {
93
+ cleanup();
94
+ output.write('\n');
95
+ process.exit(130);
96
+ }
97
+ const action = mapKey(str, key);
98
+ if (!action) return;
99
+ state = reduceCompanionMenu(state, action);
100
+ if (state.done) {
101
+ cleanup();
102
+ resolve(state.selected);
103
+ return;
104
+ }
105
+ draw();
106
+ };
107
+
108
+ input.on('keypress', onKey);
109
+ draw();
110
+ });
111
+ }
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,8 +20,10 @@ 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';
26
+ import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
24
27
 
25
28
  function parseArgs(argv) {
26
29
  const args = { mcp: true, yes: false, force: false };
@@ -117,6 +120,32 @@ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [
117
120
  return m;
118
121
  }
119
122
 
123
+ // Run caveman's cross-agent installer (non-Claude skill coverage). Downloads the
124
+ // script to a temp file and runs it as a FILE — piping to iex/bash leaves the
125
+ // script's self-path null and the installer aborts. Best-effort, fail-soft: the
126
+ // Claude Code plugin entry is already wired regardless.
127
+ async function runCavemanInstaller(log) {
128
+ const url = cavemanInstallerUrl();
129
+ const ext = process.platform === 'win32' ? 'ps1' : 'sh';
130
+ const tmp = join(tmpdir(), `wendkeep-caveman-install-${process.pid}.${ext}`);
131
+ log(`\n caveman: fetching cross-agent installer (best-effort): ${url}`);
132
+ try {
133
+ const res = await fetch(url);
134
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
135
+ writeFileSync(tmp, await res.text(), 'utf8');
136
+ const cmd = cavemanInstallerCommand(process.platform, tmp);
137
+ log(` caveman: running ${cmd.command} ${cmd.args.join(' ')}`);
138
+ const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
139
+ if (r.status !== 0) {
140
+ log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
141
+ }
142
+ } catch (e) {
143
+ log(` [!] caveman installer skipped (${e.message}) — Claude Code plugin entry still wired.`);
144
+ } finally {
145
+ try { rmSync(tmp, { force: true }); } catch { /* temp cleanup best-effort */ }
146
+ }
147
+ }
148
+
120
149
  // --- main -------------------------------------------------------------------
121
150
 
122
151
  export async function runInit(argv) {
@@ -129,7 +158,7 @@ export async function runInit(argv) {
129
158
  const fallback = join(projectPath, deriveVaultDirName(projectPath));
130
159
  if (process.stdin.isTTY && !args.yes) {
131
160
  const rl = createInterface({ input: process.stdin, output: process.stdout });
132
- const ans = (await rl.question(`Obsidian vault path [${fallback}]: `)).trim();
161
+ const ans = (await rl.question(`Obsidian vault path (Enter aceita o padrão, ou digite outro caminho)\n [${fallback}]\n> `)).trim();
133
162
  rl.close();
134
163
  vaultPath = ans || fallback;
135
164
  } else {
@@ -147,13 +176,21 @@ export async function runInit(argv) {
147
176
  } else if (args.companions !== undefined) {
148
177
  companions = resolveCompanions({ companionsFlag: args.companions });
149
178
  } else if (process.stdin.isTTY && !args.yes) {
150
- log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
151
- for (const c of COMPANIONS) log(` ${c.default ? '[x]' : '[ ]'} ${c.label}`);
152
- const def = resolveCompanions({}).join(',');
153
- const rl = createInterface({ input: process.stdin, output: process.stdout });
154
- const ans = (await rl.question(`Instalar quais? (vírgula) [${def}], "none" p/ nenhum: `)).trim();
155
- rl.close();
156
- companions = ans.toLowerCase() === 'none' ? [] : resolveCompanions({ companionsFlag: ans || def });
179
+ if (canInteractiveSelect()) {
180
+ log(''); // the checkbox menu renders its own header
181
+ companions = await selectCompanionsInteractive(COMPANIONS);
182
+ } else {
183
+ // Text fallback (no raw-mode TTY): list + comma input with clear instructions.
184
+ log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
185
+ for (const c of COMPANIONS) log(` ${c.default ? '[x]' : '[ ]'} ${c.label}`);
186
+ const def = resolveCompanions({}).join(',');
187
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
188
+ const ans = (await rl.question(
189
+ `Digite os ids separados por vírgula (Enter aceita [${def}], "none" p/ nenhum): `,
190
+ )).trim();
191
+ rl.close();
192
+ companions = ans.toLowerCase() === 'none' ? [] : resolveCompanions({ companionsFlag: ans || def });
193
+ }
157
194
  } else {
158
195
  companions = resolveCompanions({});
159
196
  }
@@ -223,18 +260,7 @@ export async function runInit(argv) {
223
260
  // caveman has no MCP / agnostic-hook path: on non-Claude agents its skills come
224
261
  // from its own cross-agent installer. Best-effort, fail-soft — the Claude Code
225
262
  // plugin entry is already wired in settings.json regardless.
226
- if (companions.includes('caveman')) {
227
- const cmd = cavemanInstallerCommand();
228
- log(`\n caveman: running cross-agent installer (best-effort): ${cmd.command} ${cmd.args.join(' ')}`);
229
- try {
230
- const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
231
- if (r.status !== 0) {
232
- log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
233
- }
234
- } catch (e) {
235
- log(` [!] caveman installer could not run (${e.code || e.message}) — Claude Code plugin entry still wired.`);
236
- }
237
- }
263
+ if (companions.includes('caveman')) await runCavemanInstaller(log);
238
264
 
239
265
  log('\nNext steps:');
240
266
  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`.