Skip to content

Commit 0168fbd

Browse files
committed
feat: polish CLI with interactive setup and colored output
1 parent 2691ae2 commit 0168fbd

1 file changed

Lines changed: 210 additions & 89 deletions

File tree

src/devscontext/cli.py

Lines changed: 210 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -9,118 +9,209 @@
99
serve: Start the MCP server (default)
1010
1111
Example:
12-
# Start the server
12+
devscontext init
13+
devscontext test --task PROJ-123
1314
devscontext serve
14-
15-
# Test with a specific ticket
16-
devscontext test --ticket PROJ-123
1715
"""
1816

1917
from __future__ import annotations
2018

19+
import sys
20+
import time
21+
from pathlib import Path
22+
2123
import click
2224

2325
from devscontext import __version__
2426

2527

28+
def _success(msg: str) -> str:
29+
"""Format success message with green checkmark."""
30+
return click.style("✓", fg="green") + " " + msg
31+
32+
33+
def _error(msg: str) -> str:
34+
"""Format error message with red X."""
35+
return click.style("✗", fg="red") + " " + msg
36+
37+
38+
def _info(msg: str) -> str:
39+
"""Format info message with blue arrow."""
40+
return click.style("→", fg="blue") + " " + msg
41+
42+
2643
@click.group(invoke_without_command=True)
2744
@click.version_option(version=__version__, prog_name="devscontext")
45+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
2846
@click.pass_context
29-
def cli(ctx: click.Context) -> None:
47+
def cli(ctx: click.Context, verbose: bool) -> None:
3048
"""DevsContext - MCP server for AI coding context.
3149
3250
Provides synthesized engineering context from Jira, meeting transcripts,
3351
and local documentation to AI coding assistants.
34-
35-
If no command is specified, defaults to 'serve'.
3652
"""
53+
ctx.ensure_object(dict)
54+
ctx.obj["verbose"] = verbose
55+
3756
if ctx.invoked_subcommand is None:
38-
# Default to serve if no command specified
3957
ctx.invoke(serve)
4058

4159

4260
@cli.command()
4361
def init() -> None:
44-
"""Create .devscontext.yaml configuration interactively.
45-
46-
Creates a starter configuration file with common defaults.
47-
"""
48-
from pathlib import Path
49-
62+
"""Create .devscontext.yaml configuration interactively."""
5063
config_path = Path(".devscontext.yaml")
64+
gitignore_path = Path(".gitignore")
5165

5266
if config_path.exists():
5367
click.echo(f"Config file already exists: {config_path}")
5468
if not click.confirm("Overwrite?", default=False):
5569
click.echo("Aborted.")
5670
return
5771

58-
config_content = """\
59-
# DevsContext Configuration
60-
# See https://github.com/anthropics/devscontext for documentation
61-
62-
adapters:
63-
jira:
64-
enabled: true
65-
base_url: "https://your-company.atlassian.net"
66-
email: "${JIRA_EMAIL}"
67-
api_token: "${JIRA_API_TOKEN}"
68-
69-
fireflies:
70-
enabled: false
71-
api_key: "${FIREFLIES_API_KEY}"
72-
73-
local_docs:
74-
enabled: true
75-
paths:
76-
- "./docs"
77-
- "./CLAUDE.md"
78-
79-
synthesis:
80-
provider: "anthropic"
81-
model: "claude-3-haiku-20240307"
82-
83-
cache:
84-
ttl_seconds: 300
85-
max_size: 100
86-
"""
72+
click.echo()
73+
click.echo(click.style("DevsContext Setup", bold=True))
74+
click.echo()
8775

76+
# Jira configuration
77+
jira_enabled = click.confirm("Configure Jira?", default=True)
78+
jira_url = ""
79+
if jira_enabled:
80+
jira_url = click.prompt(
81+
" Jira URL",
82+
default="https://your-company.atlassian.net",
83+
)
84+
click.echo(" " + _info("Set JIRA_EMAIL and JIRA_API_TOKEN environment variables"))
85+
86+
# Fireflies configuration
87+
click.echo()
88+
fireflies_enabled = click.confirm("Configure Fireflies (meeting transcripts)?", default=False)
89+
if fireflies_enabled:
90+
click.echo(" " + _info("Set FIREFLIES_API_KEY environment variable"))
91+
92+
# Local docs configuration
93+
click.echo()
94+
docs_enabled = click.confirm("Configure local docs?", default=True)
95+
docs_paths: list[str] = []
96+
if docs_enabled:
97+
default_paths = "./docs"
98+
if Path("CLAUDE.md").exists():
99+
default_paths = "./docs, ./CLAUDE.md"
100+
paths_input = click.prompt(" Doc paths (comma-separated)", default=default_paths)
101+
docs_paths = [p.strip() for p in paths_input.split(",") if p.strip()]
102+
103+
# Build config
104+
config_lines = [
105+
"# DevsContext Configuration",
106+
"",
107+
"adapters:",
108+
" jira:",
109+
f" enabled: {str(jira_enabled).lower()}",
110+
]
111+
112+
if jira_enabled:
113+
config_lines.extend(
114+
[
115+
f' base_url: "{jira_url}"',
116+
' email: "${JIRA_EMAIL}"',
117+
' api_token: "${JIRA_API_TOKEN}"',
118+
]
119+
)
120+
121+
config_lines.extend(
122+
[
123+
"",
124+
" fireflies:",
125+
f" enabled: {str(fireflies_enabled).lower()}",
126+
]
127+
)
128+
129+
if fireflies_enabled:
130+
config_lines.append(' api_key: "${FIREFLIES_API_KEY}"')
131+
132+
config_lines.extend(
133+
[
134+
"",
135+
" local_docs:",
136+
f" enabled: {str(docs_enabled).lower()}",
137+
]
138+
)
139+
140+
if docs_enabled and docs_paths:
141+
config_lines.append(" paths:")
142+
for path in docs_paths:
143+
config_lines.append(f' - "{path}"')
144+
145+
config_lines.extend(
146+
[
147+
"",
148+
"synthesis:",
149+
' provider: "anthropic"',
150+
' model: "claude-3-haiku-20240307"',
151+
"",
152+
"cache:",
153+
" ttl_seconds: 300",
154+
" max_size: 100",
155+
"",
156+
]
157+
)
158+
159+
config_content = "\n".join(config_lines)
88160
config_path.write_text(config_content)
89-
click.echo(f"Created {config_path}")
161+
162+
# Add to .gitignore if not already there
163+
gitignore_updated = False
164+
if gitignore_path.exists():
165+
gitignore_content = gitignore_path.read_text()
166+
if ".devscontext.yaml" not in gitignore_content:
167+
with gitignore_path.open("a") as f:
168+
if not gitignore_content.endswith("\n"):
169+
f.write("\n")
170+
f.write("\n# DevsContext config (contains env var references)\n")
171+
f.write(".devscontext.yaml\n")
172+
gitignore_updated = True
173+
else:
174+
gitignore_path.write_text("# DevsContext config\n.devscontext.yaml\n")
175+
gitignore_updated = True
176+
177+
# Success message
90178
click.echo()
91-
click.echo("Next steps:")
92-
click.echo(" 1. Edit .devscontext.yaml with your Jira URL")
93-
click.echo(" 2. Set environment variables:")
94-
click.echo(" export JIRA_EMAIL='your-email@company.com'")
95-
click.echo(" export JIRA_API_TOKEN='your-api-token'")
96-
click.echo(" 3. Test: devscontext test --ticket YOUR-123")
179+
click.echo(_success(f"Created {config_path}"))
180+
if gitignore_updated:
181+
click.echo(_success("Added .devscontext.yaml to .gitignore"))
182+
183+
click.echo()
184+
click.echo(click.style("Next steps:", bold=True))
185+
if jira_enabled:
186+
click.echo(" export JIRA_EMAIL='your-email@company.com'")
187+
click.echo(" export JIRA_API_TOKEN='your-api-token'")
188+
if fireflies_enabled:
189+
click.echo(" export FIREFLIES_API_KEY='your-api-key'")
190+
click.echo(" export ANTHROPIC_API_KEY='your-api-key'")
191+
click.echo()
192+
click.echo(" devscontext test --task YOUR-123")
97193

98194

99195
@cli.command()
100-
@click.option(
101-
"--ticket",
102-
"-t",
103-
default=None,
104-
help="Jira ticket ID to test with (e.g., PROJ-123)",
105-
)
106-
def test(ticket: str | None) -> None:
107-
"""Test connection to configured adapters.
108-
109-
Verifies that all enabled adapters can connect to their respective
110-
services. Optionally tests fetching context for a specific ticket.
111-
"""
196+
@click.option("--task", "-t", default=None, help="Jira ticket ID (e.g., PROJ-123)")
197+
@click.pass_context
198+
def test(ctx: click.Context, task: str | None) -> None:
199+
"""Test connection to configured adapters."""
112200
import asyncio
113201

114202
from devscontext.config import load_devscontext_config
115203
from devscontext.core import DevsContextCore
116204

205+
verbose = ctx.obj.get("verbose", False)
206+
117207
try:
118208
config = load_devscontext_config()
119209
except FileNotFoundError:
120-
click.echo("No .devscontext.yaml found. Run 'devscontext init' first.")
121-
return
210+
click.echo(_error("No .devscontext.yaml found. Run 'devscontext init' first."))
211+
sys.exit(1)
122212

123-
click.echo("Testing adapter connections...")
213+
click.echo()
214+
click.echo(click.style("Connection Status", bold=True))
124215
click.echo()
125216

126217
core = DevsContextCore(config)
@@ -129,47 +220,77 @@ async def run_health_checks() -> dict[str, bool]:
129220
return await core.health_check()
130221

131222
results = asyncio.run(run_health_checks())
223+
healthy_count = sum(1 for h in results.values() if h)
132224

133225
for adapter, healthy in results.items():
134-
status = click.style("✓", fg="green") if healthy else click.style("✗", fg="red")
135-
click.echo(f" {status} {adapter}")
226+
if healthy:
227+
click.echo(" " + _success(adapter))
228+
else:
229+
click.echo(" " + _error(f"{adapter} (check credentials)"))
230+
231+
click.echo()
232+
233+
if not task:
234+
click.echo(_info("Use --task PROJ-123 to test fetching context"))
235+
return
136236

237+
if healthy_count == 0:
238+
click.echo(_error("No healthy adapters. Fix connections before testing."))
239+
sys.exit(1)
240+
241+
click.echo(click.style(f"Fetching context for {task}...", bold=True))
137242
click.echo()
138243

139-
if ticket:
140-
click.echo(f"Fetching context for {ticket}...")
244+
start_time = time.monotonic()
141245

142-
async def fetch_context() -> str:
143-
result = await core.get_task_context(ticket)
144-
return result.synthesized
246+
async def fetch_context() -> tuple[str, list[str]]:
247+
result = await core.get_task_context(task)
248+
return result.synthesized, result.sources_used
145249

146-
try:
147-
output = asyncio.run(fetch_context())
148-
click.echo()
149-
click.echo(output)
150-
except Exception as e:
151-
click.echo(f"Error: {e}", err=True)
152-
else:
153-
click.echo("Use --ticket PROJ-123 to test fetching a ticket.")
250+
try:
251+
output, sources = asyncio.run(fetch_context())
252+
duration = time.monotonic() - start_time
253+
254+
click.echo(output)
255+
click.echo()
256+
click.echo(
257+
click.style(
258+
f"Fetched from {len(sources)} source(s) and synthesized in {duration:.1f}s",
259+
fg="cyan",
260+
)
261+
)
262+
except Exception as e:
263+
if verbose:
264+
import traceback
265+
266+
click.echo(traceback.format_exc(), err=True)
267+
click.echo(_error(f"Failed: {e}"), err=True)
268+
sys.exit(1)
154269

155270

156271
@cli.command()
157-
def serve() -> None:
158-
"""Start the MCP server (stdio transport).
272+
@click.pass_context
273+
def serve(ctx: click.Context) -> None:
274+
"""Start the MCP server (stdio transport)."""
275+
276+
# Print startup message to stderr (stdout is for MCP protocol)
277+
click.echo(
278+
click.style("DevsContext", bold=True) + " MCP server running",
279+
err=True,
280+
)
281+
click.echo(
282+
"Tools: get_task_context, search_context, get_standards",
283+
err=True,
284+
)
285+
click.echo(err=True)
159286

160-
Runs the Model Context Protocol server over stdio, allowing
161-
AI coding assistants to connect and request context.
162-
"""
163287
from devscontext.server import main as server_main
164288

165289
server_main()
166290

167291

168292
def main() -> None:
169-
"""Main entry point for the CLI.
170-
171-
Invokes the click command group.
172-
"""
293+
"""Main entry point for the CLI."""
173294
cli()
174295

175296

0 commit comments

Comments
 (0)