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 +1 -1
- package/src/companion-select.mjs +111 -0
- package/src/init.mjs +47 -21
- package/src/taxonomy.mjs +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.2.
|
|
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}]
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
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', '-
|
|
193
|
+
return { command: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath] };
|
|
188
194
|
}
|
|
189
|
-
return { command: 'bash', args: [
|
|
195
|
+
return { command: 'bash', args: [scriptPath] };
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
// Derive the default vault folder name from the project folder: `.<project>-vault`.
|