This commit is contained in:
Rohit Prasad
2026-08-29 17:04:28 -07:00
committed by GitHub
11 changed files with 850 additions and 9 deletions
+10
View File
@@ -54,11 +54,21 @@ ABOUT: dict[str, str] = {
"asana": "Keep up with your Asana work — search and read tasks and "
"projects, create tasks, and comment. Connects with a personal access "
"token from the Asana developer console.",
"obsidian": "Work with your Obsidian vault — search and read notes, follow "
"wikilinks and backlinks, pull up daily notes, and (with approval) write "
"notes. The vault is read directly from disk: no account, no keys, no "
"plugin, and nothing ever leaves this Mac. A hand-off tool can open any "
"note in the Obsidian app itself.",
}
# What connecting actually grants, as short honest bullets. Write powers always
# name themselves; reads state their boundary ("…your account can see").
ACCESS: dict[str, list[str]] = {
"obsidian": [
"Reads notes in the vault folder you pick — never outside it",
"Creates or edits notes only with your approval",
"Runs entirely on this Mac; no account or network access",
],
"telegram": [
"Reads messages sent to your bot — never your personal chats.",
"Sends messages as the bot.",
+53
View File
@@ -21,6 +21,9 @@ class Field:
required: bool = True
help: str = ""
placeholder: str = ""
# Rendering hint: "" = text input; "folder" = the GUI offers the native folder
# picker (desktop) and fills the field with the chosen path.
kind: str = ""
def to_dict(self) -> dict:
return {
@@ -29,6 +32,7 @@ class Field:
"secret": self.secret,
"required": self.required,
"help": self.help,
"kind": self.kind,
"placeholder": self.placeholder,
}
@@ -166,6 +170,26 @@ def _validate_whoami(
return ValidationResult(False, error="unexpected response from API")
def _validate_obsidian(creds: dict) -> ValidationResult:
"""Local check, no network: the folder exists and is an Obsidian vault (has the
.obsidian config dir the app creates). Identity = the vault's folder name."""
from pathlib import Path
raw = str(creds.get("vault_path") or "").strip()
if not raw:
return ValidationResult(False, error="pick your vault folder")
path = Path(raw).expanduser()
if not path.is_dir():
return ValidationResult(False, error=f"folder not found: {path}")
if not (path / ".obsidian").is_dir():
return ValidationResult(
False,
error="that folder isn't an Obsidian vault — pick the folder that holds "
"your notes (it contains a hidden .obsidian folder)",
)
return ValidationResult(True, identity=path.name)
def _validate_notion(creds: dict) -> ValidationResult:
return _validate_whoami(
"GET",
@@ -1269,6 +1293,35 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
# validator's workspace name.
account_field="account_id",
),
ConnectorDescriptor(
name="obsidian",
title="Obsidian",
icon="",
blurb="Search, read, and write notes in your local vault — no account needed.",
# "folder": the credential is a local directory grant, not a secret. Connected
# = a vault_path is stored (auth="none" would read as always-connected).
auth="folder",
two_way=False,
fields=[
Field(
"vault_path",
"Vault folder",
secret=False,
kind="folder",
help="The folder holding your notes — it contains a hidden .obsidian "
"folder. Everything stays on this Mac.",
placeholder="~/Documents/MyVault",
),
],
instructions=[
"Pick your vault folder — no account, no keys.",
"Notes are read directly from disk; nothing leaves this Mac.",
],
validate=_validate_obsidian,
brand_color="#7c3aed",
logo="obsidian",
aliases=("notes", "markdown", "vault", "pkm", "second brain", "knowledge base"),
),
ConnectorDescriptor(
name="attio",
title="Attio",
+184
View File
@@ -3278,6 +3278,190 @@ def make_integration_tools(
)
)
# -- obsidian (local vault — plain files, no network; obsidian_tools.py) --
def _obsidian_vault():
from pathlib import Path
profile, err = _profile(secrets, "obsidian", "vault_path")
if err:
return None, err
vault = Path(profile["vault_path"]).expanduser()
if not (vault / ".obsidian").is_dir():
return None, {
"error": "vault folder is missing or moved — reconnect Obsidian"
}
return vault, None
def obsidian_search_notes(
query: str, tag: str = "", max_results: int = 10
) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.search_notes(
vault, query, tag=tag, max_results=max_results
)
obsidian_search_notes.__name__ = "obsidian_search_notes"
tools.append(
_attach(
obsidian_search_notes,
_schema(
"obsidian_search_notes",
"Search Obsidian vault notes by title, tag, or content.",
{
"query": {"type": "string"},
"tag": {"type": "string"},
"max_results": {"type": "integer"},
},
["query"],
),
caps=["obsidian", "read"],
)
)
def obsidian_read_note(note: str) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.read_note(vault, note)
obsidian_read_note.__name__ = "obsidian_read_note"
tools.append(
_attach(
obsidian_read_note,
_schema(
"obsidian_read_note",
"Read one note by title, relative path, or [[wikilink]].",
{"note": {"type": "string"}},
["note"],
),
caps=["obsidian", "read"],
)
)
def obsidian_list_notes(folder: str = "", max_results: int = 30) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.list_notes(vault, folder=folder, max_results=max_results)
obsidian_list_notes.__name__ = "obsidian_list_notes"
tools.append(
_attach(
obsidian_list_notes,
_schema(
"obsidian_list_notes",
"List recently modified notes, optionally within one folder.",
{"folder": {"type": "string"}, "max_results": {"type": "integer"}},
[],
),
caps=["obsidian", "read"],
)
)
def obsidian_backlinks(note: str) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.backlinks(vault, note)
obsidian_backlinks.__name__ = "obsidian_backlinks"
tools.append(
_attach(
obsidian_backlinks,
_schema(
"obsidian_backlinks",
"List the notes that [[wikilink]] to a given note.",
{"note": {"type": "string"}},
["note"],
),
caps=["obsidian", "read"],
)
)
def obsidian_daily_note(date: str = "") -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.daily_note(vault, date=date)
obsidian_daily_note.__name__ = "obsidian_daily_note"
tools.append(
_attach(
obsidian_daily_note,
_schema(
"obsidian_daily_note",
"Read the daily note for today, or for a YYYY-MM-DD date.",
{"date": {"type": "string"}},
[],
),
caps=["obsidian", "read"],
)
)
def obsidian_write_note(
note: str, content: str, mode: str = "append"
) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.write_note(vault, note, content, mode=mode)
obsidian_write_note.__name__ = "obsidian_write_note"
tools.append(
_attach(
obsidian_write_note,
_schema(
"obsidian_write_note",
"Append to, create, or overwrite a vault note (mode: append | create | overwrite).",
{
"note": {"type": "string"},
"content": {"type": "string"},
"mode": {"type": "string"},
},
["note", "content"],
),
approval=True,
caps=["obsidian", "write"],
)
)
def open_in_obsidian(note: str) -> dict[str, Any]:
vault, err = _obsidian_vault()
if err:
return err
from . import obsidian_tools
return obsidian_tools.open_in_obsidian(vault, note)
open_in_obsidian.__name__ = "open_in_obsidian"
tools.append(
_attach(
open_in_obsidian,
_schema(
"open_in_obsidian",
"Open a note in the user's Obsidian app (obsidian:// hand-off).",
{"note": {"type": "string"}},
["note"],
),
caps=["obsidian", "read"],
)
)
# -- attio (managed OAuth or API key, multi-workspace) --
def attio_list_objects(account: str = "") -> dict[str, Any]:
+323
View File
@@ -0,0 +1,323 @@
"""Obsidian vault operations — local-first, dependency-free.
A vault is a plain folder of Markdown files (the `.obsidian/` dir inside is the app's
own config), so the connector needs no plugin, no server, no keys: tools read and
write the files directly and work whether or not Obsidian is running. Everything is
sandboxed to the connected vault every resolved path is checked back against the
vault root, mirroring the workspace guard in the files toolkit.
`open_in_obsidian` is the one hand-off point: it launches the user's own app on a
note via the `obsidian://` URL scheme (never required for anything else to work).
"""
from __future__ import annotations
import datetime
import json
import re
import urllib.parse
from pathlib import Path
from typing import Any, Optional
MAX_NOTE_CHARS = 60_000 # per read; large notes are truncated with a marker
MAX_RESULTS = 25
_SKIP_DIRS = {".obsidian", ".trash", ".git"}
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)")
_TAG_RE = re.compile(r"(?:^|\s)#([A-Za-z0-9_/-]+)")
def _is_note(path: Path) -> bool:
return path.suffix.lower() == ".md" and not any(
part in _SKIP_DIRS for part in path.parts
)
def iter_notes(vault: Path) -> list[Path]:
return sorted(p for p in vault.rglob("*.md") if _is_note(p.relative_to(vault)))
def _inside(vault: Path, path: Path) -> bool:
try:
path.resolve().relative_to(vault.resolve())
return True
except ValueError:
return False
def resolve_note(vault: Path, ref: str) -> Optional[Path]:
"""A note by relative path, bare title, or wikilink (`[[Title]]`). Title matches
are case-insensitive on the filename stem; ties break to the shortest path
(Obsidian's own link-resolution habit)."""
ref = ref.strip().strip("[]").split("|")[0].split("#")[0].strip()
if not ref:
return None
candidate = (
(vault / ref).with_suffix(".md") if not ref.endswith(".md") else vault / ref
)
if (
candidate.is_file()
and _inside(vault, candidate)
and _is_note(candidate.relative_to(vault))
):
return candidate
stem = ref.lower().removesuffix(".md")
matches = [p for p in iter_notes(vault) if p.stem.lower() == stem]
if not matches:
return None
return min(matches, key=lambda p: len(str(p)))
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""YAML frontmatter (if any) + body. Malformed frontmatter degrades to {}."""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---", 4)
if end < 0:
return {}, text
try:
import yaml
data = yaml.safe_load(text[4:end]) or {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
return data, text[end + 4 :].lstrip("\n")
def note_tags(frontmatter: dict[str, Any], body: str) -> list[str]:
tags: list[str] = []
raw = frontmatter.get("tags")
if isinstance(raw, str):
tags += [t.strip().lstrip("#") for t in raw.split(",") if t.strip()]
elif isinstance(raw, list):
tags += [str(t).lstrip("#") for t in raw]
tags += _TAG_RE.findall(body)
return sorted({t for t in tags if t})
def _title(vault: Path, path: Path) -> str:
return path.stem
def _rel(vault: Path, path: Path) -> str:
return str(path.relative_to(vault))
def _preview(body: str, query: str) -> str:
"""A ~200-char window around the first hit (or the note's start)."""
low = body.lower()
at = low.find(query.lower()) if query else -1
start = max(0, at - 60) if at >= 0 else 0
snippet = body[start : start + 200].strip().replace("\n", " ")
return ("" if start > 0 else "") + snippet
def search_notes(
vault: Path, query: str, tag: str = "", max_results: int = 10
) -> dict[str, Any]:
"""Title/tag/content search, title hits first. `tag` narrows to notes carrying it."""
query = (query or "").strip()
tag = (tag or "").lstrip("#").strip()
limit = max(1, min(int(max_results or 10), MAX_RESULTS))
hits: list[tuple[int, dict[str, Any]]] = []
for path in iter_notes(vault):
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
frontmatter, body = parse_frontmatter(text)
tags = note_tags(frontmatter, body)
if tag and tag not in tags:
continue
title = _title(vault, path)
score = 0
if query:
if query.lower() in title.lower():
score = 3
elif any(query.lower() == t.lower() for t in tags):
score = 2
elif query.lower() in body.lower():
score = 1
if score == 0:
continue
hits.append(
(
score,
{
"title": title,
"path": _rel(vault, path),
"tags": tags[:12],
"preview": _preview(body, query),
},
)
)
hits.sort(key=lambda h: (-h[0], h[1]["path"]))
return {"count": len(hits), "notes": [h[1] for h in hits[:limit]]}
def read_note(vault: Path, ref: str) -> dict[str, Any]:
path = resolve_note(vault, ref)
if path is None:
return {"error": f"note not found: {ref}"}
text = path.read_text(encoding="utf-8", errors="replace")
frontmatter, body = parse_frontmatter(text)
truncated = len(body) > MAX_NOTE_CHARS
return {
"title": _title(vault, path),
"path": _rel(vault, path),
"tags": note_tags(frontmatter, body),
"frontmatter": frontmatter,
"links": sorted({m.strip() for m in _WIKILINK_RE.findall(body)})[:50],
"content": body[:MAX_NOTE_CHARS] + ("\n…[truncated]" if truncated else ""),
}
def list_notes(vault: Path, folder: str = "", max_results: int = 30) -> dict[str, Any]:
"""Most recently modified first; `folder` narrows to a subfolder."""
limit = max(1, min(int(max_results or 30), 100))
base = vault / folder.strip("/") if folder.strip() else vault
if not (base.is_dir() and _inside(vault, base)):
return {"error": f"folder not found: {folder}"}
notes = [p for p in iter_notes(vault) if base in p.parents or base == vault]
notes.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return {
"count": len(notes),
"notes": [
{
"title": _title(vault, p),
"path": _rel(vault, p),
"modified": datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime(
"%Y-%m-%d %H:%M"
),
}
for p in notes[:limit]
],
}
def backlinks(vault: Path, ref: str) -> dict[str, Any]:
"""Notes that wikilink to this one."""
target = resolve_note(vault, ref)
if target is None:
return {"error": f"note not found: {ref}"}
stem = target.stem.lower()
linking = []
for path in iter_notes(vault):
if path == target:
continue
try:
body = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
if any(m.strip().lower() == stem for m in _WIKILINK_RE.findall(body)):
linking.append({"title": _title(vault, path), "path": _rel(vault, path)})
return {"note": _rel(vault, target), "count": len(linking), "backlinks": linking}
def _daily_config(vault: Path) -> tuple[str, str]:
"""(folder, filename-format) from .obsidian/daily-notes.json; Obsidian's defaults
otherwise. Only the common YYYY/MM/DD moment tokens are translated."""
folder, fmt = "", "YYYY-MM-DD"
try:
raw = json.loads((vault / ".obsidian" / "daily-notes.json").read_text())
folder = str(raw.get("folder") or "").strip("/")
fmt = str(raw.get("format") or fmt)
except Exception:
pass
return folder, fmt
def daily_note(vault: Path, date: str = "") -> dict[str, Any]:
"""Today's (or a given YYYY-MM-DD day's) daily note, honoring the vault's
daily-notes folder/format config."""
try:
day = (
datetime.date.fromisoformat(date) if date.strip() else datetime.date.today()
)
except ValueError:
return {"error": f"invalid date (want YYYY-MM-DD): {date}"}
folder, fmt = _daily_config(vault)
name = (
fmt.replace("YYYY", f"{day.year:04d}")
.replace("MM", f"{day.month:02d}")
.replace("DD", f"{day.day:02d}")
)
rel = f"{folder}/{name}.md" if folder else f"{name}.md"
path = vault / rel
if not path.is_file():
return {"error": f"no daily note for {day.isoformat()} (looked at {rel})"}
return read_note(vault, rel)
def write_note(
vault: Path, ref: str, content: str, mode: str = "append"
) -> dict[str, Any]:
"""append (default) | create (fails if it exists) | overwrite. New notes may name
folders that don't exist yet; everything must land inside the vault."""
if mode not in ("append", "create", "overwrite"):
return {"error": "mode must be append, create, or overwrite"}
existing = resolve_note(vault, ref)
if existing is None:
rel = ref.strip().strip("[]")
target = (
(vault / rel).with_suffix(".md") if not rel.endswith(".md") else vault / rel
)
if not _inside(vault, target):
return {"error": "path escapes the vault"}
if mode == "append":
mode = "create" # appending to a note that doesn't exist creates it
else:
target = existing
if mode == "create":
return {"error": f"note already exists: {_rel(vault, existing)}"}
if not _inside(vault, target):
return {"error": "path escapes the vault"}
target.parent.mkdir(parents=True, exist_ok=True)
if mode == "append" and target.is_file():
base = target.read_text(encoding="utf-8", errors="replace")
joiner = "" if (not base or base.endswith("\n")) else "\n"
target.write_text(base + joiner + content, encoding="utf-8")
else:
target.write_text(content, encoding="utf-8")
return {"ok": True, "path": _rel(vault, target), "mode": mode}
def open_in_obsidian(vault: Path, ref: str) -> dict[str, Any]:
"""Open the note in the Obsidian app via its obsidian:// URL scheme."""
path = resolve_note(vault, ref)
if path is None:
return {"error": f"note not found: {ref}"}
rel = str(path.relative_to(vault))[: -len(".md")]
url = (
"obsidian://open?vault="
+ urllib.parse.quote(vault.name)
+ "&file="
+ urllib.parse.quote(rel)
)
error = _launch(url)
if error:
return {"error": f"could not open Obsidian: {error}", "url": url}
return {"ok": True, "opened": rel, "url": url}
def _launch(url: str) -> Optional[str]:
"""OS-native URL open (same pattern as reveal_artifact). Returns an error string."""
import subprocess
import sys
try:
if sys.platform == "darwin":
subprocess.run(["open", url], check=True, capture_output=True, timeout=10)
elif sys.platform == "win32":
import os
os.startfile(url) # type: ignore[attr-defined]
else:
subprocess.run(
["xdg-open", url], check=True, capture_output=True, timeout=10
)
return None
except Exception as exc:
return str(exc) or exc.__class__.__name__
+49
View File
@@ -759,6 +759,55 @@ TOOL_DEFS: tuple[ConnectorToolDef, ...] = (
"write",
"Create a page under a parent page.",
),
ConnectorToolDef(
"obsidian",
"obsidian_search_notes",
"Search notes",
"read",
"Search vault notes by title, tag, or content.",
),
ConnectorToolDef(
"obsidian",
"obsidian_read_note",
"Read note",
"read",
"Read a note by title, path, or [[wikilink]].",
),
ConnectorToolDef(
"obsidian",
"obsidian_list_notes",
"List notes",
"read",
"List recently modified notes, optionally in one folder.",
),
ConnectorToolDef(
"obsidian",
"obsidian_backlinks",
"Backlinks",
"read",
"Notes that link to a given note.",
),
ConnectorToolDef(
"obsidian",
"obsidian_daily_note",
"Daily note",
"read",
"Read today's (or a given day's) daily note.",
),
ConnectorToolDef(
"obsidian",
"obsidian_write_note",
"Write note",
"write",
"Append to, create, or overwrite a note.",
),
ConnectorToolDef(
"obsidian",
"open_in_obsidian",
"Open in Obsidian",
"read",
"Open a note in the Obsidian app.",
),
ConnectorToolDef(
"attio",
"attio_list_objects",
+2 -2
View File
@@ -52,7 +52,7 @@ test("+ Add a source: full catalog on focus, filter as you type → connect-in-c
const search = page.getByTestId("access-add-search");
await expect(search).toBeFocused();
const rows = page.locator('[data-testid^="access-add-"]:not([data-testid="access-add-search"])');
await expect(rows).toHaveCount(9); // 12 in the catalog browser/slack/github (connected)
await expect(rows).toHaveCount(10); // 13 in the catalog browser/slack/github (connected)
await expect(page.getByTestId("access-add-notion")).toBeVisible();
// Already-connected sources don't match (Slack and GitHub are connected in fixtures)…
@@ -63,7 +63,7 @@ test("+ Add a source: full catalog on focus, filter as you type → connect-in-c
// …and clearing the query restores the full list ("filter as you type", not search-only).
await search.fill("");
await expect(rows).toHaveCount(9);
await expect(rows).toHaveCount(10);
// Capability aliases match too: "calendar" surfaces Outlook (title alone never would).
await search.fill("calendar");
+9
View File
@@ -187,6 +187,7 @@ const CONNECTORS = {
// (two-mode modal). Neither needs cloud sign-in.
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
{ name: "obsidian", title: "Obsidian", icon: "\u25c8", blurb: "Search, read, and write notes in your local vault \u2014 no account needed.", auth: "folder", two_way: false, channels: false, available: true, brand_color: "#7c3aed", logo: "obsidian", fields: [{ key: "vault_path", label: "Vault folder", secret: false, required: true, help: "The folder holding your notes.", placeholder: "~/Documents/MyVault", kind: "folder" }], instructions: ["Pick your vault folder \u2014 no account, no keys."], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
],
};
@@ -1792,6 +1793,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
hubspotState.hidden_fields = b.hidden_fields.map((f: string) => f.trim().toLowerCase());
return json({ ok: true, hidden_fields: [...hubspotState.hidden_fields] });
}
if (p.endsWith("/v1/connectors/obsidian/connect") && m === "POST") {
const path = String(req.postDataJSON()?.fields?.vault_path || "");
if (!path) return json({ ok: false, error: "pick your vault folder" });
if (!path.includes("Vault")) return json({ ok: false, error: "that folder isn't an Obsidian vault" });
const row = CONNECTORS.connectors.find((c: any) => c.name === "obsidian");
if (row) { row.connected = true; row.enabled = true; row.account = "MyVault"; }
return json({ ok: true, account: "MyVault" });
}
if (p.endsWith("/v1/connectors"))
return json({
connectors: [
@@ -0,0 +1,37 @@
// Obsidian connector (local vault, auth="folder"): appears in the available list,
// the add modal renders the vault-folder field (text input in browser; the native
// picker button is desktop-only), a wrong folder shows the vault error, and a real
// vault path connects with the vault name as the account identity.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("obsidian: folder field, vault validation error, then connects", async ({ page }) => {
await openConnectors(page);
// Obsidian sits at the catalog's tail — expand past the truncation fold first.
await page.getByRole("button", { name: "show all" }).click();
const card = page.getByTestId("connector-obsidian");
await expect(card).toContainText("Obsidian");
await card.getByRole("button", { name: "Connect" }).click();
// Vault-folder field renders with the descriptor's help copy.
await expect(page.getByText("Vault folder", { exact: true })).toBeVisible();
const input = page.getByPlaceholder("~/Documents/MyVault");
await expect(input).toBeVisible();
// A non-vault folder surfaces the honest error.
await input.fill("/tmp/not-a-real-one");
await page.getByRole("button", { name: "Connect", exact: true }).last().click();
await expect(page.getByText("isn't an Obsidian vault")).toBeVisible();
// A vault path connects; identity = vault folder name.
await input.fill("/Users/me/Documents/MyVault");
await page.getByRole("button", { name: "Connect", exact: true }).last().click();
await expect(page.getByTestId("connector-obsidian")).toContainText("MyVault");
});
+2
View File
@@ -531,6 +531,8 @@ export interface ConnectorField {
required: boolean;
help: string;
placeholder: string;
// Rendering hint: "" = text input; "folder" = offer the native folder picker (desktop).
kind?: string;
}
// A message from a sender not (yet) on the allow-list — parked instead of dropped (§19).
+36 -7
View File
@@ -20,6 +20,7 @@ import {
type ProviderInfo,
} from "../api";
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
import { isTauri, pickFolder } from "../tauri";
import { ModelChecklist } from "./ModelChecklist";
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
@@ -579,13 +580,41 @@ export function ConnectSetup({
{f.label}
{!f.required && <em> ({t("manage.optional")})</em>}
</span>
<input
type={f.secret ? "password" : "text"}
placeholder={f.placeholder}
value={values[f.key] || ""}
spellCheck={false}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
{f.kind === "folder" ? (
/* Local-folder credential (obsidian): native picker on desktop, and the
text input stays for browser dev / hand-typed paths. */
<div className="flex gap-2">
<input
className="flex-1 min-w-0"
type="text"
placeholder={f.placeholder}
value={values[f.key] || ""}
spellCheck={false}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
{isTauri() && (
<button
type="button"
className={BTN_BORDERED}
data-testid={`pick-${f.key}`}
onClick={async () => {
const path = await pickFolder();
if (path) setValues({ ...values, [f.key]: path });
}}
>
Choose
</button>
)}
</div>
) : (
<input
type={f.secret ? "password" : "text"}
placeholder={f.placeholder}
value={values[f.key] || ""}
spellCheck={false}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
{f.help && <span className="conn-field-help">{f.help}</span>}
</label>
))}
+145
View File
@@ -0,0 +1,145 @@
"""Obsidian connector: vault validation, note resolution (paths/titles/wikilinks),
search/tags/frontmatter, backlinks, daily notes, sandboxed writes, and the
obsidian:// hand-off. All against a real tmp vault no Obsidian needed."""
from __future__ import annotations
import json
import pytest
from coworker.connectors import obsidian_tools as ob
from coworker.connectors.descriptors import get_descriptor
@pytest.fixture()
def vault(tmp_path):
v = tmp_path / "MyVault"
(v / ".obsidian").mkdir(parents=True)
(v / "Projects").mkdir()
(v / "Daily").mkdir()
(v / "Launch Plan.md").write_text(
"---\ntags: [launch, planning]\n---\n"
"# Launch Plan\nShip [[Pricing]] before the keynote. #urgent\n",
encoding="utf-8",
)
(v / "Projects" / "Pricing.md").write_text(
"Tiered pricing draft. See [[Launch Plan]].\n", encoding="utf-8"
)
(v / "Daily" / "2026-07-20.md").write_text("- stood up\n", encoding="utf-8")
(v / ".obsidian" / "daily-notes.json").write_text(
json.dumps({"folder": "Daily", "format": "YYYY-MM-DD"}), encoding="utf-8"
)
(v / ".obsidian" / "hidden.md").write_text("never index me", encoding="utf-8")
return v
def test_descriptor_validates_real_vault(vault, tmp_path):
d = get_descriptor("obsidian")
assert d is not None and d.auth == "folder"
ok = d.validate({"vault_path": str(vault)})
assert ok.ok and ok.identity == "MyVault"
assert not d.validate({"vault_path": str(tmp_path / "nope")}).ok
plain = tmp_path / "plain"
plain.mkdir()
res = d.validate({"vault_path": str(plain)})
assert not res.ok and ".obsidian" in (res.error or "")
def test_resolve_by_path_title_and_wikilink(vault):
by_path = ob.resolve_note(vault, "Projects/Pricing.md")
by_title = ob.resolve_note(vault, "pricing")
by_link = ob.resolve_note(vault, "[[Pricing|the pricing note]]")
assert by_path == by_title == by_link
assert ob.resolve_note(vault, "No Such Note") is None
def test_search_scores_title_tag_content(vault):
top = ob.search_notes(vault, "pricing")["notes"][0]
assert top["title"] == "Pricing" # title hit outranks the content mention
tagged = ob.search_notes(vault, "launch", tag="urgent")
assert [n["title"] for n in tagged["notes"]] == ["Launch Plan"]
assert ob.search_notes(vault, "hidden")["count"] == 0 # .obsidian never indexed
def test_read_note_frontmatter_tags_links(vault):
note = ob.read_note(vault, "Launch Plan")
assert note["frontmatter"]["tags"] == ["launch", "planning"]
assert set(note["tags"]) == {"launch", "planning", "urgent"}
assert note["links"] == ["Pricing"]
assert "keynote" in note["content"]
def test_backlinks(vault):
result = ob.backlinks(vault, "Pricing")
assert result["count"] == 1 and result["backlinks"][0]["title"] == "Launch Plan"
def test_daily_note_honors_vault_config(vault):
assert "stood up" in ob.daily_note(vault, "2026-07-20")["content"]
missing = ob.daily_note(vault, "2026-07-19")
assert (
"no daily note" in missing["error"]
and "Daily/2026-07-19.md" in missing["error"]
)
assert "invalid date" in ob.daily_note(vault, "today")["error"]
def test_write_modes_and_sandbox(vault):
appended = ob.write_note(vault, "Launch Plan", "New line.")
assert appended["ok"] and appended["mode"] == "append"
assert (vault / "Launch Plan.md").read_text().endswith("New line.")
created = ob.write_note(vault, "Inbox/Idea", "A thought.", mode="append")
assert created["ok"] and created["path"] == "Inbox/Idea.md" # append→create
dup = ob.write_note(vault, "Pricing", "x", mode="create")
assert "already exists" in dup["error"]
escape = ob.write_note(vault, "../outside", "nope")
assert "escapes the vault" in escape["error"]
def test_open_in_obsidian_builds_url(vault, monkeypatch):
seen = {}
monkeypatch.setattr(ob, "_launch", lambda url: seen.setdefault("url", url) and None)
result = ob.open_in_obsidian(vault, "Pricing")
assert result["ok"] and result["opened"] == "Projects/Pricing"
assert seen["url"] == "obsidian://open?vault=MyVault&file=Projects/Pricing"
assert "not found" in ob.open_in_obsidian(vault, "ghost")["error"]
def test_open_in_obsidian_launch_failure_is_reported(vault, monkeypatch):
monkeypatch.setattr(ob, "_launch", lambda url: "no handler for obsidian://")
result = ob.open_in_obsidian(vault, "Pricing")
assert "could not open Obsidian" in result["error"] and result["url"]
def test_integration_tools_wire_and_guard(vault, tmp_path, monkeypatch):
from coworker.connectors.integration_tools import make_integration_tools
from coworker.secrets import SecretStore
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
secrets = SecretStore()
tools = {t.__name__: t for t in make_integration_tools(secrets)}
for name in (
"obsidian_search_notes",
"obsidian_read_note",
"obsidian_list_notes",
"obsidian_backlinks",
"obsidian_daily_note",
"obsidian_write_note",
"open_in_obsidian",
):
assert name in tools, name
# Not connected → a visible error, never a crash.
assert "error" in tools["obsidian_search_notes"]("pricing")
secrets.put("obsidian:default", {"vault_path": str(vault), "enabled": True})
hits = tools["obsidian_search_notes"]("pricing")
assert hits["notes"][0]["title"] == "Pricing"
# Vault moved after connect → reconnect hint, not a stack trace.
secrets.put("obsidian:default", {"vault_path": str(tmp_path / "gone")})
assert "reconnect" in tools["obsidian_read_note"]("Pricing")["error"]