obsidian-agent-cli 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.
Files changed (40) hide show
  1. obsidian_agent_cli-0.1.0.dist-info/METADATA +225 -0
  2. obsidian_agent_cli-0.1.0.dist-info/RECORD +40 -0
  3. obsidian_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  4. obsidian_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. obsidian_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
  6. obsidian_cli/__init__.py +0 -0
  7. obsidian_cli/canvas_builder.py +127 -0
  8. obsidian_cli/commands/__init__.py +0 -0
  9. obsidian_cli/commands/active.py +74 -0
  10. obsidian_cli/commands/batch_cmd.py +91 -0
  11. obsidian_cli/commands/canvas.py +123 -0
  12. obsidian_cli/commands/config_cmd.py +57 -0
  13. obsidian_cli/commands/core_cmd.py +83 -0
  14. obsidian_cli/commands/excalidraw.py +146 -0
  15. obsidian_cli/commands/export_cmd.py +40 -0
  16. obsidian_cli/commands/git_cmd.py +112 -0
  17. obsidian_cli/commands/kanban.py +113 -0
  18. obsidian_cli/commands/lint_cmd.py +26 -0
  19. obsidian_cli/commands/meta_cmd.py +97 -0
  20. obsidian_cli/commands/mover_cmd.py +78 -0
  21. obsidian_cli/commands/note.py +178 -0
  22. obsidian_cli/commands/periodic.py +127 -0
  23. obsidian_cli/commands/quickadd_cmd.py +76 -0
  24. obsidian_cli/commands/refactor_cmd.py +81 -0
  25. obsidian_cli/commands/run.py +26 -0
  26. obsidian_cli/commands/search.py +44 -0
  27. obsidian_cli/commands/status_cmd.py +13 -0
  28. obsidian_cli/commands/tags_cmd.py +22 -0
  29. obsidian_cli/commands/tasks_cmd.py +125 -0
  30. obsidian_cli/commands/teach.py +43 -0
  31. obsidian_cli/commands/template_cmd.py +45 -0
  32. obsidian_cli/commands/uri.py +59 -0
  33. obsidian_cli/commands/vault_cmd.py +40 -0
  34. obsidian_cli/commands/workspace_cmd.py +67 -0
  35. obsidian_cli/config.py +40 -0
  36. obsidian_cli/excalidraw_builder.py +210 -0
  37. obsidian_cli/kanban_builder.py +60 -0
  38. obsidian_cli/main.py +60 -0
  39. obsidian_cli/registry.py +98 -0
  40. obsidian_cli/transport.py +479 -0
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: obsidian-agent-cli
3
+ Version: 0.1.0
4
+ Summary: Full-featured CLI for Obsidian — manage notes, canvases, Excalidraw, Kanban, periodic notes, git, tasks, and more.
5
+ Author-email: ProxyLand <proxylandllc@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: requests>=2.31
11
+ Requires-Dist: python-dotenv>=1.0
12
+
13
+ # obsidian-agent-cli
14
+
15
+ A full-featured command-line interface for [Obsidian](https://obsidian.md/) — manage your vault, capture knowledge, build canvases, and run plugin commands, all from the terminal or from an AI agent.
16
+
17
+ Works with any AI coding assistant (Claude Code, Codex, Gemini CLI, etc.) via the included `obsidian_skill.md`.
18
+
19
+ ---
20
+
21
+ ## How It Works: Vault Structure
22
+
23
+ The CLI is designed around a clean separation of concerns inside your vault:
24
+
25
+ ```
26
+ MyVault/ ← your Obsidian vault root
27
+ ├── .obsidian/
28
+
29
+ ├── AI Workspace/ ← the AI agent's dedicated folder (managed by this CLI)
30
+ │ ├── registry.json # project registry
31
+ │ ├── my-project/ # per-project knowledge
32
+ │ │ └── knowledge-log.md
33
+ │ └── Teaching Notes/ # reference notes written by the agent
34
+
35
+ ├── Your Notes/ ← your folders — the AI never touches these
36
+ ├── Projects/ ← yours
37
+ └── Journal/ ← yours
38
+ ```
39
+
40
+ **The AI only operates inside `AI Workspace/`.** Every note, canvas, log, and registry file it creates lives there. Your own folders are completely untouched unless you explicitly pass their paths to a command.
41
+
42
+ > 💡 **Recommended setup:** Create a dedicated Obsidian vault just for this — don't drop it into an existing vault you already use heavily. A fresh vault keeps things clean. Then `AI Workspace/` is one subfolder inside it, and any other top-level folders you create are entirely yours.
43
+
44
+ The `AI Workspace` folder name is just the default — rename it to anything you like:
45
+ ```bash
46
+ obsidian config set workspace_path "My AI Zone"
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Features
52
+
53
+ | Command group | What it does |
54
+ |---------------|-------------|
55
+ | `note` | Create, read, update, patch, delete notes — including surgical heading-level edits |
56
+ | `search` | Full-text search and Dataview DQL queries |
57
+ | `active` | Read/write the note currently open in Obsidian |
58
+ | `periodic` | Daily, weekly, monthly notes — read, write, patch, navigate |
59
+ | `workspace` | AI workspace project registry and knowledge log |
60
+ | `canvas` | Build and manage Obsidian canvas files from JSON specs |
61
+ | `excalidraw` | Build Excalidraw diagrams from JSON specs |
62
+ | `kanban` | Create and manage Kanban boards |
63
+ | `vault` | Find notes by glob pattern, move/rename files |
64
+ | `tags` | Rename tags across the entire vault |
65
+ | `batch` | Bulk frontmatter edits and find-and-replace |
66
+ | `export` | Bundle notes into a single markdown file |
67
+ | `teach` | Write reference/teaching notes into the vault |
68
+ | `git` | Obsidian Git plugin commands |
69
+ | `tasks` | Tasks plugin — add and list tasks with emoji markers |
70
+ | `template` | Templater plugin commands |
71
+ | `lint` | Obsidian Linter commands |
72
+ | `quickadd` | QuickAdd plugin — open modal or run a choice |
73
+ | `refactor` | Rename, extract headings, merge notes |
74
+ | `mover` | Auto Note Mover — check and trigger rules |
75
+ | `meta` | Metadata Menu — get/set frontmatter fields |
76
+ | `core` | Core Obsidian UI — palette, graph, settings, splits |
77
+ | `uri` | Obsidian URI scheme — works without Obsidian open |
78
+ | `status` | REST API health check |
79
+ | `config` | View and update CLI configuration |
80
+ | `commands` | List and run any Obsidian command by ID |
81
+
82
+ **All commands output JSON.** Every command returns a single JSON object — parse it, pipe it, or feed it to an AI agent.
83
+
84
+ **Dual-mode transport.** Commands try the Obsidian REST API first. If Obsidian isn't running, most commands fall back to the vault filesystem directly.
85
+
86
+ ---
87
+
88
+ ## Requirements
89
+
90
+ - Python 3.10+
91
+ - [Obsidian](https://obsidian.md/) desktop app
92
+ - [Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) community plugin (installed and enabled inside Obsidian)
93
+
94
+ ---
95
+
96
+ ## Installation
97
+
98
+ ```bash
99
+ pip install obsidian-agent-cli
100
+ ```
101
+
102
+ Then run the setup wizard:
103
+
104
+ ```bash
105
+ obsidian config init
106
+ ```
107
+
108
+ You'll be prompted for:
109
+ - Your vault path (e.g. `/home/yourname/Documents/MyVault`)
110
+ - Your workspace folder name (default: `AI Workspace`)
111
+ - The REST API URL (default: `http://127.0.0.1:27123`)
112
+ - Your Local REST API key (copy from Obsidian → Settings → Local REST API)
113
+
114
+ ### Verify it works
115
+
116
+ With Obsidian open:
117
+ ```bash
118
+ obsidian status
119
+ ```
120
+
121
+ You should see:
122
+ ```json
123
+ {"ok": true, "versions": {"obsidian": "1.x.x"}, "transport": "api"}
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Quick Start
129
+
130
+ ```bash
131
+ # Create a note
132
+ obsidian note create "Projects/my-app/arch.md" --content "# Architecture"
133
+
134
+ # Read it back
135
+ obsidian note read "Projects/my-app/arch.md"
136
+
137
+ # Search across vault
138
+ obsidian search simple "authentication"
139
+
140
+ # Register a project for AI knowledge tracking
141
+ obsidian workspace project register my-app
142
+
143
+ # Log a discovery
144
+ obsidian workspace log my-app "Auth uses RS256 JWTs. Token expiry is 15 min."
145
+
146
+ # Read back everything the agent knows about this project
147
+ obsidian workspace recall my-app
148
+
149
+ # Build a canvas overview
150
+ obsidian canvas build "my-app-overview" --spec '{
151
+ "nodes": [
152
+ {"type": "text", "text": "# My App"},
153
+ {"type": "file", "file": "Projects/my-app/arch.md"}
154
+ ],
155
+ "edges": [
156
+ {"from": 0, "to": 1, "label": "documented in", "color": "4"}
157
+ ]
158
+ }'
159
+
160
+ # Sync vault with git
161
+ obsidian git sync -m "feat: update project notes"
162
+ ```
163
+
164
+ ---
165
+
166
+ ## AI Agent Integration
167
+
168
+ Drop `obsidian_skill.md` into any project root. Your AI agent (Claude Code, Codex, Gemini CLI, etc.) will automatically:
169
+
170
+ 1. **Register new projects** on first encounter
171
+ 2. **Recall prior knowledge** at the start of every session
172
+ 3. **Log architectural discoveries** and decisions as they happen
173
+ 4. **Build visual canvases** and **write teaching notes** when asked
174
+
175
+ See [docs/SKILL_SETUP.md](docs/SKILL_SETUP.md) for details.
176
+
177
+ ---
178
+
179
+ ## Configuration
180
+
181
+ Config is stored in `~/.obsidian-cli/config.json`. You can edit it with `obsidian config`:
182
+
183
+ ```bash
184
+ obsidian config show # view current config
185
+ obsidian config set vault_path /path/to/vault # update vault path
186
+ obsidian config set workspace_path "MyWorkspace" # rename workspace folder
187
+ obsidian config set api_key YOUR_KEY # update API key
188
+ obsidian config init # re-run setup wizard
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Documentation
194
+
195
+ - [Installation Guide](docs/INSTALL.md)
196
+ - [Usage Guide](docs/USAGE.md)
197
+ - [AI Skill Setup](docs/SKILL_SETUP.md)
198
+
199
+ ---
200
+
201
+ ## Plugin Commands
202
+
203
+ Many command groups require optional Obsidian plugins:
204
+
205
+ | Plugin | Required by |
206
+ |--------|------------|
207
+ | [Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) | Everything (core dependency) |
208
+ | [Dataview](https://github.com/blacksmithgu/obsidian-dataview) | `search advanced`, `note backlinks`, `note by-tag`, `note recent`, `note orphans`, `note links` |
209
+ | [Obsidian Git](https://github.com/denolehov/obsidian-git) | `git` commands |
210
+ | [Tasks](https://github.com/obsidian-tasks-group/obsidian-tasks) | `tasks ui`, `tasks toggle` |
211
+ | [Templater](https://github.com/SilentVoid13/Templater) | `template` commands |
212
+ | [Linter](https://github.com/platers/obsidian-linter) | `lint` commands |
213
+ | [QuickAdd](https://github.com/chhoumann/quickadd) | `quickadd` commands |
214
+ | [Auto Note Mover](https://github.com/farux/obsidian-auto-note-mover) | `mover` commands |
215
+ | [Metadata Menu](https://github.com/mdelobelle/metadatamenu) | `meta` commands |
216
+ | [Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) | `excalidraw` commands |
217
+ | [Kanban](https://github.com/mgmeyers/obsidian-kanban) | `kanban archive` |
218
+
219
+ Commands that don't require a plugin work directly on the filesystem and don't need Obsidian to be open.
220
+
221
+ ---
222
+
223
+ ## License
224
+
225
+ MIT
@@ -0,0 +1,40 @@
1
+ obsidian_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ obsidian_cli/canvas_builder.py,sha256=-miff5IgiBVlwRlxSeRxuwIOzv_Q5tTeRqVwr9CgZDs,4893
3
+ obsidian_cli/config.py,sha256=Rtcamg6sNSqmm9AAmLdfKT_QI4Cv88WcpyvsF73lgZw,1295
4
+ obsidian_cli/excalidraw_builder.py,sha256=dwKhEBWaafRdNyFhtmJEfR5VdsOmsZFIvvOweg6Qb4I,7521
5
+ obsidian_cli/kanban_builder.py,sha256=pEtDdrS2AuPmJeky1mYxGzYSpGhCGIJrFgd7A_tlp0w,2175
6
+ obsidian_cli/main.py,sha256=V4evkeg03WLhxtb0pnVB1RdUpy_7KncrI4xKb0gG1l0,1847
7
+ obsidian_cli/registry.py,sha256=v-Ab4SWgugU5uDpbgpB-iUKetj_roprMaGbmm-3QHsM,3965
8
+ obsidian_cli/transport.py,sha256=nIawcdZ0lld6jvAe2fSQhainSZrhbLnAKSIChk8IA30,21178
9
+ obsidian_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ obsidian_cli/commands/active.py,sha256=hgsUluF9hOLBD1G-g0V0CvFWr5NmPhX5mbX9eOCkkec,2135
11
+ obsidian_cli/commands/batch_cmd.py,sha256=QgVzQismwidjtEpJ-n_ZKb5fi9Q1M6ZfJegkd5O4eHk,3184
12
+ obsidian_cli/commands/canvas.py,sha256=ac6miYVPK5zFRjOeS6nGcqMlsx85eo6wqncI6_RnpbE,4353
13
+ obsidian_cli/commands/config_cmd.py,sha256=9FuuHwVJFq8z_KUj6JdcY4YvbmzQs4d9VgkaTbCPvN4,1880
14
+ obsidian_cli/commands/core_cmd.py,sha256=Yu-8hN6oITuECPdXGXCuUIlpQN1feaBUGln7l5Gaj_8,2128
15
+ obsidian_cli/commands/excalidraw.py,sha256=_WNrimXyC7w73g1w9sVdUUqAkrcJilnrYZKGvaSZCMk,5152
16
+ obsidian_cli/commands/export_cmd.py,sha256=CCKcGAHi4AFbQV6pxiTI4kv48ZG-48PsbwYivpO8tgY,1481
17
+ obsidian_cli/commands/git_cmd.py,sha256=iHP-kEYSCFsSR0J8ZTW9HB3hs8rwjWjAh0cvDBEvUC4,2975
18
+ obsidian_cli/commands/kanban.py,sha256=17Y2TAhqnn82URX2R1MYXNqgmBSuNGK3SSnBvfvEvCE,3061
19
+ obsidian_cli/commands/lint_cmd.py,sha256=ebt1HvSAtxknJ4ojC1LEnl-T7UJhpc_T08NDkVPkArQ,966
20
+ obsidian_cli/commands/meta_cmd.py,sha256=jpFQoeGfMVUArNEECaa8E5QNdqcxqpnecCqkqtKulms,3243
21
+ obsidian_cli/commands/mover_cmd.py,sha256=JDEPhEAS35GGlPlWQgnGvuS9oOGbxKONvpfN28eopZc,2426
22
+ obsidian_cli/commands/note.py,sha256=WzIP5Sm2mKXeOkITnX7w71k-U3ytn63Y-aOhE4JmxII,5450
23
+ obsidian_cli/commands/periodic.py,sha256=d4-qnSNWAmpU_5NdVZVoVETzvvvhoJ-6uHRwbx7Kx9k,4293
24
+ obsidian_cli/commands/quickadd_cmd.py,sha256=Fdh4m68mTGROEQLPlhGrj_3h7HUTRK4lV8GhwerMpjU,2507
25
+ obsidian_cli/commands/refactor_cmd.py,sha256=9Z8D1m5_C-WkPE3SqMd92YZcfWM_7yHZX1v87e8t4uk,2684
26
+ obsidian_cli/commands/run.py,sha256=rKHdsHn9V6bGpZTwtV2CvEt6NLKW4qBHmbiN9cQKBfg,540
27
+ obsidian_cli/commands/search.py,sha256=Ou_YOPmac6mNvF_jGUEVzQbhpN6je3nRn9kBrZN3oHE,923
28
+ obsidian_cli/commands/status_cmd.py,sha256=xaOlPtyPhLCAv0L7xnA5BpkmKZLuLvqijXGPtiZ8scY,321
29
+ obsidian_cli/commands/tags_cmd.py,sha256=8XPPWNr6wRxFfErMVyL4ndix7O1-x1E_TMrbkWupncY,641
30
+ obsidian_cli/commands/tasks_cmd.py,sha256=SniqchZPEqdRjau7UixdzFq26MqJ2Mfs37E8taupDVo,4504
31
+ obsidian_cli/commands/teach.py,sha256=3E6exT8TWBOHIguz4EkaLhE5hAME2OVBE6dsFvsJ260,1200
32
+ obsidian_cli/commands/template_cmd.py,sha256=fl4cu5zcQ5yUsZCLtQh8Tm53U0zMyWJ7f_mFx1Zh-c0,1248
33
+ obsidian_cli/commands/uri.py,sha256=G7YGYQDMqH2vFSku1YtrgcTzvVe1iiUlhXst6-_49rU,2080
34
+ obsidian_cli/commands/vault_cmd.py,sha256=o8_UJduvNcpAwtifuOjt1uHXGubEMvBgXvbmHAStY30,1199
35
+ obsidian_cli/commands/workspace_cmd.py,sha256=Yucns8_mryLs4ZthlrvJMMnSYoL5l_gMDLcmN0EkXXg,1383
36
+ obsidian_agent_cli-0.1.0.dist-info/METADATA,sha256=Blc5Hcquxflf0v2puIAncungZviIF_g8Zcv_d5sSAQo,8287
37
+ obsidian_agent_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
38
+ obsidian_agent_cli-0.1.0.dist-info/entry_points.txt,sha256=ccvrN5KUHV5lF2Pr2b6g7wvP5NUkSUrPo9hzUJUm7GA,51
39
+ obsidian_agent_cli-0.1.0.dist-info/top_level.txt,sha256=-RydWe8GVCQoTjjHwhLxteKqKfdnbZ1He0_jOP_wP10,13
40
+ obsidian_agent_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ obsidian = obsidian_cli.main:cli
@@ -0,0 +1 @@
1
+ obsidian_cli
File without changes
@@ -0,0 +1,127 @@
1
+ import json
2
+ import uuid
3
+
4
+
5
+ def _new_id() -> str:
6
+ return uuid.uuid4().hex[:16]
7
+
8
+
9
+ def _grid_position(index: int, columns: int = 3,
10
+ cell_w: int = 300, cell_h: int = 200,
11
+ gap: int = 50) -> tuple[int, int]:
12
+ col = index % columns
13
+ row = index // columns
14
+ return col * (cell_w + gap), row * (cell_h + gap)
15
+
16
+
17
+ class CanvasBuilder:
18
+ def __init__(self):
19
+ self._nodes: list[dict] = []
20
+ self._edges: list[dict] = []
21
+
22
+ def add_text_node(self, text: str, color: str = "",
23
+ width: int = 250, height: int = 100) -> str:
24
+ node_id = _new_id()
25
+ x, y = _grid_position(len(self._nodes))
26
+ node = {"id": node_id, "type": "text", "text": text,
27
+ "x": x, "y": y, "width": width, "height": height}
28
+ if color:
29
+ node["color"] = color
30
+ self._nodes.append(node)
31
+ return node_id
32
+
33
+ def add_file_node(self, file_path: str, subpath: str = "",
34
+ color: str = "", width: int = 400, height: int = 300) -> str:
35
+ node_id = _new_id()
36
+ x, y = _grid_position(len(self._nodes))
37
+ node = {"id": node_id, "type": "file", "file": file_path,
38
+ "x": x, "y": y, "width": width, "height": height}
39
+ if subpath:
40
+ node["subpath"] = subpath
41
+ if color:
42
+ node["color"] = color
43
+ self._nodes.append(node)
44
+ return node_id
45
+
46
+ def add_link_node(self, url: str, color: str = "",
47
+ width: int = 400, height: int = 300) -> str:
48
+ node_id = _new_id()
49
+ x, y = _grid_position(len(self._nodes))
50
+ node = {"id": node_id, "type": "link", "url": url,
51
+ "x": x, "y": y, "width": width, "height": height}
52
+ if color:
53
+ node["color"] = color
54
+ self._nodes.append(node)
55
+ return node_id
56
+
57
+ def add_group_node(self, label: str = "", background: str = "",
58
+ background_style: str = "cover",
59
+ color: str = "", width: int = 600, height: int = 400) -> str:
60
+ node_id = _new_id()
61
+ x, y = _grid_position(len(self._nodes))
62
+ node = {"id": node_id, "type": "group",
63
+ "x": x, "y": y, "width": width, "height": height}
64
+ if label:
65
+ node["label"] = label
66
+ if background:
67
+ node["background"] = background
68
+ node["backgroundStyle"] = background_style
69
+ if color:
70
+ node["color"] = color
71
+ self._nodes.append(node)
72
+ return node_id
73
+
74
+ def add_edge(self, from_id: str, to_id: str,
75
+ label: str = "", color: str = "",
76
+ from_side: str = "right", to_side: str = "left",
77
+ from_end: str = "none", to_end: str = "arrow") -> str:
78
+ edge_id = _new_id()
79
+ edge = {"id": edge_id, "fromNode": from_id, "toNode": to_id,
80
+ "fromSide": from_side, "toSide": to_side,
81
+ "fromEnd": from_end, "toEnd": to_end}
82
+ if label:
83
+ edge["label"] = label
84
+ if color:
85
+ edge["color"] = color
86
+ self._edges.append(edge)
87
+ return edge_id
88
+
89
+ def build(self) -> dict:
90
+ return {"nodes": list(self._nodes), "edges": list(self._edges)}
91
+
92
+ def to_json(self) -> str:
93
+ return json.dumps(self.build(), indent=2)
94
+
95
+ @classmethod
96
+ def from_spec(cls, spec: dict) -> "CanvasBuilder":
97
+ cb = cls()
98
+ index_to_id: dict[int, str] = {}
99
+ for i, node_spec in enumerate(spec.get("nodes", [])):
100
+ t = node_spec.get("type", "text")
101
+ color = node_spec.get("color", "")
102
+ if t == "text":
103
+ nid = cb.add_text_node(node_spec.get("text", ""), color=color)
104
+ elif t == "file":
105
+ nid = cb.add_file_node(node_spec.get("file", ""),
106
+ subpath=node_spec.get("subpath", ""),
107
+ color=color)
108
+ elif t == "link":
109
+ nid = cb.add_link_node(node_spec.get("url", ""), color=color)
110
+ elif t == "group":
111
+ nid = cb.add_group_node(
112
+ label=node_spec.get("label", ""),
113
+ background=node_spec.get("background", ""),
114
+ background_style=node_spec.get("backgroundStyle", "cover"),
115
+ color=color,
116
+ )
117
+ else:
118
+ nid = cb.add_text_node(str(node_spec), color=color)
119
+ index_to_id[i] = nid
120
+ for edge_spec in spec.get("edges", []):
121
+ from_id = index_to_id.get(edge_spec.get("from"))
122
+ to_id = index_to_id.get(edge_spec.get("to"))
123
+ if from_id and to_id:
124
+ cb.add_edge(from_id, to_id,
125
+ label=edge_spec.get("label", ""),
126
+ color=edge_spec.get("color", ""))
127
+ return cb
File without changes
@@ -0,0 +1,74 @@
1
+ import json
2
+ import click
3
+ from ..config import load_config
4
+ from ..transport import Transport
5
+
6
+
7
+ @click.group()
8
+ def active():
9
+ """Operations on the currently active note in Obsidian."""
10
+
11
+
12
+ @active.command()
13
+ def read():
14
+ """Read content of the active note."""
15
+ cfg = load_config()
16
+ t = Transport(cfg)
17
+ result = t.get_active()
18
+ click.echo(json.dumps(result))
19
+
20
+
21
+ @active.command()
22
+ def meta():
23
+ """Read frontmatter, tags, and stat of the active note."""
24
+ cfg = load_config()
25
+ t = Transport(cfg)
26
+ result = t.get_active_meta()
27
+ click.echo(json.dumps(result))
28
+
29
+
30
+ @active.command()
31
+ @click.argument("content")
32
+ def write(content):
33
+ """Overwrite the active note."""
34
+ cfg = load_config()
35
+ t = Transport(cfg)
36
+ result = t.write_active(content)
37
+ click.echo(json.dumps(result))
38
+
39
+
40
+ @active.command("append")
41
+ @click.argument("content")
42
+ def append_cmd(content):
43
+ """Append content to the active note."""
44
+ cfg = load_config()
45
+ t = Transport(cfg)
46
+ result = t.append_active(content)
47
+ click.echo(json.dumps(result))
48
+
49
+
50
+ @active.command("patch")
51
+ @click.argument("content")
52
+ @click.option("--operation", default="append", type=click.Choice(["append", "prepend", "replace"]))
53
+ @click.option("--target-type", default="heading", type=click.Choice(["heading", "block", "frontmatter"]))
54
+ @click.option("--target", required=True)
55
+ @click.option("--create-if-missing", is_flag=True, default=False)
56
+ @click.option("--apply-if-preexists", is_flag=True, default=False)
57
+ @click.option("--trim-whitespace", is_flag=True, default=False)
58
+ def patch_cmd(content, operation, target_type, target,
59
+ create_if_missing, apply_if_preexists, trim_whitespace):
60
+ """Patch a section of the active note."""
61
+ cfg = load_config()
62
+ t = Transport(cfg)
63
+ result = t.patch_active(content, operation, target_type, target,
64
+ create_if_missing, apply_if_preexists, trim_whitespace)
65
+ click.echo(json.dumps(result))
66
+
67
+
68
+ @active.command()
69
+ def delete():
70
+ """Delete the active note."""
71
+ cfg = load_config()
72
+ t = Transport(cfg)
73
+ result = t.delete_active()
74
+ click.echo(json.dumps(result))
@@ -0,0 +1,91 @@
1
+ import json
2
+ import re
3
+ from pathlib import Path
4
+ import click
5
+ from ..config import load_config
6
+ from ..transport import Transport
7
+
8
+
9
+ def _iter_notes(vault_path: str, folder: str = "", pattern: str = "**/*.md") -> list[str]:
10
+ vault = Path(vault_path)
11
+ base = vault / folder if folder else vault
12
+ return [
13
+ str(p.relative_to(vault)).replace("\\", "/")
14
+ for p in base.glob(pattern)
15
+ if ".obsidian" not in p.parts
16
+ ]
17
+
18
+
19
+ @click.group("batch")
20
+ def batch_cmd():
21
+ """Bulk operations across multiple notes."""
22
+
23
+
24
+ @batch_cmd.command("frontmatter")
25
+ @click.argument("field")
26
+ @click.argument("value")
27
+ @click.option("--folder", default="", help="Limit to this vault folder")
28
+ @click.option("--pattern", default="**/*.md", help="Glob pattern for notes")
29
+ @click.option("--dry-run", is_flag=True, default=False)
30
+ def batch_frontmatter(field, value, folder, pattern, dry_run):
31
+ """Set a frontmatter field to VALUE in all matching notes."""
32
+ cfg = load_config()
33
+ t = Transport(cfg)
34
+ notes = _iter_notes(cfg.vault_path, folder, pattern)
35
+ changed = []
36
+ errors = []
37
+ for note_path in notes:
38
+ if dry_run:
39
+ changed.append(note_path)
40
+ continue
41
+ result = t.patch_file(note_path, value, "replace", "frontmatter", field,
42
+ True, False, False)
43
+ if result.get("error"):
44
+ errors.append({"note": note_path, "message": result.get("message")})
45
+ else:
46
+ changed.append(note_path)
47
+ click.echo(json.dumps({
48
+ "field": field, "value": value,
49
+ "changed_count": len(changed), "changed": changed,
50
+ "error_count": len(errors), "errors": errors,
51
+ "dry_run": dry_run,
52
+ }))
53
+
54
+
55
+ @batch_cmd.command("rename")
56
+ @click.argument("search")
57
+ @click.argument("replacement")
58
+ @click.option("--folder", default="", help="Limit to this vault folder")
59
+ @click.option("--pattern", default="**/*.md")
60
+ @click.option("--regex", "use_regex", is_flag=True, default=False, help="Treat SEARCH as regex")
61
+ @click.option("--dry-run", is_flag=True, default=False)
62
+ def batch_rename(search, replacement, folder, pattern, use_regex, dry_run):
63
+ """Find-and-replace text across note contents."""
64
+ cfg = load_config()
65
+ vault = Path(cfg.vault_path)
66
+ notes = _iter_notes(cfg.vault_path, folder, pattern)
67
+ changed = []
68
+ skipped = []
69
+ re_search = re.compile(search) if use_regex else None
70
+ for rel in notes:
71
+ p = vault / rel
72
+ text = p.read_text(encoding="utf-8", errors="ignore")
73
+ if use_regex:
74
+ if not re_search.search(text):
75
+ skipped.append(rel)
76
+ continue
77
+ new_text = re_search.sub(replacement, text)
78
+ else:
79
+ if search not in text:
80
+ skipped.append(rel)
81
+ continue
82
+ new_text = text.replace(search, replacement)
83
+ changed.append(rel)
84
+ if not dry_run:
85
+ p.write_text(new_text, encoding="utf-8")
86
+ click.echo(json.dumps({
87
+ "search": search, "replacement": replacement,
88
+ "changed_count": len(changed), "changed": changed,
89
+ "skipped_count": len(skipped),
90
+ "dry_run": dry_run,
91
+ }))