obsidian-wikilinks 0.2.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/LICENSE +21 -0
- package/README.md +207 -0
- package/hooks/hooks.json +14 -0
- package/hooks/wikilink-resolver.py +206 -0
- package/package.json +47 -0
- package/plugin/obsidian-wikilinks.js +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sven Depickere
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# obsidian-wikilinks
|
|
2
|
+
|
|
3
|
+
Resolve `[[wikilinks]]` typed in a Codex, Claude Code, or OpenCode prompt to
|
|
4
|
+
absolute paths in your Obsidian vault, and inject them as context so the agent
|
|
5
|
+
reads the right notes.
|
|
6
|
+
|
|
7
|
+
This is a **coding-agent** plugin (it teaches Codex / Claude Code / OpenCode
|
|
8
|
+
about `[[ ]]`), not an Obsidian-app plugin.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
The prompt is scanned for `[[...]]`, each target is fuzzy-matched against note
|
|
13
|
+
and folder names in your vault, and the resolved absolute paths are added to the
|
|
14
|
+
prompt as extra context. The agent can then read the referenced notes when they
|
|
15
|
+
are relevant to your request.
|
|
16
|
+
|
|
17
|
+
All hosts share one resolver, `hooks/wikilink-resolver.py`:
|
|
18
|
+
|
|
19
|
+
- Codex and Claude Code run it as a `UserPromptSubmit` hook.
|
|
20
|
+
- OpenCode runs it from `plugin/obsidian-wikilinks.js` on the `chat.message`
|
|
21
|
+
hook, which appends the resolution as a synthetic text part.
|
|
22
|
+
|
|
23
|
+
## Install in Codex
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
codex plugin marketplace add DepickereSven/obsidian-wikilinks
|
|
27
|
+
codex plugin add obsidian-wikilinks@depickeresven-obsidian-wikilinks
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Start a new thread, run `/hooks`, and review and trust the plugin's
|
|
31
|
+
`UserPromptSubmit` hook when Codex asks. Installed command hooks do not run until
|
|
32
|
+
they have been trusted.
|
|
33
|
+
|
|
34
|
+
## Install in Claude Code
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude plugin marketplace add DepickereSven/obsidian-wikilinks
|
|
38
|
+
claude plugin install obsidian-wikilinks@depickeresven-obsidian-wikilinks
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Install in OpenCode
|
|
42
|
+
|
|
43
|
+
### From npm (recommended)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
opencode plugin obsidian-wikilinks
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That installs the package and adds it to your config. Or add it by hand:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"$schema": "https://opencode.ai/config.json",
|
|
54
|
+
"plugin": ["obsidian-wikilinks"]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use `~/.config/opencode/opencode.json` for every project, or `opencode.json` in
|
|
59
|
+
a repo for that project only.
|
|
60
|
+
|
|
61
|
+
> The package is not on npm yet. Until it is published, use one of the local
|
|
62
|
+
> options below, or publish it yourself from a checkout with `npm publish`.
|
|
63
|
+
|
|
64
|
+
### From a local checkout
|
|
65
|
+
|
|
66
|
+
OpenCode loads any `.js` / `.ts` file in a plugin directory, and follows
|
|
67
|
+
symlinks. Clone the repo once, then link the plugin:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/DepickereSven/obsidian-wikilinks.git ~/.config/opencode/obsidian-wikilinks
|
|
71
|
+
mkdir -p ~/.config/opencode/plugin
|
|
72
|
+
ln -s ~/.config/opencode/obsidian-wikilinks/plugin/obsidian-wikilinks.js ~/.config/opencode/plugin/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `.opencode/plugin/` instead of `~/.config/opencode/plugin/` to enable it for
|
|
76
|
+
a single project only.
|
|
77
|
+
|
|
78
|
+
Alternatively, reference the checkout from your config instead of symlinking —
|
|
79
|
+
`plugin` entries accept `file://` URLs and paths relative to the config file:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"$schema": "https://opencode.ai/config.json",
|
|
84
|
+
"plugin": ["file:///Users/you/src/obsidian-wikilinks/plugin/obsidian-wikilinks.js"]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The plugin finds `hooks/wikilink-resolver.py` next to itself, in the checkout or
|
|
89
|
+
in `node_modules`. If you copy the `.js` file somewhere on its own, point it at
|
|
90
|
+
the resolver with
|
|
91
|
+
`OBSIDIAN_WIKILINKS_RESOLVER=/path/to/hooks/wikilink-resolver.py`.
|
|
92
|
+
|
|
93
|
+
In the common case, no configuration is needed on any host. The plugin reads
|
|
94
|
+
Obsidian's own vault registry and uses your active vault automatically.
|
|
95
|
+
|
|
96
|
+
## Examples
|
|
97
|
+
|
|
98
|
+
If your vault contains `Projects/Website Redesign.md`, this prompt:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
Summarize [[Website Redesign]] and list the next actions.
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
injects the note's absolute path so the agent can read and summarize it.
|
|
105
|
+
|
|
106
|
+
You can reference several notes, headings, and aliases in one prompt:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
Compare [[Research/AI Agents#Open questions]] with
|
|
110
|
+
[[Projects/Website Redesign|the project brief]].
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The heading and alias are ignored during path lookup. Both note paths are added
|
|
114
|
+
to the prompt context. Folder wikilinks work too:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
Review the notes in [[Meetings]] and prepare a weekly summary.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
When a name is ambiguous, the plugin provides up to three candidate paths. If
|
|
121
|
+
there is no match, it says so instead of guessing.
|
|
122
|
+
|
|
123
|
+
## Vault selection
|
|
124
|
+
|
|
125
|
+
Vault path resolution order:
|
|
126
|
+
|
|
127
|
+
1. The current host's explicit override:
|
|
128
|
+
- Codex: `~/.codex/obsidian-wikilinks.json`
|
|
129
|
+
- Claude Code: `~/.claude/obsidian-wikilinks.json`
|
|
130
|
+
- OpenCode: `~/.config/opencode/obsidian-wikilinks.json`
|
|
131
|
+
(or `$OPENCODE_CONFIG_DIR` / `$XDG_CONFIG_HOME` when set)
|
|
132
|
+
2. The other hosts' config files, as a compatibility fallback
|
|
133
|
+
3. `$OBSIDIAN_VAULT` environment variable
|
|
134
|
+
4. Obsidian's vault registry — auto-detected (prefers the open vault, else most
|
|
135
|
+
recently opened). Cross-platform (macOS / Windows / Linux).
|
|
136
|
+
5. `~/Documents/Obsidian` (default fallback)
|
|
137
|
+
|
|
138
|
+
You only need an explicit override if you have **multiple vaults** and want to
|
|
139
|
+
pin a specific one. Create the config file for your host with:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{ "vault": "/Users/you/Documents/Obsidian" }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The plugin itself is identical on every device. Update Claude Code with
|
|
146
|
+
`claude plugin update obsidian-wikilinks`. Re-run the Codex `plugin add` command
|
|
147
|
+
to install an updated version there. For OpenCode, re-run `opencode plugin
|
|
148
|
+
obsidian-wikilinks`, or `git pull` in the checkout.
|
|
149
|
+
|
|
150
|
+
## Environment variables
|
|
151
|
+
|
|
152
|
+
| Variable | Purpose |
|
|
153
|
+
|---------------------------------|--------------------------------------------------------------|
|
|
154
|
+
| `OBSIDIAN_VAULT` | Vault path, used when no host config file sets one |
|
|
155
|
+
| `OBSIDIAN_WIKILINKS_RESOLVER` | Path to `wikilink-resolver.py` (OpenCode only) |
|
|
156
|
+
| `OBSIDIAN_WIKILINKS_PYTHON` | Python interpreter to use (default `python3`, OpenCode only) |
|
|
157
|
+
| `OBSIDIAN_WIKILINKS_TIMEOUT_MS` | Resolver timeout in ms (default `10000`, OpenCode only) |
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm test # smoke tests on Node
|
|
163
|
+
npm run test:bun # same tests on Bun, the runtime OpenCode uses
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
No dependencies to install: the plugin uses Node/Bun built-ins and the resolver
|
|
167
|
+
is standard-library Python.
|
|
168
|
+
|
|
169
|
+
## Releasing
|
|
170
|
+
|
|
171
|
+
CI runs the test suite on Node 20/22/24 and on Bun for every push and pull
|
|
172
|
+
request, and checks that the packed tarball ships both `plugin/` and `hooks/`.
|
|
173
|
+
|
|
174
|
+
To cut a release:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm version patch # or minor / major
|
|
178
|
+
git push --follow-tags
|
|
179
|
+
gh release create "v$(node -p 'require("./package.json").version')" --generate-notes
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`npm version` also rewrites the Claude Code and Codex manifests through
|
|
183
|
+
`scripts/sync-versions.mjs`, so all three stay on one version. Publishing the
|
|
184
|
+
GitHub release triggers `.github/workflows/publish.yml`, which re-runs the
|
|
185
|
+
tests, verifies the tag matches `package.json`, refuses to overwrite a version
|
|
186
|
+
already on npm, and publishes with provenance.
|
|
187
|
+
|
|
188
|
+
The workflow authenticates through **npm trusted publishing** (OIDC), so no
|
|
189
|
+
token is stored in the repository. One-time setup: on npmjs.com, open the
|
|
190
|
+
package's *Settings -> Trusted publishers*, and add this repository with
|
|
191
|
+
workflow `publish.yml`.
|
|
192
|
+
|
|
193
|
+
Trusted-publisher settings live on the package, so they can only be configured
|
|
194
|
+
once the package exists. Do the **first** publish by hand from a checkout with
|
|
195
|
+
`npm publish --access public`, then wire up the trusted publisher and let the
|
|
196
|
+
workflow handle every release after that.
|
|
197
|
+
|
|
198
|
+
To use a granular access token instead, save it as the `NPM_TOKEN` secret and
|
|
199
|
+
uncomment the `NODE_AUTH_TOKEN` block in the workflow.
|
|
200
|
+
|
|
201
|
+
Use the workflow's manual trigger (`workflow_dispatch`) with *dry run* enabled
|
|
202
|
+
to rehearse a publish without releasing anything.
|
|
203
|
+
|
|
204
|
+
## Requirements
|
|
205
|
+
|
|
206
|
+
- `python3` on PATH (standard-library only; no pip installs).
|
|
207
|
+
- OpenCode only: no extra dependencies — the plugin uses Node/Bun built-ins.
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prompt hook: resolve [[wikilinks]] in the prompt to Obsidian vault files.
|
|
3
|
+
|
|
4
|
+
Reads the hook JSON on stdin, scans the prompt for [[...]] references,
|
|
5
|
+
fuzzy-matches them against note names in the vault, and emits the resolved
|
|
6
|
+
absolute paths as additionalContext so the agent can read the right files.
|
|
7
|
+
|
|
8
|
+
Hosts: Claude Code and Codex run this as a UserPromptSubmit hook. OpenCode runs
|
|
9
|
+
it through plugin/obsidian-wikilinks.js, which feeds the same stdin payload and
|
|
10
|
+
reads the same stdout JSON.
|
|
11
|
+
|
|
12
|
+
Vault path resolution order:
|
|
13
|
+
1. The current host's config file (~/.claude, ~/.codex or ~/.config/opencode)
|
|
14
|
+
2. The other hosts' config files (compatibility fallback)
|
|
15
|
+
3. $OBSIDIAN_VAULT environment variable
|
|
16
|
+
4. Obsidian's own vault registry (auto-detected, cross-platform)
|
|
17
|
+
5. ~/Documents/Obsidian (default)
|
|
18
|
+
"""
|
|
19
|
+
import difflib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
CONFIG_NAME = "obsidian-wikilinks.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def opencode_config_dir():
|
|
29
|
+
explicit = os.environ.get("OPENCODE_CONFIG_DIR")
|
|
30
|
+
if explicit:
|
|
31
|
+
return os.path.expanduser(explicit)
|
|
32
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
33
|
+
return os.path.join(xdg, "opencode")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CONFIG_FILES = {
|
|
37
|
+
"claude": os.path.expanduser("~/.claude/" + CONFIG_NAME),
|
|
38
|
+
"codex": os.path.expanduser("~/.codex/" + CONFIG_NAME),
|
|
39
|
+
"opencode": os.path.join(opencode_config_dir(), CONFIG_NAME),
|
|
40
|
+
}
|
|
41
|
+
SKIP_DIRS = {".obsidian", ".trash", ".git", ".vault-meta"}
|
|
42
|
+
WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def obsidian_registry_paths():
|
|
46
|
+
"""Candidate locations of Obsidian's obsidian.json across platforms."""
|
|
47
|
+
home = os.path.expanduser("~")
|
|
48
|
+
candidates = [
|
|
49
|
+
os.path.join(home, "Library", "Application Support", "obsidian", "obsidian.json"), # macOS
|
|
50
|
+
os.path.join(os.environ.get("APPDATA", ""), "obsidian", "obsidian.json"), # Windows
|
|
51
|
+
os.path.join(home, ".config", "obsidian", "obsidian.json"), # Linux
|
|
52
|
+
]
|
|
53
|
+
return [p for p in candidates if p and os.path.isfile(p)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def autodetect_vault():
|
|
57
|
+
"""Read Obsidian's vault registry; prefer the open vault, else most recent."""
|
|
58
|
+
for path in obsidian_registry_paths():
|
|
59
|
+
try:
|
|
60
|
+
with open(path) as f:
|
|
61
|
+
vaults = json.load(f).get("vaults", {})
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
continue
|
|
64
|
+
valid = [v for v in vaults.values() if os.path.isdir(v.get("path", ""))]
|
|
65
|
+
if not valid:
|
|
66
|
+
continue
|
|
67
|
+
valid.sort(key=lambda v: (bool(v.get("open")), v.get("ts", 0)), reverse=True)
|
|
68
|
+
return valid[0]["path"]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_host():
|
|
73
|
+
"""Which agent invoked us; decides whose config file wins."""
|
|
74
|
+
host = os.environ.get("OBSIDIAN_WIKILINKS_HOST", "").strip().lower()
|
|
75
|
+
if host in CONFIG_FILES:
|
|
76
|
+
return host
|
|
77
|
+
if os.environ.get("OPENCODE") or os.environ.get("OPENCODE_CONFIG_DIR"):
|
|
78
|
+
return "opencode"
|
|
79
|
+
if os.environ.get("PLUGIN_ROOT"):
|
|
80
|
+
return "codex"
|
|
81
|
+
return "claude"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def config_file_order():
|
|
85
|
+
"""Current host's config first, the other hosts' files as fallbacks."""
|
|
86
|
+
host = detect_host()
|
|
87
|
+
return [CONFIG_FILES[host]] + [v for k, v in CONFIG_FILES.items() if k != host]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_vault():
|
|
91
|
+
for config_file in config_file_order():
|
|
92
|
+
if not os.path.isfile(config_file):
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
with open(config_file) as f:
|
|
96
|
+
cfg = json.load(f)
|
|
97
|
+
vault = cfg.get("vault", "")
|
|
98
|
+
if vault:
|
|
99
|
+
return os.path.expanduser(vault)
|
|
100
|
+
except (json.JSONDecodeError, OSError):
|
|
101
|
+
pass
|
|
102
|
+
env = os.environ.get("OBSIDIAN_VAULT", "")
|
|
103
|
+
if env:
|
|
104
|
+
return os.path.expanduser(env)
|
|
105
|
+
detected = autodetect_vault()
|
|
106
|
+
if detected:
|
|
107
|
+
return detected
|
|
108
|
+
return os.path.expanduser("~/Documents/Obsidian")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_index(vault):
|
|
112
|
+
index = [] # (stem, relpath, abspath)
|
|
113
|
+
for root, dirs, files in os.walk(vault):
|
|
114
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
115
|
+
for d in dirs:
|
|
116
|
+
abspath = os.path.join(root, d)
|
|
117
|
+
relpath = os.path.relpath(abspath, vault)
|
|
118
|
+
index.append((d, relpath, abspath + os.sep))
|
|
119
|
+
for f in files:
|
|
120
|
+
if f.startswith("."):
|
|
121
|
+
continue
|
|
122
|
+
abspath = os.path.join(root, f)
|
|
123
|
+
relpath = os.path.relpath(abspath, vault)
|
|
124
|
+
stem = os.path.splitext(f)[0]
|
|
125
|
+
index.append((stem, relpath, abspath))
|
|
126
|
+
return index
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def normalize(s):
|
|
130
|
+
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve(target, index):
|
|
134
|
+
t = target.strip()
|
|
135
|
+
t_lower = t.lower()
|
|
136
|
+
t_md = t_lower if t_lower.endswith(".md") else t_lower + ".md"
|
|
137
|
+
t_norm = normalize(t)
|
|
138
|
+
|
|
139
|
+
# 1. exact stem match (case-insensitive)
|
|
140
|
+
hits = [e for e in index if e[0].lower() == t_lower]
|
|
141
|
+
# 2. exact relative path match (with or without .md)
|
|
142
|
+
if not hits:
|
|
143
|
+
hits = [e for e in index if e[1].lower() in (t_lower, t_md)]
|
|
144
|
+
# 3. substring match on stem
|
|
145
|
+
if not hits:
|
|
146
|
+
hits = [e for e in index if t_lower in e[0].lower()]
|
|
147
|
+
# 4. normalized containment ("Backend search" ~ "Back-end search vs ...")
|
|
148
|
+
if not hits and t_norm:
|
|
149
|
+
hits = [e for e in index if t_norm in normalize(e[0])]
|
|
150
|
+
# 5. fuzzy match: full stems, then stem prefixes of target length
|
|
151
|
+
if not hits:
|
|
152
|
+
stems = {e[0]: e for e in index}
|
|
153
|
+
close = difflib.get_close_matches(t, stems.keys(), n=3, cutoff=0.6)
|
|
154
|
+
if not close:
|
|
155
|
+
prefixes = {}
|
|
156
|
+
for stem in stems:
|
|
157
|
+
prefixes.setdefault(stem[: len(t)], stem)
|
|
158
|
+
close = [prefixes[p] for p in
|
|
159
|
+
difflib.get_close_matches(t, prefixes.keys(), n=3, cutoff=0.6)]
|
|
160
|
+
hits = [stems[c] for c in close]
|
|
161
|
+
return hits[:3]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main():
|
|
165
|
+
try:
|
|
166
|
+
payload = json.load(sys.stdin)
|
|
167
|
+
except (json.JSONDecodeError, ValueError):
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
|
|
170
|
+
prompt = payload.get("prompt", "")
|
|
171
|
+
targets = WIKILINK.findall(prompt)
|
|
172
|
+
if not targets:
|
|
173
|
+
sys.exit(0)
|
|
174
|
+
|
|
175
|
+
vault = get_vault()
|
|
176
|
+
if not os.path.isdir(vault):
|
|
177
|
+
sys.exit(0)
|
|
178
|
+
|
|
179
|
+
index = build_index(vault)
|
|
180
|
+
lines = []
|
|
181
|
+
for target in dict.fromkeys(targets): # dedupe, keep order
|
|
182
|
+
hits = resolve(target, index)
|
|
183
|
+
if not hits:
|
|
184
|
+
lines.append(f"[[{target}]] -> no match found in vault {vault}")
|
|
185
|
+
elif len(hits) == 1:
|
|
186
|
+
lines.append(f"[[{target}]] -> {hits[0][2]}")
|
|
187
|
+
else:
|
|
188
|
+
opts = ", ".join(h[2] for h in hits)
|
|
189
|
+
lines.append(f"[[{target}]] -> ambiguous, candidates: {opts}")
|
|
190
|
+
|
|
191
|
+
context = (
|
|
192
|
+
"Obsidian wikilink resolution (vault: " + vault + "):\n"
|
|
193
|
+
+ "\n".join(lines)
|
|
194
|
+
+ "\nRead the resolved file(s) when their content is relevant to the request."
|
|
195
|
+
)
|
|
196
|
+
print(json.dumps({
|
|
197
|
+
"hookSpecificOutput": {
|
|
198
|
+
"hookEventName": "UserPromptSubmit",
|
|
199
|
+
"additionalContext": context,
|
|
200
|
+
}
|
|
201
|
+
}))
|
|
202
|
+
sys.exit(0)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "obsidian-wikilinks",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Resolve Obsidian [[wikilinks]] in coding-agent prompts to local vault paths and inject them as context.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "plugin/obsidian-wikilinks.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./plugin/obsidian-wikilinks.js",
|
|
9
|
+
"./server": "./plugin/obsidian-wikilinks.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node test/smoke.mjs",
|
|
14
|
+
"test:bun": "bun test/smoke.mjs",
|
|
15
|
+
"check:versions": "node scripts/sync-versions.mjs --check",
|
|
16
|
+
"sync:versions": "node scripts/sync-versions.mjs",
|
|
17
|
+
"version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
|
|
18
|
+
"prepublishOnly": "npm run check:versions && npm test"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"plugin",
|
|
22
|
+
"hooks",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"obsidian",
|
|
28
|
+
"wikilinks",
|
|
29
|
+
"notes",
|
|
30
|
+
"context",
|
|
31
|
+
"opencode",
|
|
32
|
+
"opencode-plugin"
|
|
33
|
+
],
|
|
34
|
+
"author": {
|
|
35
|
+
"name": "Sven Depickere",
|
|
36
|
+
"url": "https://github.com/DepickereSven"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/DepickereSven/obsidian-wikilinks",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/DepickereSven/obsidian-wikilinks.git"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/DepickereSven/obsidian-wikilinks/issues"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode plugin: resolve [[wikilinks]] in a prompt to Obsidian vault paths.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around hooks/wikilink-resolver.py, the same resolver used by the
|
|
5
|
+
* Claude Code and Codex UserPromptSubmit hooks. The Python script owns all
|
|
6
|
+
* vault detection and matching logic; this file only bridges OpenCode's
|
|
7
|
+
* `chat.message` hook to it.
|
|
8
|
+
*/
|
|
9
|
+
import {spawn} from "node:child_process"
|
|
10
|
+
import {existsSync} from "node:fs"
|
|
11
|
+
import {homedir} from "node:os"
|
|
12
|
+
import path from "node:path"
|
|
13
|
+
import {fileURLToPath} from "node:url"
|
|
14
|
+
|
|
15
|
+
const WIKILINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/
|
|
16
|
+
const PYTHON = process.env.OBSIDIAN_WIKILINKS_PYTHON || "python3"
|
|
17
|
+
const TIMEOUT_MS = Number(process.env.OBSIDIAN_WIKILINKS_TIMEOUT_MS || 10000)
|
|
18
|
+
|
|
19
|
+
/** Locate wikilink-resolver.py, whether the plugin is symlinked, copied, or installed by another host. */
|
|
20
|
+
function findResolver() {
|
|
21
|
+
const explicit = process.env.OBSIDIAN_WIKILINKS_RESOLVER
|
|
22
|
+
if (explicit) return existsSync(explicit) ? explicit : null
|
|
23
|
+
|
|
24
|
+
const candidates = []
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
// Repo layout: <root>/plugin/obsidian-wikilinks.js -> <root>/hooks/wikilink-resolver.py
|
|
27
|
+
for (let dir = here, i = 0; i < 5; i++, dir = path.dirname(dir)) {
|
|
28
|
+
candidates.push(path.join(dir, "hooks", "wikilink-resolver.py"))
|
|
29
|
+
if (dir === path.dirname(dir)) break
|
|
30
|
+
}
|
|
31
|
+
// Copied next to the plugin file
|
|
32
|
+
candidates.push(path.join(here, "wikilink-resolver.py"))
|
|
33
|
+
// Installed by another host on the same machine
|
|
34
|
+
const home = homedir()
|
|
35
|
+
for (const base of [
|
|
36
|
+
path.join(home, ".config", "opencode", "obsidian-wikilinks"),
|
|
37
|
+
path.join(home, ".claude", "plugins", "obsidian-wikilinks"),
|
|
38
|
+
path.join(home, ".codex", "plugins", "obsidian-wikilinks"),
|
|
39
|
+
]) {
|
|
40
|
+
candidates.push(path.join(base, "hooks", "wikilink-resolver.py"))
|
|
41
|
+
}
|
|
42
|
+
return candidates.find(existsSync) || null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run the resolver with the hook payload on stdin; resolve to its additionalContext, or null. */
|
|
46
|
+
function resolveWikilinks(script, prompt) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
let child
|
|
49
|
+
try {
|
|
50
|
+
child = spawn(PYTHON, [script], {
|
|
51
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
52
|
+
env: {...process.env, OBSIDIAN_WIKILINKS_HOST: "opencode"},
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
return resolve(null)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let out = ""
|
|
59
|
+
const done = (value) => {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
resolve(value)
|
|
62
|
+
}
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
child.kill()
|
|
65
|
+
done(null)
|
|
66
|
+
}, TIMEOUT_MS)
|
|
67
|
+
|
|
68
|
+
child.stdout.on("data", (chunk) => (out += chunk))
|
|
69
|
+
child.on("error", () => done(null))
|
|
70
|
+
child.on("close", () => {
|
|
71
|
+
try {
|
|
72
|
+
const context = JSON.parse(out).hookSpecificOutput?.additionalContext
|
|
73
|
+
done(typeof context === "string" && context ? context : null)
|
|
74
|
+
} catch {
|
|
75
|
+
done(null)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
child.stdin.on("error", () => {
|
|
80
|
+
})
|
|
81
|
+
child.stdin.end(JSON.stringify({prompt}))
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let partCounter = 0
|
|
86
|
+
|
|
87
|
+
function partID() {
|
|
88
|
+
partCounter += 1
|
|
89
|
+
const stamp = Date.now().toString(36)
|
|
90
|
+
const rand = Math.random().toString(36).slice(2, 10)
|
|
91
|
+
return `prt_wikilink_${stamp}${partCounter.toString(36)}${rand}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {import("@opencode-ai/plugin").Plugin} */
|
|
95
|
+
export const ObsidianWikilinksPlugin = async () => {
|
|
96
|
+
return {
|
|
97
|
+
"chat.message": async (_input, output) => {
|
|
98
|
+
const prompt = output.parts
|
|
99
|
+
.filter((part) => part.type === "text" && !part.synthetic && part.text)
|
|
100
|
+
.map((part) => part.text)
|
|
101
|
+
.join("\n")
|
|
102
|
+
if (!prompt || !WIKILINK.test(prompt)) return
|
|
103
|
+
|
|
104
|
+
const script = findResolver()
|
|
105
|
+
if (!script) return
|
|
106
|
+
|
|
107
|
+
const context = await resolveWikilinks(script, prompt)
|
|
108
|
+
if (!context) return
|
|
109
|
+
|
|
110
|
+
output.parts.push({
|
|
111
|
+
id: partID(),
|
|
112
|
+
sessionID: output.message.sessionID,
|
|
113
|
+
messageID: output.message.id,
|
|
114
|
+
type: "text",
|
|
115
|
+
text: context,
|
|
116
|
+
synthetic: true,
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Default export shape required by opencode for npm and file:// plugin specs
|
|
123
|
+
// (`{ id, server }`); the named export above keeps plugin-directory loading
|
|
124
|
+
// working on older opencode versions that scan named exports.
|
|
125
|
+
export default {
|
|
126
|
+
id: "obsidian-wikilinks",
|
|
127
|
+
server: ObsidianWikilinksPlugin,
|
|
128
|
+
}
|