obsidian-agent-cli-app 0.1.0__py3-none-any.whl
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.
- obsidian_agent/__init__.py +1 -0
- obsidian_agent/cli.py +54 -0
- obsidian_agent/config.py +60 -0
- obsidian_agent/core.py +55 -0
- obsidian_agent/doctor.py +67 -0
- obsidian_agent/loop.py +148 -0
- obsidian_agent/ui.py +80 -0
- obsidian_agent_cli_app-0.1.0.dist-info/METADATA +107 -0
- obsidian_agent_cli_app-0.1.0.dist-info/RECORD +12 -0
- obsidian_agent_cli_app-0.1.0.dist-info/WHEEL +4 -0
- obsidian_agent_cli_app-0.1.0.dist-info/entry_points.txt +2 -0
- obsidian_agent_cli_app-0.1.0.dist-info/licenses/LICENCE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
obsidian_agent/cli.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Command-line entry point for obsidian-agent, built on Typer for
|
|
2
|
+
clean --help output and a lower-boilerplate command structure."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .config import CONFIG_FILE, load_config
|
|
9
|
+
from .doctor import run_doctor
|
|
10
|
+
from .loop import main_loop
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="obsidian-agent",
|
|
14
|
+
help="Chat with your Obsidian vault via an MCP-connected AI agent.",
|
|
15
|
+
add_completion=True,
|
|
16
|
+
no_args_is_help=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def chat():
|
|
22
|
+
"""Start an interactive chat session (default command)."""
|
|
23
|
+
config = load_config()
|
|
24
|
+
asyncio.run(main_loop(config))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def doctor():
|
|
29
|
+
"""Check that obsidian-mcp is installed and can reach your vault."""
|
|
30
|
+
config = load_config()
|
|
31
|
+
run_doctor(config)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def config(reset: bool = typer.Option(False, "--reset", help="Re-run first-time setup.")):
|
|
36
|
+
"""View or reset your configuration."""
|
|
37
|
+
if reset:
|
|
38
|
+
load_config(force_setup=True)
|
|
39
|
+
return
|
|
40
|
+
typer.echo(f"Config file: {CONFIG_FILE}")
|
|
41
|
+
if CONFIG_FILE.exists():
|
|
42
|
+
typer.echo(CONFIG_FILE.read_text())
|
|
43
|
+
else:
|
|
44
|
+
typer.echo("No config yet — run 'obsidian-agent config --reset' to create one.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback(invoke_without_command=True)
|
|
48
|
+
def main(ctx: typer.Context):
|
|
49
|
+
if ctx.invoked_subcommand is None:
|
|
50
|
+
chat()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
app()
|
obsidian_agent/config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""User configuration: a one-time interactive setup, stored under the
|
|
2
|
+
platform's standard config directory, with env-var overrides for power
|
|
3
|
+
users."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "obsidian-agent"
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL = "meta-llama/llama-3.1-8b-instruct"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write_config(config: dict):
|
|
16
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
with open(CONFIG_FILE, "w") as f:
|
|
18
|
+
for key, value in config.items():
|
|
19
|
+
f.write(f'{key} = "{value}"\n')
|
|
20
|
+
print(f"Saved config to {CONFIG_FILE}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_first_time_setup() -> dict:
|
|
24
|
+
print("First-time setup for obsidian-agent.\n")
|
|
25
|
+
api_key = input("Enter your OpenRouter API key: ").strip()
|
|
26
|
+
vault_path = input("Enter the absolute path to your Obsidian vault: ").strip()
|
|
27
|
+
model = input(f"Model to use [{DEFAULT_MODEL}]: ").strip() or DEFAULT_MODEL
|
|
28
|
+
|
|
29
|
+
config = {
|
|
30
|
+
"openrouter_api_key": api_key,
|
|
31
|
+
"vault_path": vault_path,
|
|
32
|
+
"model": model,
|
|
33
|
+
}
|
|
34
|
+
_write_config(config)
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(force_setup: bool = False) -> dict:
|
|
39
|
+
if force_setup or not CONFIG_FILE.exists():
|
|
40
|
+
config = run_first_time_setup()
|
|
41
|
+
else:
|
|
42
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
43
|
+
config = tomllib.load(f)
|
|
44
|
+
|
|
45
|
+
# Env vars always win, for power users / CI / testing.
|
|
46
|
+
config["openrouter_api_key"] = os.environ.get(
|
|
47
|
+
"OPENROUTER_API_KEY", config.get("openrouter_api_key")
|
|
48
|
+
)
|
|
49
|
+
config["vault_path"] = os.environ.get(
|
|
50
|
+
"OBSIDIAN_VAULT_PATH", config.get("vault_path")
|
|
51
|
+
)
|
|
52
|
+
config["model"] = os.environ.get("AGENT_MODEL", config.get("model", DEFAULT_MODEL))
|
|
53
|
+
|
|
54
|
+
missing = [k for k in ("openrouter_api_key", "vault_path") if not config.get(k)]
|
|
55
|
+
if missing:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Missing required config: {', '.join(missing)}. "
|
|
58
|
+
f"Run 'obsidian-agent config --reset' to set up again."
|
|
59
|
+
)
|
|
60
|
+
return config
|
obsidian_agent/core.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared building blocks: MCP session helpers, tool-schema conversion,
|
|
2
|
+
and the OpenRouter/OpenAI client. Both the chat loop and the doctor
|
|
3
|
+
command build on this."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from mcp import StdioServerParameters
|
|
7
|
+
from openai import OpenAI
|
|
8
|
+
|
|
9
|
+
SYSTEM_PROMPT = (
|
|
10
|
+
"You are a helpful assistant with access to the user's Obsidian vault. "
|
|
11
|
+
"Always read a note before updating it so you don't overwrite existing "
|
|
12
|
+
"content by accident, unless the user clearly asks you to replace it."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_llm_client(api_key: str) -> OpenAI:
|
|
17
|
+
return OpenAI(
|
|
18
|
+
api_key=api_key,
|
|
19
|
+
base_url="https://openrouter.ai/api/v1",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_server_params(vault_path: str) -> StdioServerParameters:
|
|
24
|
+
return StdioServerParameters(
|
|
25
|
+
command="obsidian-mcp",
|
|
26
|
+
args=[],
|
|
27
|
+
env={
|
|
28
|
+
**os.environ,
|
|
29
|
+
"OBSIDIAN_VAULT_PATH": vault_path,
|
|
30
|
+
# Quiet the server's own logging; we only want MCP protocol
|
|
31
|
+
# traffic on stdio, not its log lines colliding with our UI.
|
|
32
|
+
"OBSIDIAN_LOG_LEVEL": "ERROR",
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def convert_mcp_tool_to_openai(tool) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"type": "function",
|
|
40
|
+
"function": {
|
|
41
|
+
"name": tool.name,
|
|
42
|
+
"description": tool.description,
|
|
43
|
+
"parameters": tool.inputSchema,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def execute_tool_call(tool_name: str, arguments: dict, session) -> str:
|
|
49
|
+
try:
|
|
50
|
+
result = await session.call_tool(tool_name, arguments=arguments)
|
|
51
|
+
if result.content and len(result.content) > 0:
|
|
52
|
+
return result.content[0].text
|
|
53
|
+
return "Tool executed but returned no content."
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return f"Error calling tool: {e}"
|
obsidian_agent/doctor.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Sanity checks: is obsidian-mcp installed, and can we actually reach
|
|
2
|
+
tools on the configured vault. This is test_obsidian_mcp.py, promoted to
|
|
3
|
+
a real subcommand with rich output."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
from mcp import ClientSession
|
|
11
|
+
from mcp.client.stdio import stdio_client
|
|
12
|
+
|
|
13
|
+
from . import ui
|
|
14
|
+
from .core import build_server_params
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_obsidian_mcp_installed() -> bool:
|
|
18
|
+
found = shutil.which("obsidian-mcp") is not None
|
|
19
|
+
if not found:
|
|
20
|
+
ui.console.print("[bold red]✗[/] obsidian-mcp was not found on your PATH.")
|
|
21
|
+
ui.console.print(" Install it with: [cyan]pip install obsidian-mcp[/]")
|
|
22
|
+
else:
|
|
23
|
+
ui.console.print("[bold green]✓[/] obsidian-mcp found on PATH.")
|
|
24
|
+
return found
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def check_vault_connection(vault_path: str):
|
|
28
|
+
server_params = build_server_params(vault_path)
|
|
29
|
+
ui.console.print(f"[dim]Connecting to vault:[/] {vault_path}")
|
|
30
|
+
|
|
31
|
+
# Same stderr redirect as loop.py — keep obsidian-mcp's own log
|
|
32
|
+
# lines from cluttering the doctor output.
|
|
33
|
+
with open(os.devnull, "w") as devnull:
|
|
34
|
+
async with stdio_client(server_params, errlog=devnull) as (read_stream, write_stream):
|
|
35
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
36
|
+
await session.initialize()
|
|
37
|
+
|
|
38
|
+
tools_result = await session.list_tools()
|
|
39
|
+
ui.console.print(f"[bold green]✓[/] Connected. {len(tools_result.tools)} tools available:")
|
|
40
|
+
for tool in tools_result.tools:
|
|
41
|
+
desc = (tool.description or "").strip().splitlines()
|
|
42
|
+
desc = desc[0] if desc else ""
|
|
43
|
+
ui.console.print(f" [cyan]{tool.name}[/] [dim]{desc}[/]")
|
|
44
|
+
|
|
45
|
+
ui.console.print("\n[dim]Listing notes in vault root...[/]")
|
|
46
|
+
result = await session.call_tool("list_notes_tool", arguments={"recursive": True})
|
|
47
|
+
content = result.content[0].text if result.content else "No data"
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(content)
|
|
50
|
+
items = data.get("items", [])
|
|
51
|
+
ui.console.print(f"[bold green]✓[/] Found {data.get('total', len(items))} notes. First 10:")
|
|
52
|
+
for note in items[:10]:
|
|
53
|
+
ui.console.print(f" [dim]-[/] {note.get('path')}")
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
ui.console.print("[yellow]Raw response:[/]", content)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_doctor(config: dict):
|
|
59
|
+
ui.console.rule("[bold cyan]obsidian-agent doctor")
|
|
60
|
+
if not check_obsidian_mcp_installed():
|
|
61
|
+
raise SystemExit(1)
|
|
62
|
+
try:
|
|
63
|
+
asyncio.run(check_vault_connection(config["vault_path"]))
|
|
64
|
+
except Exception as e:
|
|
65
|
+
ui.print_error(f"Could not connect to vault: {e}")
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
ui.console.print("\n[bold green]All checks passed.[/]")
|
obsidian_agent/loop.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The agentic chat loop: keeps calling tools until the model responds
|
|
2
|
+
with plain text instead of another tool call, instead of stopping after
|
|
3
|
+
one round. Now with streaming output and slash commands."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
|
|
9
|
+
from mcp import ClientSession
|
|
10
|
+
from mcp.client.stdio import stdio_client
|
|
11
|
+
|
|
12
|
+
from . import ui
|
|
13
|
+
from .core import (
|
|
14
|
+
SYSTEM_PROMPT,
|
|
15
|
+
build_llm_client,
|
|
16
|
+
build_server_params,
|
|
17
|
+
convert_mcp_tool_to_openai,
|
|
18
|
+
execute_tool_call,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MAX_STEPS = 8
|
|
22
|
+
EXTRA_HEADERS = {
|
|
23
|
+
"HTTP-Referer": "http://localhost:8080",
|
|
24
|
+
"X-Title": "Obsidian Agent",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def run_turn(client_llm, model, messages, tools, session, max_steps=MAX_STEPS):
|
|
29
|
+
"""Keep calling tools, feeding results back to the model, until it
|
|
30
|
+
responds with plain text instead of another tool call, or the step
|
|
31
|
+
budget runs out."""
|
|
32
|
+
for _ in range(max_steps):
|
|
33
|
+
with ui.thinking_spinner():
|
|
34
|
+
response = client_llm.chat.completions.create(
|
|
35
|
+
model=model,
|
|
36
|
+
messages=messages,
|
|
37
|
+
tools=tools,
|
|
38
|
+
tool_choice="auto",
|
|
39
|
+
temperature=0.7,
|
|
40
|
+
extra_headers=EXTRA_HEADERS,
|
|
41
|
+
)
|
|
42
|
+
msg = response.choices[0].message
|
|
43
|
+
messages.append(msg.to_dict())
|
|
44
|
+
|
|
45
|
+
if not msg.tool_calls:
|
|
46
|
+
return msg.content
|
|
47
|
+
|
|
48
|
+
for tool_call in msg.tool_calls:
|
|
49
|
+
try:
|
|
50
|
+
args = json.loads(tool_call.function.arguments)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
result_text = "Error: malformed tool arguments"
|
|
53
|
+
args = {}
|
|
54
|
+
else:
|
|
55
|
+
ui.print_tool_call(tool_call.function.name, args)
|
|
56
|
+
result_text = await execute_tool_call(
|
|
57
|
+
tool_call.function.name, args, session
|
|
58
|
+
)
|
|
59
|
+
messages.append(
|
|
60
|
+
{
|
|
61
|
+
"role": "tool",
|
|
62
|
+
"tool_call_id": tool_call.id,
|
|
63
|
+
"content": result_text,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return "Stopped after too many steps — the task may be more complex than expected."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def handle_slash_command(command: str, messages: list, tools: list, system_prompt: str) -> bool:
|
|
71
|
+
"""Returns True if the input was a slash command (and was handled)."""
|
|
72
|
+
if command == "/help":
|
|
73
|
+
ui.print_help()
|
|
74
|
+
return True
|
|
75
|
+
if command == "/tools":
|
|
76
|
+
ui.print_tools(tools)
|
|
77
|
+
return True
|
|
78
|
+
if command == "/clear":
|
|
79
|
+
messages.clear()
|
|
80
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
81
|
+
ui.console.print("[dim]Conversation cleared.[/]\n")
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def main_loop(config: dict):
|
|
87
|
+
vault_path = config["vault_path"]
|
|
88
|
+
model = config["model"]
|
|
89
|
+
api_key = config["openrouter_api_key"]
|
|
90
|
+
|
|
91
|
+
client_llm = build_llm_client(api_key)
|
|
92
|
+
server_params = build_server_params(vault_path)
|
|
93
|
+
|
|
94
|
+
# AsyncExitStack keeps the stdio subprocess and MCP session alive for
|
|
95
|
+
# the whole chat loop. Returning a session from inside a narrower
|
|
96
|
+
# "async with" block would close the connection the moment that
|
|
97
|
+
# function returns — every tool call after that would silently fail.
|
|
98
|
+
async with AsyncExitStack() as stack:
|
|
99
|
+
with ui.console.status("[dim]starting obsidian-mcp...[/]"):
|
|
100
|
+
# Redirect the subprocess's stderr to devnull so its own log
|
|
101
|
+
# lines (fastmcp INFO logs, authlib deprecation warnings)
|
|
102
|
+
# don't interleave with our Rich output. A plain sync file
|
|
103
|
+
# object is fine here — errlog just needs to be file-like.
|
|
104
|
+
devnull = stack.enter_context(open(os.devnull, "w"))
|
|
105
|
+
read_stream, write_stream = await stack.enter_async_context(
|
|
106
|
+
stdio_client(server_params, errlog=devnull)
|
|
107
|
+
)
|
|
108
|
+
session = await stack.enter_async_context(
|
|
109
|
+
ClientSession(read_stream, write_stream)
|
|
110
|
+
)
|
|
111
|
+
await session.initialize()
|
|
112
|
+
tools_result = await session.list_tools()
|
|
113
|
+
|
|
114
|
+
if not tools_result.tools:
|
|
115
|
+
ui.print_error("No tools available from obsidian-mcp. Exiting.")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
openai_tools = [convert_mcp_tool_to_openai(t) for t in tools_result.tools]
|
|
119
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
120
|
+
|
|
121
|
+
ui.print_banner(vault_path, model)
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
ui.print_turn_separator()
|
|
126
|
+
user_input = ui.prompt_user()
|
|
127
|
+
except (EOFError, KeyboardInterrupt):
|
|
128
|
+
ui.console.print("\n[dim]Goodbye.[/]")
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
stripped = user_input.strip()
|
|
132
|
+
if not stripped:
|
|
133
|
+
continue
|
|
134
|
+
if stripped.lower() in ("exit", "quit", "/exit", "/quit"):
|
|
135
|
+
ui.console.print("[dim]Goodbye.[/]")
|
|
136
|
+
break
|
|
137
|
+
if stripped.startswith("/"):
|
|
138
|
+
if handle_slash_command(stripped, messages, openai_tools, SYSTEM_PROMPT):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
messages.append({"role": "user", "content": user_input})
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
reply = await run_turn(client_llm, model, messages, openai_tools, session)
|
|
145
|
+
messages.append({"role": "assistant", "content": reply})
|
|
146
|
+
ui.print_agent_reply(reply)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
ui.print_error(str(e))
|
obsidian_agent/ui.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Terminal presentation layer: banner, spinners, markdown rendering,
|
|
2
|
+
colored tool-call output. Kept separate from the agent logic so the
|
|
3
|
+
core loop doesn't need to know or care how things are displayed."""
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.spinner import Spinner
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.rule import Rule
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
BANNER = r"""
|
|
16
|
+
[bold cyan] ██████╗ ██████╗ ███████╗██╗██████╗ ██╗ █████╗ ███╗ ██╗[/]
|
|
17
|
+
[bold cyan]██╔═══██╗██╔══██╗██╔════╝██║██╔══██╗██║██╔══██╗████╗ ██║[/]
|
|
18
|
+
[bold cyan]██║ ██║██████╔╝███████╗██║██║ ██║██║███████║██╔██╗ ██║[/]
|
|
19
|
+
[bold cyan]██║ ██║██╔══██╗╚════██║██║██║ ██║██║██╔══██║██║╚██╗██║[/]
|
|
20
|
+
[bold cyan]╚██████╔╝██████╔╝███████║██║██████╔╝██║██║ ██║██║ ╚████║[/]
|
|
21
|
+
[bold cyan] ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝[/]
|
|
22
|
+
[dim] a g e n t[/]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_banner(vault_path: str, model: str):
|
|
27
|
+
console.print(BANNER)
|
|
28
|
+
console.print(f"[dim]vault:[/] {vault_path}")
|
|
29
|
+
console.print(f"[dim]model:[/] {model}")
|
|
30
|
+
console.print("[dim]Type a message, or /help for commands.[/]\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def print_help():
|
|
34
|
+
console.print(
|
|
35
|
+
Panel.fit(
|
|
36
|
+
"[bold]/help[/] show this message\n"
|
|
37
|
+
"[bold]/tools[/] list available vault tools\n"
|
|
38
|
+
"[bold]/clear[/] clear the conversation history\n"
|
|
39
|
+
"[bold]/exit[/] quit [dim](also: exit, quit, Ctrl+D)[/]",
|
|
40
|
+
title="commands",
|
|
41
|
+
border_style="cyan",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_tools(tools: list):
|
|
47
|
+
lines = []
|
|
48
|
+
for t in tools:
|
|
49
|
+
name = t["function"]["name"]
|
|
50
|
+
desc = (t["function"]["description"] or "").strip().splitlines()
|
|
51
|
+
desc = desc[0] if desc else ""
|
|
52
|
+
lines.append(f"[bold cyan]{name}[/] [dim]{desc}[/]")
|
|
53
|
+
console.print(Panel.fit("\n".join(lines), title=f"{len(tools)} tools available", border_style="cyan"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_tool_call(name: str, arguments: dict):
|
|
57
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in arguments.items())
|
|
58
|
+
console.print(f" [dim]→ using[/] [bold yellow]{name}[/]([dim]{args_str}[/])")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_agent_reply(text: str):
|
|
62
|
+
console.print()
|
|
63
|
+
console.print("[bold magenta]◆ agent[/]")
|
|
64
|
+
console.print(Markdown(text or "*(no response)*"), style="")
|
|
65
|
+
console.print()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def print_error(message: str):
|
|
69
|
+
console.print(f"[bold red]Error:[/] {message}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def thinking_spinner():
|
|
73
|
+
"""Returns a context-manager Live spinner; use with `with thinking_spinner():`."""
|
|
74
|
+
return Live(Spinner("dots", text=Text(" thinking...", style="dim")), console=console, refresh_per_second=10, transient=True)
|
|
75
|
+
|
|
76
|
+
def print_turn_separator():
|
|
77
|
+
console.print(Rule(style="dim"))
|
|
78
|
+
|
|
79
|
+
def prompt_user() -> str:
|
|
80
|
+
return console.input("[bold cyan]❯[/] ")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: obsidian-agent-cli-app
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A conversational AI agent for your Obsidian vault, connected via MCP.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENCE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fastmcp==2.8.1
|
|
9
|
+
Requires-Dist: mcp>=1.0.0
|
|
10
|
+
Requires-Dist: obsidian-mcp>=2.1.6
|
|
11
|
+
Requires-Dist: openai>=1.0.0
|
|
12
|
+
Requires-Dist: pydantic<2.12,>=2.0
|
|
13
|
+
Requires-Dist: rich>=13.0.0
|
|
14
|
+
Requires-Dist: typer>=0.12.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# obsidian-agent
|
|
18
|
+
|
|
19
|
+
A conversational AI agent for your Obsidian vault. Ask it to find notes,
|
|
20
|
+
summarize what you wrote last week, fix broken links, tag things, or
|
|
21
|
+
edit a note — it figures out which vault operations to run and does it,
|
|
22
|
+
chaining multiple steps together when needed.
|
|
23
|
+
|
|
24
|
+
Built on [MCP](https://modelcontextprotocol.io) (the same protocol Claude
|
|
25
|
+
Desktop uses for tool access) and [OpenRouter](https://openrouter.ai) for
|
|
26
|
+
the LLM, so you can point it at whichever model you like.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.11+
|
|
31
|
+
- An [OpenRouter](https://openrouter.ai/keys) API key (free tier available)
|
|
32
|
+
- An Obsidian vault on your local filesystem
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install git+https://github.com/ZedoMak/obsidian-agent.git
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This pulls in `obsidian-mcp` (the vault-access server) automatically —
|
|
41
|
+
no separate install step needed.
|
|
42
|
+
|
|
43
|
+
## First run
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
obsidian-agent
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
You'll be asked three things once, and never again:
|
|
50
|
+
|
|
51
|
+
- your OpenRouter API key
|
|
52
|
+
- the absolute path to your vault
|
|
53
|
+
- which model to use (defaults to a free-tier model if you're not sure)
|
|
54
|
+
|
|
55
|
+
This gets saved to `~/.config/obsidian-agent/config.toml`. Change it later
|
|
56
|
+
with `obsidian-agent config --reset`, or override any single value with an
|
|
57
|
+
environment variable: `OPENROUTER_API_KEY`, `OBSIDIAN_VAULT_PATH`, `AGENT_MODEL`.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
obsidian-agent # start chatting with your vault
|
|
63
|
+
obsidian-agent doctor # troubleshooting: checks obsidian-mcp is installed
|
|
64
|
+
# and can actually reach your vault
|
|
65
|
+
obsidian-agent config # view current config
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Inside a chat session:
|
|
69
|
+
|
|
70
|
+
| Command | Does |
|
|
71
|
+
| -------- | --------------------------------------------- |
|
|
72
|
+
| `/help` | list commands |
|
|
73
|
+
| `/tools` | list every vault operation the agent can call |
|
|
74
|
+
| `/clear` | wipe conversation history, start fresh |
|
|
75
|
+
| `/exit` | quit (also: `exit`, `quit`, Ctrl+D) |
|
|
76
|
+
|
|
77
|
+
## What it can actually do
|
|
78
|
+
|
|
79
|
+
Anything the underlying `obsidian-mcp` server exposes — reading, creating,
|
|
80
|
+
editing, and deleting notes; searching by text, tag, date, or regex;
|
|
81
|
+
managing tags; finding backlinks and broken links; listing orphaned notes.
|
|
82
|
+
Run `/tools` inside a session to see the live list for your installed version.
|
|
83
|
+
|
|
84
|
+
The agent always reads a note before editing it, so it won't blindly
|
|
85
|
+
overwrite content — this is a system-level instruction, not something you
|
|
86
|
+
need to ask for each time.
|
|
87
|
+
|
|
88
|
+
## Troubleshooting
|
|
89
|
+
|
|
90
|
+
**`obsidian-mcp was not found on your PATH`**
|
|
91
|
+
Run `obsidian-agent doctor` — it'll tell you exactly what's missing and how
|
|
92
|
+
to fix it.
|
|
93
|
+
|
|
94
|
+
**Something else broke**
|
|
95
|
+
This project pins a few dependency versions deliberately
|
|
96
|
+
(`fastmcp==2.8.1`, `pydantic<2.12`) because of real breaking changes
|
|
97
|
+
upstream. If you're hacking on the source directly rather than using
|
|
98
|
+
`pip install`, don't loosen those pins without checking `obsidian-agent doctor`
|
|
99
|
+
still passes afterward.
|
|
100
|
+
|
|
101
|
+
## Contributing
|
|
102
|
+
|
|
103
|
+
Issues and PRs welcome. This is a young project — expect rough edges.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
obsidian_agent/__init__.py,sha256=Pru0BlFBASFCFo7McHdohtKkUtgMPDwbGfyUZlE2_Vw,21
|
|
2
|
+
obsidian_agent/cli.py,sha256=IqyJtBUWFMQNWrNQcwmdU4okaM_V914bitZoxye3JkQ,1356
|
|
3
|
+
obsidian_agent/config.py,sha256=H0iqS1rvqKLZkDOPWUh_I8zyLiHMG72k_Moeqopg6As,2016
|
|
4
|
+
obsidian_agent/core.py,sha256=EnG8sgCAAMv9ZmSgq0caBbkK_dBZqy7pV8NTOHJsw_0,1713
|
|
5
|
+
obsidian_agent/doctor.py,sha256=qH4sTBs5h0Rm89UK5wPvDCsjGkTAR4Pg3KCTFvTxOgY,2856
|
|
6
|
+
obsidian_agent/loop.py,sha256=KMIatOJ2WzpJHiAo3XvlegXVksm41J0PUEXFJWbzu4A,5378
|
|
7
|
+
obsidian_agent/ui.py,sha256=ELMfuG4IlT0StN4jCziZJibbJuDw4oK8KIpRYlCd-NI,3422
|
|
8
|
+
obsidian_agent_cli_app-0.1.0.dist-info/METADATA,sha256=NMnAq83pkgOiofFttQr6qqtjEj-nxNrNlQMsP8gm7a4,3525
|
|
9
|
+
obsidian_agent_cli_app-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
obsidian_agent_cli_app-0.1.0.dist-info/entry_points.txt,sha256=OGzPSLDZvRQGVmdqu9RJtD2wUvB7QdKgdZsdCNSYReg,58
|
|
11
|
+
obsidian_agent_cli_app-0.1.0.dist-info/licenses/LICENCE,sha256=hZMBcHPqgQ9qHgCSKl9_HKpzVpV_HRS8D-G05n9PbP8,1064
|
|
12
|
+
obsidian_agent_cli_app-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZedoMak
|
|
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.
|