From 61bd287f46155b058d43b68077606ba63313e31e Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 21 Jul 2026 15:47:38 -0700 Subject: [PATCH 1/2] =?UTF-8?q?port=20aisuite#381:=20Obsidian=20connector?= =?UTF-8?q?=20=E2=80=94=20local=20vault,=20no=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder-grant auth kind, seven tools on the raw Markdown (search/read/ list/backlinks/daily/write/open-in-app), approval-gated sandboxed writes, native folder picker in the connect form. --- coworker/connectors/catalog_copy.py | 10 + coworker/connectors/descriptors.py | 53 ++++ coworker/connectors/integration_tools.py | 184 +++++++++++ coworker/connectors/obsidian_tools.py | 323 ++++++++++++++++++++ coworker/connectors/tool_defs.py | 49 +++ surfaces/gui/e2e/fixtures.ts | 9 + surfaces/gui/e2e/obsidian-connector.spec.ts | 35 +++ surfaces/gui/src/api.ts | 2 + surfaces/gui/src/components/ManageTabs.tsx | 43 ++- tests/test_obsidian.py | 145 +++++++++ 10 files changed, 846 insertions(+), 7 deletions(-) create mode 100644 coworker/connectors/obsidian_tools.py create mode 100644 surfaces/gui/e2e/obsidian-connector.spec.ts create mode 100644 tests/test_obsidian.py diff --git a/coworker/connectors/catalog_copy.py b/coworker/connectors/catalog_copy.py index 2e5c801e..4d9b7194 100644 --- a/coworker/connectors/catalog_copy.py +++ b/coworker/connectors/catalog_copy.py @@ -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.", diff --git a/coworker/connectors/descriptors.py b/coworker/connectors/descriptors.py index 296dec43..716cd5a9 100644 --- a/coworker/connectors/descriptors.py +++ b/coworker/connectors/descriptors.py @@ -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, } @@ -162,6 +166,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", @@ -1261,6 +1285,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", diff --git a/coworker/connectors/integration_tools.py b/coworker/connectors/integration_tools.py index 7588136d..ec076c3b 100644 --- a/coworker/connectors/integration_tools.py +++ b/coworker/connectors/integration_tools.py @@ -3276,6 +3276,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]: diff --git a/coworker/connectors/obsidian_tools.py b/coworker/connectors/obsidian_tools.py new file mode 100644 index 00000000..aa15c1e7 --- /dev/null +++ b/coworker/connectors/obsidian_tools.py @@ -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__ diff --git a/coworker/connectors/tool_defs.py b/coworker/connectors/tool_defs.py index d8b365eb..e4886394 100644 --- a/coworker/connectors/tool_defs.py +++ b/coworker/connectors/tool_defs.py @@ -767,6 +767,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", diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 67b11123..dd00f48e 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -138,6 +138,7 @@ const CONNECTORS = { connectors: [ { name: "browser", title: "Browser", icon: "B", blurb: "Headless browser.", auth: "none", two_way: false, channels: false, available: true, brand_color: "#6b7280", logo: "", fields: [], instructions: [], connected: true, account: null, enabled: true, allowed_users: [], tools: [], managed: false, managed_profile: false }, { name: "telegram", title: "Telegram", icon: "T", blurb: "Two-way Telegram messaging.", auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#229ed9", logo: "telegram", fields: [{ key: "bot_token", label: "Bot token", secret: true, required: true, help: "", placeholder: "123456:ABC…" }], 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 }, // Managed-capable connector (one-click via cloud when signed in; manual paste otherwise). // Carries pre-connect detail copy (§38): about + access + tools drive available-detail.spec.ts. { name: "gmail", title: "Gmail", icon: "✉", blurb: "Search, summarize, draft, and send email.", about: "Search, summarize, and send over your Gmail.", access: ["Reads and searches your mail.", "Sends email as you.", "Never deletes mail or changes account settings."], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#ea4335", logo: "gmail", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "gmail_search", label: "Search mail", kind: "read", description: "Search messages.", enabled: true, requires_approval: false }, { name: "gmail_send", label: "Send email", kind: "write", description: "Send a message.", enabled: true, requires_approval: true }], managed: true, managed_profile: false }, @@ -1029,6 +1030,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: [ diff --git a/surfaces/gui/e2e/obsidian-connector.spec.ts b/surfaces/gui/e2e/obsidian-connector.spec.ts new file mode 100644 index 00000000..6427cc6e --- /dev/null +++ b/surfaces/gui/e2e/obsidian-connector.spec.ts @@ -0,0 +1,35 @@ +// 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); + + 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"); +}); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 10592365..b5f839a1 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -282,6 +282,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). diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index 1d461686..2312efcb 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -28,6 +28,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"; import { Toggle } from "./Toggle"; @@ -855,13 +856,41 @@ export function ConnectSetup({ {f.label} {!f.required && (optional)} - 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. */ +
+ setValues({ ...values, [f.key]: e.target.value })} + /> + {isTauri() && ( + + )} +
+ ) : ( + setValues({ ...values, [f.key]: e.target.value })} + /> + )} {f.help && {f.help}} ))} diff --git a/tests/test_obsidian.py b/tests/test_obsidian.py new file mode 100644 index 00000000..f67399de --- /dev/null +++ b/tests/test_obsidian.py @@ -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"] From 813160bcd9ec4de67d210ab52e342575fc5750a3 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Tue, 21 Jul 2026 16:03:24 -0700 Subject: [PATCH 2/2] e2e: catalog count 13, obsidian at list tail behind show-all --- surfaces/gui/e2e/access-section.spec.ts | 4 ++-- surfaces/gui/e2e/fixtures.ts | 2 +- surfaces/gui/e2e/obsidian-connector.spec.ts | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/surfaces/gui/e2e/access-section.spec.ts b/surfaces/gui/e2e/access-section.spec.ts index 5d4b490e..bbaa9402 100644 --- a/surfaces/gui/e2e/access-section.spec.ts +++ b/surfaces/gui/e2e/access-section.spec.ts @@ -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"); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index dd00f48e..ee8246ad 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -138,7 +138,6 @@ const CONNECTORS = { connectors: [ { name: "browser", title: "Browser", icon: "B", blurb: "Headless browser.", auth: "none", two_way: false, channels: false, available: true, brand_color: "#6b7280", logo: "", fields: [], instructions: [], connected: true, account: null, enabled: true, allowed_users: [], tools: [], managed: false, managed_profile: false }, { name: "telegram", title: "Telegram", icon: "T", blurb: "Two-way Telegram messaging.", auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#229ed9", logo: "telegram", fields: [{ key: "bot_token", label: "Bot token", secret: true, required: true, help: "", placeholder: "123456:ABC…" }], 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 }, // Managed-capable connector (one-click via cloud when signed in; manual paste otherwise). // Carries pre-connect detail copy (§38): about + access + tools drive available-detail.spec.ts. { name: "gmail", title: "Gmail", icon: "✉", blurb: "Search, summarize, draft, and send email.", about: "Search, summarize, and send over your Gmail.", access: ["Reads and searches your mail.", "Sends email as you.", "Never deletes mail or changes account settings."], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#ea4335", logo: "gmail", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "gmail_search", label: "Search mail", kind: "read", description: "Search messages.", enabled: true, requires_approval: false }, { name: "gmail_send", label: "Send email", kind: "write", description: "Send a message.", enabled: true, requires_approval: true }], managed: true, managed_profile: false }, @@ -156,6 +155,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 Mac."], 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 }, ], }; diff --git a/surfaces/gui/e2e/obsidian-connector.spec.ts b/surfaces/gui/e2e/obsidian-connector.spec.ts index 6427cc6e..1f677034 100644 --- a/surfaces/gui/e2e/obsidian-connector.spec.ts +++ b/surfaces/gui/e2e/obsidian-connector.spec.ts @@ -14,6 +14,8 @@ async function openConnectors(page) { 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();