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 +7 -0
- flanner/__main__.py +8 -0
- flanner/agent_hooks.py +226 -0
- flanner/claude_integration.py +386 -0
- flanner/cli.py +1283 -0
- flanner/database.py +946 -0
- flanner/exceptions.py +51 -0
- flanner/frontmatter.py +213 -0
- flanner/git_integration.py +321 -0
- flanner/jira_utils.py +138 -0
- flanner/plan_ops.py +68 -0
- flanner/server.py +981 -0
- flanner/storage.py +242 -0
- flanner/utils.py +225 -0
- flanner/web/static/css/styles.css +843 -0
- flanner/web/static/js/app.js +145 -0
- flanner/web/templates/_empty_glyph.html +12 -0
- flanner/web/templates/base.html +66 -0
- flanner/web/templates/dashboard.html +109 -0
- flanner/web/templates/error.html +13 -0
- flanner/web/templates/plan_edit.html +85 -0
- flanner/web/templates/plan_history.html +110 -0
- flanner/web/templates/plan_new.html +92 -0
- flanner/web/templates/plan_view.html +88 -0
- flanner/web/templates/project_detail.html +109 -0
- flanner/web/templates/project_new.html +93 -0
- flanner/web/templates/projects.html +72 -0
- flanner/web.py +788 -0
- flanner-0.4.0.dist-info/METADATA +189 -0
- flanner-0.4.0.dist-info/RECORD +34 -0
- flanner-0.4.0.dist-info/WHEEL +5 -0
- flanner-0.4.0.dist-info/entry_points.txt +2 -0
- flanner-0.4.0.dist-info/licenses/LICENSE +21 -0
- flanner-0.4.0.dist-info/top_level.txt +1 -0
flanner/__init__.py
ADDED
flanner/__main__.py
ADDED
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")
|