@m16khb/llm-wiki 0.1.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.
@@ -0,0 +1,188 @@
1
+ package wiki
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ func TestVaultInfoSearchReadAndLint(t *testing.T) {
12
+ root := t.TempDir()
13
+ writeTestPage(t, root, "20-wiki/concepts/llm-wiki-pattern.md", `---
14
+ title: LLM Wiki Pattern
15
+ type: concept
16
+ status: active
17
+ created: 2026-05-23
18
+ updated: 2026-05-23
19
+ tags: [llm, wiki]
20
+ domain: agent-memory
21
+ ---
22
+
23
+ # LLM Wiki Pattern
24
+
25
+ A Karpathy-inspired wiki pattern related to [[andrej-karpathy]].
26
+ `)
27
+ writeTestPage(t, root, "20-wiki/entities/andrej-karpathy.md", `---
28
+ title: Andrej Karpathy
29
+ type: entity
30
+ status: active
31
+ created: 2026-05-23
32
+ updated: 2026-05-23
33
+ tags: [llm]
34
+ domain: people
35
+ ---
36
+
37
+ # Andrej Karpathy
38
+ `)
39
+ writeTestPage(t, root, "10-sources/karpathy-llm-wiki-gist.md", `---
40
+ title: Karpathy LLM Wiki Gist
41
+ type: source
42
+ status: active
43
+ created: 2026-05-23
44
+ updated: 2026-05-23
45
+ tags: [source]
46
+ domain: primary-source
47
+ ---
48
+
49
+ source card
50
+ `)
51
+ writeTestPage(t, root, ".obsidian/ignored.md", `no frontmatter and should be ignored`)
52
+
53
+ v := newTestVault(t, root)
54
+ info, err := v.Info()
55
+ if err != nil {
56
+ t.Fatalf("Info() error = %v", err)
57
+ }
58
+ if info.MarkdownFiles != 3 || info.Sources != 1 || info.Concepts != 1 || info.Entities != 1 {
59
+ t.Fatalf("unexpected info: %+v", info)
60
+ }
61
+
62
+ results, err := v.Search(SearchOptions{Query: "karpathy", Limit: 2})
63
+ if err != nil {
64
+ t.Fatalf("Search() error = %v", err)
65
+ }
66
+ if len(results) == 0 || results[0].Score == 0 {
67
+ t.Fatalf("expected search hit, got %+v", results)
68
+ }
69
+
70
+ page, content, err := v.ReadPage("llm-wiki-pattern", 0)
71
+ if err != nil {
72
+ t.Fatalf("ReadPage() error = %v", err)
73
+ }
74
+ if page.Path != "20-wiki/concepts/llm-wiki-pattern.md" || !strings.Contains(content, "[[andrej-karpathy]]") {
75
+ t.Fatalf("unexpected read: page=%+v content=%q", page, content)
76
+ }
77
+
78
+ lint, err := v.Lint()
79
+ if err != nil {
80
+ t.Fatalf("Lint() error = %v", err)
81
+ }
82
+ if !lint.OK || len(lint.MissingFrontmatter) != 0 || len(lint.BrokenWikilinks) != 0 {
83
+ t.Fatalf("expected clean lint, got %+v", lint)
84
+ }
85
+ }
86
+
87
+ func TestLintReportsBrokenLinksAndDuplicateSlugWarning(t *testing.T) {
88
+ root := t.TempDir()
89
+ writeTestPage(t, root, "20-wiki/concepts/dup.md", testFrontmatter("Dup Concept", "concept")+"\n[[missing-page]]\n")
90
+ writeTestPage(t, root, "20-wiki/entities/dup.md", testFrontmatter("Dup Entity", "entity")+"\n")
91
+
92
+ lint, err := newTestVault(t, root).Lint()
93
+ if err != nil {
94
+ t.Fatalf("Lint() error = %v", err)
95
+ }
96
+ if lint.OK {
97
+ t.Fatalf("expected lint failure for broken link, got %+v", lint)
98
+ }
99
+ if len(lint.BrokenWikilinks) != 1 || !strings.Contains(lint.BrokenWikilinks[0], "missing-page") {
100
+ t.Fatalf("expected broken wikilink, got %+v", lint.BrokenWikilinks)
101
+ }
102
+ if len(lint.DuplicateSlugs) != 1 || len(lint.Warnings) != 1 {
103
+ t.Fatalf("expected duplicate slug warning, got %+v", lint)
104
+ }
105
+ }
106
+
107
+ func TestSafeJoinRejectsUnsafePaths(t *testing.T) {
108
+ v := newTestVault(t, t.TempDir())
109
+ for _, rel := range []string{"", "/tmp/file.md", "../escape.md", "20-wiki/../.obsidian/app.json", ".obsidian/app.json"} {
110
+ if _, err := v.SafeJoin(rel); err == nil {
111
+ t.Fatalf("SafeJoin(%q) expected error", rel)
112
+ }
113
+ }
114
+ if abs, err := v.SafeJoin("20-wiki/concepts/page.md"); err != nil || !strings.HasPrefix(abs, v.Root) {
115
+ t.Fatalf("SafeJoin allowed path = %q, %v", abs, err)
116
+ }
117
+ }
118
+
119
+ func TestCaptureWritesAllowedMarkdownAndRejectsProtectedFolders(t *testing.T) {
120
+ v := newTestVault(t, t.TempDir())
121
+ v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
122
+
123
+ result, err := v.Capture(CaptureInput{
124
+ Title: "Captured Note",
125
+ Body: "Captured body.",
126
+ Type: "concept",
127
+ Tags: []string{"agent", "wiki"},
128
+ Folder: "20-wiki/concepts",
129
+ Slug: "captured-note",
130
+ })
131
+ if err != nil {
132
+ t.Fatalf("Capture() error = %v", err)
133
+ }
134
+ if result.Path != "20-wiki/concepts/captured-note.md" || !result.Created || result.Overwrote {
135
+ t.Fatalf("unexpected capture result: %+v", result)
136
+ }
137
+ data, err := os.ReadFile(filepath.Join(v.Root, filepath.FromSlash(result.Path)))
138
+ if err != nil {
139
+ t.Fatalf("read captured file: %v", err)
140
+ }
141
+ content := string(data)
142
+ for _, want := range []string{`title: "Captured Note"`, "created: 2026-05-23", "tags: [agent, wiki]", "## Changelog"} {
143
+ if !strings.Contains(content, want) {
144
+ t.Fatalf("captured content missing %q:\n%s", want, content)
145
+ }
146
+ }
147
+
148
+ if _, err := v.Capture(CaptureInput{Title: "Dup", Body: "body", Folder: "20-wiki/concepts", Slug: "captured-note"}); err == nil {
149
+ t.Fatalf("expected duplicate capture to fail without overwrite")
150
+ }
151
+ for _, folder := range []string{"10-sources", ".obsidian", "_archive", "../20-wiki/concepts"} {
152
+ if _, err := v.Capture(CaptureInput{Title: "Bad", Body: "body", Folder: folder, Slug: "bad"}); err == nil {
153
+ t.Fatalf("Capture folder %q expected error", folder)
154
+ }
155
+ }
156
+ }
157
+
158
+ func newTestVault(t *testing.T, root string) *Vault {
159
+ t.Helper()
160
+ v, err := New(root)
161
+ if err != nil {
162
+ t.Fatalf("New(%q) error = %v", root, err)
163
+ }
164
+ return v
165
+ }
166
+
167
+ func writeTestPage(t *testing.T, root, rel, content string) {
168
+ t.Helper()
169
+ abs := filepath.Join(root, filepath.FromSlash(rel))
170
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
171
+ t.Fatalf("mkdir %s: %v", rel, err)
172
+ }
173
+ if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
174
+ t.Fatalf("write %s: %v", rel, err)
175
+ }
176
+ }
177
+
178
+ func testFrontmatter(title, typ string) string {
179
+ return "---\n" +
180
+ "title: " + title + "\n" +
181
+ "type: " + typ + "\n" +
182
+ "status: active\n" +
183
+ "created: 2026-05-23\n" +
184
+ "updated: 2026-05-23\n" +
185
+ "tags: []\n" +
186
+ "domain: test\n" +
187
+ "---\n"
188
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../lib/runner').run();
@@ -0,0 +1,167 @@
1
+ const childProcess = require('node:child_process');
2
+ const crypto = require('node:crypto');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const SIGNAL_EXIT_CODES = {
8
+ SIGINT: 130,
9
+ SIGTERM: 143,
10
+ };
11
+
12
+ function executableName(platform = process.platform) {
13
+ return platform === 'win32' ? 'llm-wiki.exe' : 'llm-wiki';
14
+ }
15
+
16
+ function isExecutable(file) {
17
+ try {
18
+ const stat = fs.statSync(file);
19
+ return stat.isFile();
20
+ } catch (err) {
21
+ if (err && err.code === 'ENOENT') return false;
22
+ throw err;
23
+ }
24
+ }
25
+
26
+ function findBundledBinary(packageRoot, platform = process.platform) {
27
+ const candidate = path.join(packageRoot, 'bin', executableName(platform));
28
+ return isExecutable(candidate) ? candidate : '';
29
+ }
30
+
31
+ function readPackageVersion(packageRoot) {
32
+ try {
33
+ const data = fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8');
34
+ const pkg = JSON.parse(data);
35
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
36
+ } catch (_err) {
37
+ return '0.0.0';
38
+ }
39
+ }
40
+
41
+ function hashFile(hash, root, file) {
42
+ let stat;
43
+ try {
44
+ stat = fs.statSync(file);
45
+ } catch (err) {
46
+ if (err && err.code === 'ENOENT') return;
47
+ throw err;
48
+ }
49
+ if (stat.isDirectory()) {
50
+ const entries = fs.readdirSync(file).sort();
51
+ for (const entry of entries) {
52
+ hashFile(hash, root, path.join(file, entry));
53
+ }
54
+ return;
55
+ }
56
+ const isBuildInput = file.endsWith('.go') || ['go.mod', 'go.sum', 'package.json'].includes(path.basename(file));
57
+ if (!stat.isFile() || !isBuildInput) {
58
+ return;
59
+ }
60
+ hash.update(path.relative(root, file));
61
+ hash.update('\0');
62
+ hash.update(String(stat.size));
63
+ hash.update('\0');
64
+ hash.update(String(Math.trunc(stat.mtimeMs)));
65
+ hash.update('\0');
66
+ }
67
+
68
+ function sourceFingerprint(packageRoot) {
69
+ const hash = crypto.createHash('sha256');
70
+ for (const entry of ['go.mod', 'go.sum', 'package.json', 'cmd', 'internal']) {
71
+ hashFile(hash, packageRoot, path.join(packageRoot, entry));
72
+ }
73
+ return hash.digest('hex').slice(0, 16);
74
+ }
75
+
76
+ function defaultCacheRoot(env = process.env) {
77
+ if (env.LLM_WIKI_NPM_CACHE_DIR) return env.LLM_WIKI_NPM_CACHE_DIR;
78
+ if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'llm-wiki', 'npm-wrapper');
79
+ const home = os.homedir();
80
+ if (home) return path.join(home, '.cache', 'llm-wiki', 'npm-wrapper');
81
+ return path.join(os.tmpdir(), 'llm-wiki', 'npm-wrapper');
82
+ }
83
+
84
+ function cacheBinaryPath(packageRoot, env = process.env, platform = process.platform, arch = process.arch) {
85
+ const version = readPackageVersion(packageRoot);
86
+ const fingerprint = sourceFingerprint(packageRoot);
87
+ const filename = `llm-wiki-${version}-${platform}-${arch}-${fingerprint}${platform === 'win32' ? '.exe' : ''}`;
88
+ return path.join(defaultCacheRoot(env), filename);
89
+ }
90
+
91
+ function buildBinary(packageRoot, output, env = process.env) {
92
+ fs.mkdirSync(path.dirname(output), { recursive: true });
93
+ const result = childProcess.spawnSync('go', ['build', '-o', output, './cmd/llm-wiki'], {
94
+ cwd: packageRoot,
95
+ env,
96
+ stdio: ['ignore', 'ignore', 'inherit'],
97
+ });
98
+ if (result.error) {
99
+ throw new Error(`Go toolchain is required to build llm-wiki for npx: ${result.error.message}`);
100
+ }
101
+ if (result.status !== 0) {
102
+ throw new Error(`go build failed with exit code ${result.status}`);
103
+ }
104
+ if (!isExecutable(output)) {
105
+ throw new Error(`go build did not create expected binary: ${output}`);
106
+ }
107
+ return output;
108
+ }
109
+
110
+ function ensureBinary(options = {}) {
111
+ const env = options.env || process.env;
112
+ const packageRoot = options.packageRoot || env.LLM_WIKI_NPM_PACKAGE_ROOT || path.resolve(__dirname, '..', '..');
113
+ const bundled = findBundledBinary(packageRoot);
114
+ if (bundled) return bundled;
115
+
116
+ const cached = cacheBinaryPath(packageRoot, env);
117
+ if (isExecutable(cached) && env.LLM_WIKI_NPM_REBUILD !== '1') return cached;
118
+ return buildBinary(packageRoot, cached, env);
119
+ }
120
+
121
+ function exitCodeForSignal(signal) {
122
+ return SIGNAL_EXIT_CODES[signal] || 1;
123
+ }
124
+
125
+ function run(argv = process.argv.slice(2), options = {}) {
126
+ const env = options.env || process.env;
127
+ let binary;
128
+ try {
129
+ binary = ensureBinary({ env, packageRoot: options.packageRoot });
130
+ } catch (err) {
131
+ console.error(`llm-wiki npm wrapper: ${err.message}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ const child = childProcess.spawn(binary, argv, {
136
+ env,
137
+ stdio: 'inherit',
138
+ });
139
+
140
+ let childExited = false;
141
+ child.on('error', (err) => {
142
+ console.error(`llm-wiki npm wrapper: failed to run ${binary}: ${err.message}`);
143
+ process.exit(1);
144
+ });
145
+ child.on('exit', (code, signal) => {
146
+ childExited = true;
147
+ if (signal) {
148
+ process.exit(exitCodeForSignal(signal));
149
+ }
150
+ process.exit(code === null ? 1 : code);
151
+ });
152
+
153
+ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
154
+ process.on(signal, () => {
155
+ if (!childExited) child.kill(signal);
156
+ });
157
+ }
158
+ }
159
+
160
+ module.exports = {
161
+ cacheBinaryPath,
162
+ ensureBinary,
163
+ executableName,
164
+ findBundledBinary,
165
+ run,
166
+ sourceFingerprint,
167
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@m16khb/llm-wiki",
3
+ "version": "0.1.0",
4
+ "description": "npx wrapper for the llm-wiki Go MCP server and CLI",
5
+ "bin": {
6
+ "llm-wiki": "npm/bin/llm-wiki.js"
7
+ },
8
+ "files": [
9
+ "cmd/",
10
+ "internal/",
11
+ "npm/bin/",
12
+ "npm/lib/",
13
+ "go.mod",
14
+ "go.sum",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test:npm": "node --test npm/test/*.test.js",
19
+ "build:go": "go build -o bin/llm-wiki ./cmd/llm-wiki"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "UNLICENSED",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }