flanner 0.4.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.
flanner/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ MCP Plan File Manager
3
+
4
+ A comprehensive plan file management system for Claude Code and AI assistants.
5
+ """
6
+
7
+ __version__ = "0.4.0"
flanner/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Main entry point for running the CLI as a module
3
+ """
4
+
5
+ from .cli import cli
6
+
7
+ if __name__ == "__main__":
8
+ cli()
flanner/agent_hooks.py ADDED
@@ -0,0 +1,226 @@
1
+ """Agent integration: the guard-write hook and init-time wiring.
2
+
3
+ Two cooperating layers steer coding agents toward the flanner MCP tools:
4
+
5
+ - a managed block in the repo's CLAUDE.md and AGENTS.md, plus a skill (soft:
6
+ guidance; AGENTS.md is the cross-tool convention Codex and others read)
7
+ - a PreToolUse hook that denies raw Writes into the plan directory (hard:
8
+ enforcement, Claude Code only). The MCP tools write through flanner's
9
+ storage layer, not the agent's Write tool, so the correct path is never
10
+ blocked.
11
+
12
+ `decide_write` is pure and takes a parsed payload + session so it can be
13
+ tested without stdin or a live hook.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from sqlalchemy.orm import Session
23
+
24
+ from .database import ProjectModel, get_project_by_root
25
+ from .git_integration import find_git_root
26
+
27
+ AGENT_MD_START = "<!-- flanner:managed -->"
28
+ AGENT_MD_END = "<!-- /flanner:managed -->"
29
+
30
+ # The hook entry flanner merges into a repo's .claude/settings.json.
31
+ HOOK_COMMAND = "flanner hook guard-write"
32
+ HOOK_MATCHER = "Write|Edit|MultiEdit"
33
+
34
+
35
+ def _resolve(file_path: str, cwd: str) -> Path:
36
+ """Absolute, normalized target path (file_path may be relative to cwd)."""
37
+ p = Path(file_path)
38
+ if not p.is_absolute():
39
+ p = Path(cwd) / p
40
+ return p.resolve()
41
+
42
+
43
+ def decide_write(payload: dict[str, Any], session: Session) -> dict[str, Any] | None:
44
+ """Return a PreToolUse deny decision, or None to allow the write.
45
+
46
+ Allows (returns None) unless all three gates match: the repo is
47
+ flanner-managed, the target is a .md, and it sits inside that project's
48
+ plan directory. Any missing field or lookup miss allows the write.
49
+ """
50
+ tool_input = payload.get("tool_input") or {}
51
+ file_path = tool_input.get("file_path")
52
+ cwd = payload.get("cwd")
53
+ if not file_path or not cwd:
54
+ return None
55
+
56
+ target = _resolve(file_path, cwd)
57
+
58
+ root = find_git_root(str(target.parent)) or find_git_root(cwd)
59
+ if not root:
60
+ return None
61
+
62
+ project = get_project_by_root(session, root)
63
+ if not project or not project.project_root:
64
+ return None # gate 1: not a flanner repo
65
+
66
+ plan_dir = (Path(project.project_root) / project.plan_directory).resolve()
67
+ if target.suffix != ".md" or not target.is_relative_to(plan_dir):
68
+ return None # gate 2: not a plan file
69
+
70
+ return _deny(_steer_message(project, target)) # gate 3: wrong tool for a plan
71
+
72
+
73
+ def _steer_message(project: ProjectModel, target: Path) -> str:
74
+ return (
75
+ f"{target.name} is a flanner-managed plan file (project '{project.name}', "
76
+ f"dir '{project.plan_directory}'). Don't write it directly; the plan header "
77
+ f"and versioning are added by the flanner MCP tools. To create it, call "
78
+ f"create_plan_file_tool(project_id='{project.id}', name='{target.stem}', "
79
+ f"content=<body without frontmatter>). To revise an existing plan, use "
80
+ f"update_plan_file_tool(plan_file_id=...)."
81
+ )
82
+
83
+
84
+ def _deny(reason: str) -> dict[str, Any]:
85
+ return {
86
+ "hookSpecificOutput": {
87
+ "hookEventName": "PreToolUse",
88
+ "permissionDecision": "deny",
89
+ "permissionDecisionReason": reason,
90
+ }
91
+ }
92
+
93
+
94
+ def run_guard_write(raw_stdin: str, session: Session) -> str:
95
+ """Read a PreToolUse payload, return the JSON to print (empty = allow).
96
+
97
+ Fails open: any error yields an allow, so a broken guard never blocks
98
+ a legitimate write.
99
+ """
100
+ try:
101
+ payload = json.loads(raw_stdin) if raw_stdin.strip() else {}
102
+ decision = decide_write(payload, session)
103
+ except Exception:
104
+ return ""
105
+ return json.dumps(decision) if decision else ""
106
+
107
+
108
+ # Agent-instruction files flanner writes the managed block into. CLAUDE.md is
109
+ # read by Claude Code; AGENTS.md is the cross-tool convention read by Codex and
110
+ # others. The block is tool-agnostic (it points at the MCP tools), so both get
111
+ # the same guidance.
112
+ AGENT_MD_FILES = ("CLAUDE.md", "AGENTS.md")
113
+
114
+
115
+ def agent_md_block(project: ProjectModel) -> str:
116
+ """The managed section naming the plan dir and the tools to use."""
117
+ return (
118
+ f"{AGENT_MD_START}\n"
119
+ f"## Plan files (managed by flanner)\n\n"
120
+ f"Design, architecture, and planning markdown for this repo is managed by "
121
+ f"flanner and lives in `{project.plan_directory}/` (project: {project.name}).\n\n"
122
+ f"When the user asks to save a plan/design/architecture doc, confirm it is a "
123
+ f"plan, then use the flanner MCP tools instead of writing the file directly:\n\n"
124
+ f"- `get_plan_config` to confirm the location and header format\n"
125
+ f"- `create_plan_file_tool(project_id, name, content)` to create it "
126
+ f"(adds the YAML header and versions it)\n"
127
+ f"- `update_plan_file_tool(plan_file_id, content)` to revise it\n\n"
128
+ f"Never hand-write the YAML header; the tools generate it.\n"
129
+ f"{AGENT_MD_END}"
130
+ )
131
+
132
+
133
+ def upsert_agent_md(root: str, filename: str, block: str) -> bool:
134
+ """Write or replace the managed block in <root>/<filename>. Returns True if changed."""
135
+ path = Path(root) / filename
136
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
137
+
138
+ if AGENT_MD_START in existing and AGENT_MD_END in existing:
139
+ head, _, rest = existing.partition(AGENT_MD_START)
140
+ _, _, tail = rest.partition(AGENT_MD_END)
141
+ updated = f"{head.rstrip()}\n\n{block}\n{tail.lstrip()}".strip() + "\n"
142
+ elif existing.strip():
143
+ updated = existing.rstrip() + "\n\n" + block + "\n"
144
+ else:
145
+ updated = block + "\n"
146
+
147
+ if updated == existing:
148
+ return False
149
+ path.write_text(updated, encoding="utf-8")
150
+ return True
151
+
152
+
153
+ def ensure_settings_hook(root: str) -> bool:
154
+ """Merge the guard-write PreToolUse hook into <root>/.claude/settings.json.
155
+
156
+ Idempotent; returns True if the file was changed.
157
+ """
158
+ settings_path = Path(root) / ".claude" / "settings.json"
159
+ settings: dict[str, Any] = {}
160
+ if settings_path.exists():
161
+ try:
162
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
163
+ except json.JSONDecodeError:
164
+ settings = {}
165
+
166
+ hooks = settings.setdefault("hooks", {})
167
+ pre = hooks.setdefault("PreToolUse", [])
168
+
169
+ already = any(
170
+ h.get("type") == "command" and h.get("command") == HOOK_COMMAND
171
+ for entry in pre
172
+ if isinstance(entry, dict)
173
+ for h in entry.get("hooks", [])
174
+ )
175
+ if already:
176
+ return False
177
+
178
+ pre.append(
179
+ {
180
+ "matcher": HOOK_MATCHER,
181
+ "hooks": [{"type": "command", "command": HOOK_COMMAND}],
182
+ }
183
+ )
184
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
185
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
186
+ return True
187
+
188
+
189
+ SKILL_NAME = "flanner-plan"
190
+
191
+ _SKILL_BODY = """---
192
+ name: flanner-plan
193
+ description: >
194
+ Save or update a plan, design, architecture, migration, or RFC document
195
+ through flanner so it is placed in the managed plan directory, given the
196
+ standard YAML header, and versioned. Use when the user asks to write or
197
+ revise any planning markdown that should be tracked, or when you are about
198
+ to create such a document yourself.
199
+ ---
200
+
201
+ # Saving a plan through flanner
202
+
203
+ This repo manages planning docs with flanner. Do not write them with the
204
+ Write tool; the plan directory and header are handled by the MCP tools.
205
+
206
+ 1. Confirm intent. If it is ambiguous whether a markdown file is a plan
207
+ (versus a README, changelog, or notes), ask the user before proceeding.
208
+ 2. Resolve the target with `get_plan_config` and `list_projects`.
209
+ 3. Create with `create_plan_file_tool(project_id, name, content)`, passing the
210
+ markdown body WITHOUT frontmatter; the header is added for you.
211
+ 4. Revise an existing plan with `update_plan_file_tool(plan_file_id, content)`,
212
+ which bumps the version and re-hashes the content.
213
+
214
+ Never hand-write the YAML header, and never place plan files outside the
215
+ directory reported by `get_plan_config`.
216
+ """
217
+
218
+
219
+ def install_skill(root: str) -> bool:
220
+ """Write the flanner-plan skill into <root>/.claude/skills. Returns True if changed."""
221
+ skill_path = Path(root) / ".claude" / "skills" / SKILL_NAME / "SKILL.md"
222
+ if skill_path.exists() and skill_path.read_text(encoding="utf-8") == _SKILL_BODY:
223
+ return False
224
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
225
+ skill_path.write_text(_SKILL_BODY, encoding="utf-8")
226
+ return True
@@ -0,0 +1,386 @@
1
+ """
2
+ Claude Code Integration for Flanner
3
+
4
+ Handles automatic registration of MCP server with Claude Code.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import platform
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def get_claude_config_path() -> Path | None:
17
+ """
18
+ Get the path to Claude Desktop configuration file.
19
+
20
+ Returns:
21
+ Path to claude_desktop_config.json or None if not found
22
+ """
23
+ system = platform.system()
24
+ home = Path.home()
25
+
26
+ # Possible Claude Desktop config locations
27
+ possible_paths = []
28
+
29
+ if system == "Windows":
30
+ # Windows paths
31
+ possible_paths = [
32
+ home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
33
+ home / ".claude" / "claude_desktop_config.json",
34
+ ]
35
+ elif system == "Darwin": # macOS
36
+ possible_paths = [
37
+ home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
38
+ home / ".claude" / "claude_desktop_config.json",
39
+ ]
40
+ else: # Linux
41
+ possible_paths = [
42
+ home / ".config" / "claude" / "claude_desktop_config.json",
43
+ home / ".claude" / "claude_desktop_config.json",
44
+ ]
45
+
46
+ # Return first existing path
47
+ for path in possible_paths:
48
+ if path.exists():
49
+ return path
50
+
51
+ # Return default path (even if it doesn't exist yet)
52
+ return possible_paths[0] if possible_paths else None
53
+
54
+
55
+ def read_claude_config() -> dict[str, Any]:
56
+ """
57
+ Read Claude Code MCP settings.
58
+
59
+ Returns:
60
+ Dictionary of MCP settings, or empty dict with mcpServers key
61
+ """
62
+ config_path = get_claude_config_path()
63
+
64
+ if not config_path or not config_path.exists():
65
+ return {"mcpServers": {}}
66
+
67
+ try:
68
+ with open(config_path, encoding="utf-8") as f:
69
+ config: dict[str, Any] = json.load(f)
70
+
71
+ # Ensure mcpServers key exists
72
+ if "mcpServers" not in config:
73
+ config["mcpServers"] = {}
74
+
75
+ return config
76
+ except Exception as e:
77
+ logger.warning("Could not read Claude config: %s", e)
78
+ return {"mcpServers": {}}
79
+
80
+
81
+ def write_claude_config(config: dict[str, Any]) -> bool:
82
+ """
83
+ Write Claude Code MCP settings.
84
+
85
+ Args:
86
+ config: Configuration dictionary
87
+
88
+ Returns:
89
+ True if successful, False otherwise
90
+ """
91
+ config_path = get_claude_config_path()
92
+
93
+ if not config_path:
94
+ return False
95
+
96
+ try:
97
+ # Ensure directory exists
98
+ config_path.parent.mkdir(parents=True, exist_ok=True)
99
+
100
+ # Write config with pretty formatting
101
+ with open(config_path, "w", encoding="utf-8") as f:
102
+ json.dump(config, f, indent=2)
103
+
104
+ return True
105
+ except Exception as e:
106
+ logger.error("Could not write Claude config: %s", e)
107
+ return False
108
+
109
+
110
+ def get_local_server_config() -> dict[str, Any]:
111
+ """
112
+ Get the configuration for local MCP server.
113
+
114
+ Returns:
115
+ Server configuration dictionary
116
+ """
117
+ # Get absolute path to the project directory
118
+ project_dir = Path(__file__).resolve().parent.parent
119
+
120
+ return {
121
+ "command": "python",
122
+ "args": ["-m", "flanner.server"],
123
+ "cwd": str(project_dir),
124
+ "env": {
125
+ # Add any environment variables if needed
126
+ },
127
+ }
128
+
129
+
130
+ def get_cloud_server_config(server_url: str, api_key: str | None = None) -> dict[str, Any]:
131
+ """
132
+ Get the configuration for cloud-based MCP server (future use).
133
+
134
+ Args:
135
+ server_url: URL of the cloud MCP server
136
+ api_key: Optional API key for authentication
137
+
138
+ Returns:
139
+ Server configuration dictionary
140
+ """
141
+ config = {
142
+ "type": "cloud",
143
+ "url": server_url,
144
+ }
145
+
146
+ if api_key:
147
+ config["apiKey"] = api_key
148
+
149
+ return config
150
+
151
+
152
+ def is_server_registered(server_name: str = "flanner") -> bool:
153
+ """
154
+ Check if MCP server is registered in Claude Code.
155
+
156
+ Args:
157
+ server_name: Name of the server to check
158
+
159
+ Returns:
160
+ True if server is registered, False otherwise
161
+ """
162
+ config = read_claude_config()
163
+ return server_name in config.get("mcpServers", {})
164
+
165
+
166
+ def get_server_config_from_claude(server_name: str = "flanner") -> dict[str, Any] | None:
167
+ """
168
+ Get the current server configuration from Claude Code.
169
+
170
+ Args:
171
+ server_name: Name of the server
172
+
173
+ Returns:
174
+ Server configuration or None if not found
175
+ """
176
+ config = read_claude_config()
177
+ server_config: dict[str, Any] | None = config.get("mcpServers", {}).get(server_name)
178
+ return server_config
179
+
180
+
181
+ def register_mcp_server(
182
+ server_name: str = "flanner",
183
+ server_type: str = "local",
184
+ server_url: str | None = None,
185
+ api_key: str | None = None,
186
+ force: bool = False,
187
+ ) -> tuple[bool, str]:
188
+ """
189
+ Register MCP server with Claude Code.
190
+
191
+ Args:
192
+ server_name: Name to register the server under
193
+ server_type: "local" or "cloud"
194
+ server_url: URL for cloud servers
195
+ api_key: API key for cloud servers
196
+ force: If True, overwrite existing configuration
197
+
198
+ Returns:
199
+ Tuple of (success: bool, message: str)
200
+ """
201
+ # Get current config
202
+ config = read_claude_config()
203
+
204
+ # Check if already registered
205
+ if server_name in config["mcpServers"] and not force:
206
+ return True, f"Server '{server_name}' is already registered in Claude Code"
207
+
208
+ # Get server configuration based on type
209
+ if server_type == "local":
210
+ server_config = get_local_server_config()
211
+ elif server_type == "cloud":
212
+ if not server_url:
213
+ return False, "Cloud server requires a URL"
214
+ server_config = get_cloud_server_config(server_url, api_key)
215
+ else:
216
+ return False, f"Invalid server type: {server_type}"
217
+
218
+ # Add server to config
219
+ config["mcpServers"][server_name] = server_config
220
+
221
+ # Write config
222
+ if write_claude_config(config):
223
+ action = "Updated" if force else "Registered"
224
+ return True, f"{action} MCP server '{server_name}' in Claude Code"
225
+ else:
226
+ return False, "Failed to write Claude Code configuration"
227
+
228
+
229
+ def unregister_mcp_server(server_name: str = "flanner") -> tuple[bool, str]:
230
+ """
231
+ Unregister MCP server from Claude Code.
232
+
233
+ Args:
234
+ server_name: Name of the server to unregister
235
+
236
+ Returns:
237
+ Tuple of (success: bool, message: str)
238
+ """
239
+ config = read_claude_config()
240
+
241
+ if server_name not in config.get("mcpServers", {}):
242
+ return False, f"Server '{server_name}' is not registered in Claude Code"
243
+
244
+ # Remove server
245
+ del config["mcpServers"][server_name]
246
+
247
+ # Write config
248
+ if write_claude_config(config):
249
+ return True, f"Unregistered MCP server '{server_name}' from Claude Code"
250
+ else:
251
+ return False, "Failed to write Claude Code configuration"
252
+
253
+
254
+ def verify_server_config() -> tuple[bool, str, dict[str, Any] | None]:
255
+ """
256
+ Verify that the registered MCP server configuration is correct.
257
+
258
+ Returns:
259
+ Tuple of (is_valid: bool, message: str, current_config: Optional[Dict])
260
+ """
261
+ server_name = "flanner"
262
+
263
+ # Check if registered
264
+ if not is_server_registered(server_name):
265
+ return False, "MCP server is not registered in Claude Code", None
266
+
267
+ # Get current config
268
+ current_config = get_server_config_from_claude(server_name)
269
+ expected_config = get_local_server_config()
270
+
271
+ # Compare configurations
272
+ if current_config == expected_config:
273
+ return True, "MCP server configuration is correct", current_config
274
+ else:
275
+ return False, "MCP server configuration has changed", current_config
276
+
277
+
278
+ def get_claude_config_info() -> dict[str, Any]:
279
+ """
280
+ Get information about Claude Code configuration.
281
+
282
+ Returns:
283
+ Dictionary with configuration information
284
+ """
285
+ config_path = get_claude_config_path()
286
+
287
+ info = {
288
+ "config_path": str(config_path) if config_path else "Not found",
289
+ "config_exists": config_path.exists() if config_path else False,
290
+ "server_registered": is_server_registered(),
291
+ "total_servers": 0,
292
+ "our_server_config": None,
293
+ }
294
+
295
+ if config_path and config_path.exists():
296
+ config = read_claude_config()
297
+ info["total_servers"] = len(config.get("mcpServers", {}))
298
+ info["our_server_config"] = config.get("mcpServers", {}).get("flanner")
299
+
300
+ return info
301
+
302
+
303
+ def auto_register_on_init() -> tuple[bool, str]:
304
+ """
305
+ Automatically register MCP server on first init.
306
+ This is called during 'mcp-plan init' command.
307
+
308
+ Returns:
309
+ Tuple of (success: bool, message: str)
310
+ """
311
+ # Check if already registered
312
+ if is_server_registered():
313
+ # Verify configuration is correct
314
+ is_valid, msg, current_config = verify_server_config()
315
+
316
+ if is_valid:
317
+ return True, "MCP server already registered and up-to-date"
318
+ else:
319
+ # Configuration changed, update it
320
+ success, update_msg = register_mcp_server(force=True)
321
+ if success:
322
+ return True, "MCP server configuration updated"
323
+ else:
324
+ return False, f"Failed to update configuration: {update_msg}"
325
+ else:
326
+ # Not registered, register it
327
+ return register_mcp_server()
328
+
329
+
330
+ def check_server_status() -> dict[str, Any]:
331
+ """
332
+ Check the status of MCP server registration.
333
+ This is called during 'mcp-plan status' command.
334
+
335
+ Returns:
336
+ Dictionary with status information
337
+ """
338
+ status = {
339
+ "registered": False,
340
+ "config_valid": False,
341
+ "config_path": None,
342
+ "message": "",
343
+ "action_needed": None,
344
+ }
345
+
346
+ config_path = get_claude_config_path()
347
+ status["config_path"] = str(config_path) if config_path else "Not found"
348
+
349
+ if not config_path:
350
+ status["message"] = "Claude Code configuration path not found"
351
+ status["action_needed"] = "Please ensure Claude Code is installed"
352
+ return status
353
+
354
+ if not is_server_registered():
355
+ status["message"] = "MCP server is not registered in Claude Code"
356
+ status["action_needed"] = "Run: flanner init (or manually register)"
357
+ return status
358
+
359
+ status["registered"] = True
360
+
361
+ # Verify configuration
362
+ is_valid, msg, current_config = verify_server_config()
363
+ status["config_valid"] = is_valid
364
+ status["message"] = msg
365
+
366
+ if not is_valid:
367
+ status["action_needed"] = "Run: flanner init (to update configuration)"
368
+
369
+ return status
370
+
371
+
372
+ def print_registration_instructions() -> None:
373
+ """
374
+ Print manual registration instructions for Claude Code.
375
+ """
376
+ server_config = get_local_server_config()
377
+
378
+ print("\n" + "=" * 60)
379
+ print("CLAUDE CODE MCP SERVER CONFIGURATION")
380
+ print("=" * 60)
381
+ print("\nTo manually add the MCP server to Claude Code:")
382
+ print("\n1. Open Claude Code settings")
383
+ print("2. Add the following to your MCP settings:\n")
384
+ print(json.dumps({"mcpServers": {"flanner": server_config}}, indent=2))
385
+ print("\n3. Restart Claude Code")
386
+ print("=" * 60 + "\n")