hypomnema 1.0.0
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/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.ko.md +160 -0
- package/README.md +160 -0
- package/commands/.gitkeep +0 -0
- package/commands/crystallize.md +116 -0
- package/commands/doctor.md +66 -0
- package/commands/feedback.md +67 -0
- package/commands/graph.md +54 -0
- package/commands/ingest.md +85 -0
- package/commands/init.md +101 -0
- package/commands/lint.md +55 -0
- package/commands/query.md +55 -0
- package/commands/resume.md +48 -0
- package/commands/stats.md +39 -0
- package/commands/uninstall.md +52 -0
- package/commands/upgrade.md +63 -0
- package/commands/verify.md +60 -0
- package/docs/.gitkeep +0 -0
- package/docs/ARCHITECTURE.md +183 -0
- package/docs/CONTRIBUTING.md +115 -0
- package/docs/TEST-CASES.md +580 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/hooks.json +109 -0
- package/hooks/hypo-auto-commit.mjs +36 -0
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-compact-guard.mjs +71 -0
- package/hooks/hypo-cwd-change.mjs +91 -0
- package/hooks/hypo-file-watch.mjs +47 -0
- package/hooks/hypo-first-prompt.mjs +59 -0
- package/hooks/hypo-hot-rebuild.mjs +95 -0
- package/hooks/hypo-lookup.mjs +178 -0
- package/hooks/hypo-personal-check.mjs +195 -0
- package/hooks/hypo-session-start.mjs +141 -0
- package/hooks/hypo-shared.mjs +213 -0
- package/package.json +37 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/bump-version.mjs +53 -0
- package/scripts/crystallize.mjs +153 -0
- package/scripts/doctor.mjs +361 -0
- package/scripts/feedback.mjs +130 -0
- package/scripts/graph.mjs +183 -0
- package/scripts/ingest.mjs +130 -0
- package/scripts/init.mjs +515 -0
- package/scripts/lib/frontmatter.mjs +11 -0
- package/scripts/lib/hypo-ignore.mjs +54 -0
- package/scripts/lib/hypo-root.mjs +53 -0
- package/scripts/lint.mjs +210 -0
- package/scripts/query.mjs +124 -0
- package/scripts/resume.mjs +115 -0
- package/scripts/stats.mjs +132 -0
- package/scripts/uninstall.mjs +188 -0
- package/scripts/upgrade.mjs +538 -0
- package/scripts/verify.mjs +172 -0
- package/skills/.gitkeep +0 -0
- package/skills/crystallize/SKILL.md +85 -0
- package/skills/graph/SKILL.md +54 -0
- package/skills/ingest/SKILL.md +83 -0
- package/skills/lint/SKILL.md +55 -0
- package/skills/query/SKILL.md +58 -0
- package/skills/verify/SKILL.md +92 -0
- package/templates/.gitkeep +0 -0
- package/templates/.hypoignore +18 -0
- package/templates/Home.md +34 -0
- package/templates/Overview.md +50 -0
- package/templates/SCHEMA.md +106 -0
- package/templates/hot.md +22 -0
- package/templates/hypo-automation.md +69 -0
- package/templates/hypo-config.md +41 -0
- package/templates/hypo-guide.md +146 -0
- package/templates/hypo-help.md +53 -0
- package/templates/index.md +44 -0
- package/templates/log.md +25 -0
- package/templates/pages/_index.md +61 -0
- package/templates/projects/_template/hot.md +28 -0
- package/templates/projects/_template/index.md +39 -0
- package/templates/projects/_template/prd.md +29 -0
- package/templates/projects/_template/session-state.md +9 -0
- package/templates/session-state.md +12 -0
package/scripts/init.mjs
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hypomnema init script
|
|
4
|
+
*
|
|
5
|
+
* Sets up a new wiki directory, installs hooks, and merges settings.json.
|
|
6
|
+
* Called by /hypo:init after collecting wizard answers.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/init.mjs [options]
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
|
|
13
|
+
* --no-hooks Skip hook installation
|
|
14
|
+
* --codex Also install Codex hooks (~/.codex/hooks/)
|
|
15
|
+
* --git-remote=<url> Git remote URL
|
|
16
|
+
* --no-git-init Skip git initialization
|
|
17
|
+
* --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
18
|
+
* --dry-run Show what would be done without making changes
|
|
19
|
+
* --help, -h Show this help message
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, readdirSync } from 'fs';
|
|
23
|
+
import { join, basename } from 'path';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { execSync, spawnSync } from 'child_process';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
28
|
+
|
|
29
|
+
const HOME = homedir();
|
|
30
|
+
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
31
|
+
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
32
|
+
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
33
|
+
const TEMPLATES = join(PKG_ROOT, 'templates');
|
|
34
|
+
|
|
35
|
+
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const args = {
|
|
39
|
+
hypoDir: resolveHypoRoot(),
|
|
40
|
+
hooks: true,
|
|
41
|
+
codex: false,
|
|
42
|
+
gitRemote: null,
|
|
43
|
+
gitInit: true,
|
|
44
|
+
dryRun: false,
|
|
45
|
+
fromRemote: null,
|
|
46
|
+
shellSetup: true,
|
|
47
|
+
shellConfig: null,
|
|
48
|
+
};
|
|
49
|
+
for (const arg of argv.slice(2)) {
|
|
50
|
+
if (arg === '--help' || arg === '-h') {
|
|
51
|
+
console.log(`Usage: node scripts/init.mjs [options]
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
|
|
55
|
+
--no-hooks Skip hook installation
|
|
56
|
+
--codex Also install Codex hooks (~/.codex/hooks/)
|
|
57
|
+
--git-remote=<url> Git remote URL
|
|
58
|
+
--no-git-init Skip git initialization
|
|
59
|
+
--from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
60
|
+
--no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
|
|
61
|
+
--shell-config=<path> Shell config file path (default: auto-detect)
|
|
62
|
+
--dry-run Show what would be done without making changes
|
|
63
|
+
--help, -h Show this help message`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
67
|
+
else if (arg === '--no-hooks') args.hooks = false;
|
|
68
|
+
else if (arg === '--codex') args.codex = true;
|
|
69
|
+
else if (arg.startsWith('--git-remote=')) args.gitRemote = arg.slice(13);
|
|
70
|
+
else if (arg === '--no-git-init') args.gitInit = false;
|
|
71
|
+
else if (arg.startsWith('--from-remote=')) {
|
|
72
|
+
const url = arg.slice(14).trim();
|
|
73
|
+
if (!url) { console.error('Error: --from-remote requires a non-empty URL'); process.exit(1); }
|
|
74
|
+
args.fromRemote = url;
|
|
75
|
+
}
|
|
76
|
+
else if (arg === '--dry-run') args.dryRun = true;
|
|
77
|
+
else if (arg === '--no-shell') args.shellSetup = false;
|
|
78
|
+
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
79
|
+
}
|
|
80
|
+
return args;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── result tracking ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const results = { created: [], skipped: [], merged: [], errors: [] };
|
|
86
|
+
|
|
87
|
+
function log(action, path) { results[action].push(path); }
|
|
88
|
+
|
|
89
|
+
// ── directory structure ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const HYPO_DIRS = ['pages', 'projects', 'sources', 'journal/daily', 'journal/weekly', 'journal/monthly'];
|
|
92
|
+
|
|
93
|
+
function ensureDir(dir, dryRun) {
|
|
94
|
+
if (existsSync(dir)) return;
|
|
95
|
+
if (!dryRun) mkdirSync(dir, { recursive: true });
|
|
96
|
+
log('created', dir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── template copy ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function copyTemplate(srcName, destPath, dryRun, transform) {
|
|
102
|
+
const src = join(TEMPLATES, srcName);
|
|
103
|
+
if (!existsSync(src)) { log('errors', `template missing: ${srcName}`); return; }
|
|
104
|
+
if (existsSync(destPath)) { log('skipped', destPath); return; }
|
|
105
|
+
if (!dryRun) {
|
|
106
|
+
let content = readFileSync(src, 'utf-8');
|
|
107
|
+
content = content.replace(/YYYY-MM-DD/g, new Date().toISOString().slice(0, 10));
|
|
108
|
+
if (transform) content = transform(content);
|
|
109
|
+
writeFileSync(destPath, content);
|
|
110
|
+
}
|
|
111
|
+
log('created', destPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── hypo-config.md ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function writeHypoConfig(hypoDir, dryRun) {
|
|
117
|
+
const dest = join(hypoDir, 'hypo-config.md');
|
|
118
|
+
if (existsSync(dest)) { log('skipped', dest); return; }
|
|
119
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
120
|
+
const src = join(TEMPLATES, 'hypo-config.md');
|
|
121
|
+
const base = existsSync(src) ? readFileSync(src, 'utf-8') : '';
|
|
122
|
+
const content = base.replace(/YYYY-MM-DD/g, today);
|
|
123
|
+
if (!dryRun) writeFileSync(dest, content);
|
|
124
|
+
log('created', dest);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── .hypoignore ──────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function writeWikiignore(hypoDir, dryRun) {
|
|
130
|
+
const dest = join(hypoDir, '.hypoignore');
|
|
131
|
+
if (existsSync(dest)) { log('skipped', dest); return; }
|
|
132
|
+
const src = join(TEMPLATES, '.hypoignore');
|
|
133
|
+
const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
|
|
134
|
+
if (!dryRun) writeFileSync(dest, content);
|
|
135
|
+
log('created', dest);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── hook installation ────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function loadHookMap() {
|
|
141
|
+
let cfg;
|
|
142
|
+
try {
|
|
143
|
+
cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
|
|
144
|
+
} catch {
|
|
145
|
+
console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
149
|
+
console.error('Error: hooks/hooks.json must be a JSON object');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
|
|
153
|
+
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
function _extractCommandFileName(command) {
|
|
157
|
+
if (typeof command !== 'string') return null;
|
|
158
|
+
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
159
|
+
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
160
|
+
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
161
|
+
return bare ? bare[1] : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _isHookFileName(file) {
|
|
165
|
+
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _isHookGroup(group) {
|
|
169
|
+
return group &&
|
|
170
|
+
typeof group === 'object' &&
|
|
171
|
+
!Array.isArray(group) &&
|
|
172
|
+
Array.isArray(group.hooks) &&
|
|
173
|
+
group.hooks.length > 0 &&
|
|
174
|
+
group.hooks.every(hook =>
|
|
175
|
+
hook &&
|
|
176
|
+
typeof hook === 'object' &&
|
|
177
|
+
!Array.isArray(hook) &&
|
|
178
|
+
hook.type === 'command' &&
|
|
179
|
+
_extractCommandFileName(hook.command)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
184
|
+
function _extractFileNames(groups) {
|
|
185
|
+
return groups.flatMap(group => {
|
|
186
|
+
if (typeof group === 'string') return [group.trim()];
|
|
187
|
+
return group.hooks.map(hook => _extractCommandFileName(hook.command));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const [event, groups] of Object.entries(cfg.hooks)) {
|
|
192
|
+
const valid = Array.isArray(groups) &&
|
|
193
|
+
groups.length > 0 &&
|
|
194
|
+
groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
195
|
+
_extractFileNames(groups).length > 0;
|
|
196
|
+
if (!valid) {
|
|
197
|
+
console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (cfg.shared !== undefined && (!Array.isArray(cfg.shared) || !cfg.shared.every(f => _isHookFileName(f)))) {
|
|
202
|
+
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
return Object.fromEntries(Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function installHooks(targetDir, dryRun) {
|
|
209
|
+
if (!existsSync(HOOKS_SRC)) { log('errors', `hooks source missing: ${HOOKS_SRC}`); return; }
|
|
210
|
+
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
211
|
+
for (const file of readdirSync(HOOKS_SRC)) {
|
|
212
|
+
if (!file.endsWith('.mjs')) continue;
|
|
213
|
+
const dest = join(targetDir, file);
|
|
214
|
+
if (existsSync(dest)) { log('skipped', dest); continue; }
|
|
215
|
+
if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
|
|
216
|
+
log('created', dest);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|
|
221
|
+
let settings = {};
|
|
222
|
+
if (existsSync(settingsPath)) {
|
|
223
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
|
|
224
|
+
log('errors', `settings.json is not valid JSON — fix or back it up before re-running: ${settingsPath}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!settings.hooks) settings.hooks = {};
|
|
229
|
+
|
|
230
|
+
let changed = false;
|
|
231
|
+
for (const [event, files] of Object.entries(hookMap)) {
|
|
232
|
+
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
235
|
+
const already = settings.hooks[event]
|
|
236
|
+
.flatMap(g => g.hooks || [])
|
|
237
|
+
.some(h => h.command === cmd);
|
|
238
|
+
if (already) continue;
|
|
239
|
+
settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
|
|
240
|
+
log('merged', `${event}: ${file}`);
|
|
241
|
+
changed = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (changed && !dryRun) {
|
|
246
|
+
mkdirSync(join(settingsPath, '..'), { recursive: true });
|
|
247
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── pkg git hook ────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const PKG_GIT_HOOK_CONTENT = `#!/bin/sh
|
|
254
|
+
# Auto-sync hooks/ to ~/.claude/hooks/ when hook files change.
|
|
255
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
256
|
+
changed=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | grep '^hooks/')
|
|
257
|
+
if [ -z "$changed" ]; then
|
|
258
|
+
exit 0
|
|
259
|
+
fi
|
|
260
|
+
echo "[post-commit] hooks/ changed — syncing to ~/.claude/hooks/ ..."
|
|
261
|
+
node "$REPO_ROOT/scripts/upgrade.mjs" --apply
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
function writePkgJson(dryRun) {
|
|
265
|
+
const dest = join(HOME, '.claude', 'hypo-pkg.json');
|
|
266
|
+
if (!dryRun) {
|
|
267
|
+
mkdirSync(join(HOME, '.claude'), { recursive: true });
|
|
268
|
+
writeFileSync(dest, JSON.stringify({ pkgRoot: PKG_ROOT }, null, 2) + '\n');
|
|
269
|
+
log('created', dest);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function installPkgGitHook(dryRun) {
|
|
274
|
+
const gitDir = join(PKG_ROOT, '.git');
|
|
275
|
+
if (!existsSync(gitDir)) return;
|
|
276
|
+
const hooksDir = join(gitDir, 'hooks');
|
|
277
|
+
const hookPath = join(hooksDir, 'post-commit');
|
|
278
|
+
if (existsSync(hookPath)) { log('skipped', hookPath); return; }
|
|
279
|
+
if (!dryRun) {
|
|
280
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
281
|
+
writeFileSync(hookPath, PKG_GIT_HOOK_CONTENT, { mode: 0o755 });
|
|
282
|
+
}
|
|
283
|
+
log('created', hookPath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── shell function setup ─────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
289
|
+
const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
290
|
+
|
|
291
|
+
function shellFunctionBlock() {
|
|
292
|
+
return `${SHELL_MARKER_START}
|
|
293
|
+
function claude() {
|
|
294
|
+
echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
|
|
295
|
+
command claude "$@"
|
|
296
|
+
}
|
|
297
|
+
${SHELL_MARKER_END}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function detectShellConfig(customPath) {
|
|
301
|
+
if (customPath) return customPath;
|
|
302
|
+
const shell = process.env.SHELL || '';
|
|
303
|
+
if (shell.includes('zsh')) return join(HOME, '.zshrc');
|
|
304
|
+
if (shell.includes('bash')) return join(HOME, '.bashrc');
|
|
305
|
+
// fallback: prefer .zshrc if it exists, else .bashrc
|
|
306
|
+
const zshrc = join(HOME, '.zshrc');
|
|
307
|
+
return existsSync(zshrc) ? zshrc : join(HOME, '.bashrc');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function installShellFunction(shellConfigPath, dryRun) {
|
|
311
|
+
const block = shellFunctionBlock();
|
|
312
|
+
|
|
313
|
+
if (!existsSync(shellConfigPath)) {
|
|
314
|
+
if (!dryRun) writeFileSync(shellConfigPath, block + '\n');
|
|
315
|
+
log('created', `${shellConfigPath} (shell function)`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const content = readFileSync(shellConfigPath, 'utf-8');
|
|
320
|
+
const startIdx = content.indexOf(SHELL_MARKER_START);
|
|
321
|
+
const endIdx = content.indexOf(SHELL_MARKER_END);
|
|
322
|
+
|
|
323
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
324
|
+
// Block exists — check if already up to date
|
|
325
|
+
const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
|
|
326
|
+
if (existing === block) { log('skipped', `${shellConfigPath} (shell function up to date)`); return; }
|
|
327
|
+
// Replace stale block
|
|
328
|
+
const updated = content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
|
|
329
|
+
if (!dryRun) writeFileSync(shellConfigPath, updated);
|
|
330
|
+
log('merged', `${shellConfigPath} (shell function updated)`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// No block — remove any legacy wiki-session-start function first, then append
|
|
335
|
+
const legacyPattern = /\n?# Wiki session context[^\n]*\nfunction claude\(\) \{[\s\S]+?\n\}\n?/g;
|
|
336
|
+
const cleaned = content.replace(legacyPattern, '\n');
|
|
337
|
+
const appended = cleaned.trimEnd() + '\n\n' + block + '\n';
|
|
338
|
+
if (!dryRun) writeFileSync(shellConfigPath, appended);
|
|
339
|
+
log('created', `${shellConfigPath} (shell function)`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── from-remote clone ────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
function cloneFromRemote(url, hypoDir, dryRun) {
|
|
345
|
+
if (existsSync(hypoDir)) {
|
|
346
|
+
log('errors', `--from-remote: target directory already exists: ${hypoDir}. Remove it or choose a different --hypo-dir.`);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
console.log(`Cloning ${url} → ${hypoDir} ...`);
|
|
350
|
+
if (!dryRun) {
|
|
351
|
+
const r = spawnSync('git', ['clone', url, hypoDir], { stdio: 'inherit' });
|
|
352
|
+
if (r.error || r.status !== 0) {
|
|
353
|
+
log('errors', `git clone failed: ${url}`);
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
if (!existsSync(join(hypoDir, 'hypo-config.md'))) {
|
|
357
|
+
spawnSync('rm', ['-rf', hypoDir]);
|
|
358
|
+
log('errors', `--from-remote: cloned repo is not a Hypomnema wiki (hypo-config.md missing). Removed ${hypoDir}.`);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
log('created', hypoDir);
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── git setup ────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function git(hypoDir, args, opts = {}) {
|
|
369
|
+
return spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', ...opts });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function gitSetup(hypoDir, remote, dryRun) {
|
|
373
|
+
const isGit = existsSync(join(hypoDir, '.git'));
|
|
374
|
+
if (!isGit) {
|
|
375
|
+
if (!dryRun) {
|
|
376
|
+
const r = spawnSync('git', ['init', hypoDir], { stdio: 'ignore' });
|
|
377
|
+
if (r.error || r.status !== 0) {
|
|
378
|
+
log('errors', `git init failed in ${hypoDir}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
log('created', join(hypoDir, '.git'));
|
|
383
|
+
}
|
|
384
|
+
if (remote) {
|
|
385
|
+
const existing = git(hypoDir, ['remote', 'get-url', 'origin']);
|
|
386
|
+
if (existing.status === 0) {
|
|
387
|
+
const url = existing.stdout.trim();
|
|
388
|
+
if (url !== remote) log('skipped', `remote origin already set to ${url}`);
|
|
389
|
+
} else {
|
|
390
|
+
if (!dryRun) git(hypoDir, ['remote', 'add', 'origin', remote], { stdio: 'ignore' });
|
|
391
|
+
log('merged', `git remote origin → ${remote}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── first commit + push ──────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function firstCommit(hypoDir, remote, dryRun) {
|
|
399
|
+
const logR = git(hypoDir, ['log', '--oneline', '-1']);
|
|
400
|
+
if (logR.status === 0 && logR.stdout.trim()) {
|
|
401
|
+
log('skipped', 'first commit (repo already has commits)');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
405
|
+
if (!dryRun) {
|
|
406
|
+
git(hypoDir, ['add', '-A'], { stdio: 'ignore' });
|
|
407
|
+
const commitR = git(hypoDir, ['commit', '-m', `chore: init hypomnema (${today})`]);
|
|
408
|
+
if (commitR.status !== 0) { log('errors', 'first commit failed'); return; }
|
|
409
|
+
if (remote) {
|
|
410
|
+
const actualOrigin = git(hypoDir, ['remote', 'get-url', 'origin']);
|
|
411
|
+
const pushTarget = actualOrigin.status === 0 ? actualOrigin.stdout.trim() : remote;
|
|
412
|
+
const pushR = git(hypoDir, ['push', '-u', 'origin', 'HEAD']);
|
|
413
|
+
if (pushR.status !== 0) log('errors', `git push failed: ${(pushR.stderr || '').trim()}`);
|
|
414
|
+
else log('merged', `pushed to ${pushTarget}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
log('created', `first commit (${today})`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
const args = parseArgs(process.argv);
|
|
423
|
+
|
|
424
|
+
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
425
|
+
const HOOK_MAP = (args.hooks || args.codex) ? loadHookMap() : null;
|
|
426
|
+
|
|
427
|
+
if (args.fromRemote) {
|
|
428
|
+
// ── from-remote path: clone → read config → install hooks ──────────────────
|
|
429
|
+
const cloned = cloneFromRemote(args.fromRemote, args.hypoDir, args.dryRun);
|
|
430
|
+
if (!cloned) {
|
|
431
|
+
console.error(results.errors.join('\n'));
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
// ── normal path: create structure + templates ───────────────────────────────
|
|
436
|
+
// 1. wiki directory structure
|
|
437
|
+
ensureDir(args.hypoDir, args.dryRun);
|
|
438
|
+
for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
|
|
439
|
+
|
|
440
|
+
// 2. template files
|
|
441
|
+
copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
|
|
442
|
+
copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
|
|
443
|
+
copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
|
|
444
|
+
copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
|
|
445
|
+
copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
|
|
446
|
+
copyTemplate('Home.md', join(args.hypoDir, 'Home.md'), args.dryRun);
|
|
447
|
+
copyTemplate('Overview.md', join(args.hypoDir, 'Overview.md'), args.dryRun);
|
|
448
|
+
copyTemplate('hypo-help.md', join(args.hypoDir, 'hypo-help.md'), args.dryRun);
|
|
449
|
+
copyTemplate('hypo-automation.md',join(args.hypoDir, 'hypo-automation.md'),args.dryRun);
|
|
450
|
+
copyTemplate('session-state.md', join(args.hypoDir, 'session-state.md'), args.dryRun);
|
|
451
|
+
copyTemplate(join('pages', '_index.md'), join(args.hypoDir, 'pages', '_index.md'), args.dryRun);
|
|
452
|
+
|
|
453
|
+
// projects/_template structure
|
|
454
|
+
ensureDir(join(args.hypoDir, 'projects', '_template'), args.dryRun);
|
|
455
|
+
ensureDir(join(args.hypoDir, 'projects', '_template', 'decisions'), args.dryRun);
|
|
456
|
+
ensureDir(join(args.hypoDir, 'projects', '_template', 'session-log'), args.dryRun);
|
|
457
|
+
copyTemplate(join('projects', '_template', 'hot.md'), join(args.hypoDir, 'projects', '_template', 'hot.md'), args.dryRun);
|
|
458
|
+
copyTemplate(join('projects', '_template', 'index.md'), join(args.hypoDir, 'projects', '_template', 'index.md'), args.dryRun);
|
|
459
|
+
copyTemplate(join('projects', '_template', 'prd.md'), join(args.hypoDir, 'projects', '_template', 'prd.md'), args.dryRun);
|
|
460
|
+
copyTemplate(join('projects', '_template', 'session-state.md'),join(args.hypoDir, 'projects', '_template', 'session-state.md'),args.dryRun);
|
|
461
|
+
|
|
462
|
+
// 3. hypo-config.md + .hypoignore
|
|
463
|
+
writeHypoConfig(args.hypoDir, args.dryRun);
|
|
464
|
+
writeWikiignore(args.hypoDir, args.dryRun);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 4. hooks
|
|
468
|
+
|
|
469
|
+
if (args.hooks) {
|
|
470
|
+
const claudeHooks = join(HOME, '.claude', 'hooks');
|
|
471
|
+
installHooks(claudeHooks, args.dryRun);
|
|
472
|
+
mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
|
|
473
|
+
writePkgJson(args.dryRun);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 5. shell function (claude wrapper)
|
|
477
|
+
if (args.shellSetup) {
|
|
478
|
+
const shellConfigPath = detectShellConfig(args.shellConfig);
|
|
479
|
+
installShellFunction(shellConfigPath, args.dryRun);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 6. codex hooks (optional)
|
|
483
|
+
if (args.codex) {
|
|
484
|
+
const codexHooks = join(HOME, '.codex', 'hooks');
|
|
485
|
+
installHooks(codexHooks, args.dryRun);
|
|
486
|
+
mergeSettingsJson(join(HOME, '.codex', 'settings.json'), codexHooks, args.dryRun, HOOK_MAP);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 7. git setup (skip when cloned from remote — already has .git + remote)
|
|
490
|
+
if (args.gitInit && !args.fromRemote) {
|
|
491
|
+
gitSetup(args.hypoDir, args.gitRemote, args.dryRun);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 8. pkg repo git hook (auto-sync hooks/ → ~/.claude/hooks/ on commit)
|
|
495
|
+
if (args.hooks) {
|
|
496
|
+
installPkgGitHook(args.dryRun);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 9. first commit + push (skip when cloned from remote — already has commits)
|
|
500
|
+
if (args.gitInit && !args.fromRemote) {
|
|
501
|
+
firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── report ───────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
const lines = [];
|
|
507
|
+
if (results.created.length) lines.push(`✓ Created (${results.created.length}):\n${results.created.map(p => ` ${p}`).join('\n')}`);
|
|
508
|
+
if (results.skipped.length) lines.push(`⊘ Skipped / already exists (${results.skipped.length}):\n${results.skipped.map(p => ` ${p}`).join('\n')}`);
|
|
509
|
+
if (results.merged.length) lines.push(`↪ Merged into settings.json:\n${results.merged.map(p => ` ${p}`).join('\n')}`);
|
|
510
|
+
if (results.errors.length) lines.push(`✗ Errors:\n${results.errors.map(p => ` ${p}`).join('\n')}`);
|
|
511
|
+
|
|
512
|
+
if (args.dryRun) lines.unshift('[DRY RUN — no changes made]');
|
|
513
|
+
|
|
514
|
+
console.log(lines.join('\n\n'));
|
|
515
|
+
if (results.errors.length) process.exit(1);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function parseFrontmatter(content) {
|
|
2
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3
|
+
if (!m) return null;
|
|
4
|
+
const fm = {};
|
|
5
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
6
|
+
const idx = line.indexOf(':');
|
|
7
|
+
if (idx < 0) continue;
|
|
8
|
+
fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/\s*#.*$/, '').replace(/^["']|["']$/g, '');
|
|
9
|
+
}
|
|
10
|
+
return fm;
|
|
11
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, relative, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
export function loadHypoIgnore(hypoDir) {
|
|
5
|
+
const ignorePath = join(hypoDir, '.hypoignore');
|
|
6
|
+
if (!existsSync(ignorePath)) return [];
|
|
7
|
+
return readFileSync(ignorePath, 'utf-8')
|
|
8
|
+
.split('\n')
|
|
9
|
+
.map(l => l.trim())
|
|
10
|
+
.filter(l => l && !l.startsWith('#'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function globToRegex(glob) {
|
|
14
|
+
return new RegExp('^' +
|
|
15
|
+
glob
|
|
16
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
.replace(/\*\*/g, '\x00') // placeholder before single-* replacement
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\?/g, '[^/]')
|
|
20
|
+
.replace(/\x00/g, '.*') // restore ** → .*
|
|
21
|
+
+ '$');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isIgnored(filePath, hypoDir, patterns) {
|
|
25
|
+
const rel = relative(hypoDir, filePath).replace(/\\/g, '/');
|
|
26
|
+
const base = basename(filePath);
|
|
27
|
+
|
|
28
|
+
for (const pattern of patterns) {
|
|
29
|
+
const isDir = pattern.endsWith('/');
|
|
30
|
+
if (isDir) {
|
|
31
|
+
const dir = pattern.slice(0, -1);
|
|
32
|
+
const isAnchored = dir.includes('/');
|
|
33
|
+
if (isAnchored) {
|
|
34
|
+
// e.g. pages/journal/ — anchored to wiki root, match prefix
|
|
35
|
+
const re = globToRegex(dir);
|
|
36
|
+
const parts = rel.split('/');
|
|
37
|
+
for (let i = dir.split('/').length; i <= parts.length; i++) {
|
|
38
|
+
if (re.test(parts.slice(0, i).join('/'))) return true;
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// e.g. node_modules/ — unanchored, match any single component
|
|
42
|
+
const re = globToRegex(dir);
|
|
43
|
+
for (const part of rel.split('/')) {
|
|
44
|
+
if (re.test(part)) return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const hasSlash = pattern.includes('/');
|
|
50
|
+
const target = hasSlash ? rel : base;
|
|
51
|
+
if (globToRegex(pattern).test(target)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-root.mjs — resolve the Hypomnema root directory
|
|
4
|
+
*
|
|
5
|
+
* Resolution order:
|
|
6
|
+
* 1. HYPO_DIR environment variable
|
|
7
|
+
* 2. Scan common locations for hypo-config.md marker
|
|
8
|
+
* 3. Default: ~/hypomnema
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
|
|
15
|
+
const HOME = homedir();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Expand leading ~/ to the user's home directory.
|
|
19
|
+
* @param {string} p
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function expandHome(p) {
|
|
23
|
+
if (p === '~') return HOME;
|
|
24
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return join(HOME, p.slice(2));
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the Hypomnema root directory.
|
|
30
|
+
* Checks HYPO_DIR env → hypo-config.md scan → ~/hypomnema default.
|
|
31
|
+
* @returns {string} absolute path to Hypomnema root
|
|
32
|
+
*/
|
|
33
|
+
export function resolveHypoRoot() {
|
|
34
|
+
if (process.env.HYPO_DIR) {
|
|
35
|
+
return expandHome(process.env.HYPO_DIR);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const candidates = [
|
|
39
|
+
join(HOME, 'hypomnema'),
|
|
40
|
+
join(HOME, 'wiki'),
|
|
41
|
+
join(HOME, 'notes'),
|
|
42
|
+
join(HOME, 'knowledge'),
|
|
43
|
+
join(HOME, 'Documents', 'hypomnema'),
|
|
44
|
+
join(HOME, 'Documents', 'wiki'),
|
|
45
|
+
join(HOME, 'Documents', 'notes'),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
if (existsSync(join(c, 'hypo-config.md'))) return c;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return join(HOME, 'hypomnema');
|
|
53
|
+
}
|